diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32c0131a5..eaced973d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,12 +225,18 @@ jobs: - 'tests/CMakeLists.txt' - 'tests/test_manifest.toml' - 'scripts/ci_select_tests.py' + # The M4 closure is executed inside the installed MPI/VTK lane below. Any edit to + # its ledger, runner, source fence, or workflow must therefore require this job on + # the PR that changes the proof, never wait for a later nightly run. + - 'tests/gates/m4_runtime_io.toml' + - 'tests/python/architecture/test_m4_runtime_io_gate.py' + - 'scripts/run_m4_gate.py' + - '.github/workflows/ci.yml' - 'cmake/**' - 'CMakeLists.txt' - 'CMakePresets.json' - # Les changements de workflows/actions CI sont valides par revue + lint YAML local, - # et ne compilent pas Kokkos par defaut. Ajouter `ci-kokkos` pour forcer les gates - # Serial, ou `ci-full` pour MPI + OpenMP. + # Les autres changements de workflows/actions CI sont valides par revue + lint YAML + # local. Le workflow CI lui-meme fait exception car il porte la lane M4 executable. # full : un push master ne lance la suite COMPLETE (MPI + Kokkos OpenMP) que si un # chemin build/backend a bouge. Conservateur a dessein -- couvre tout ce qui peut affecter # l'artefact compile OU le chemin DSL/production exerce par le job kokkos-openmp (dsl.py et @@ -305,7 +311,7 @@ jobs: gate-cpp-prewarm: name: ubuntu-latest / Kokkos Serial (C++ prewarm ${{ matrix.lane }}) runs-on: ubuntu-latest - timeout-minutes: 22 + timeout-minutes: 30 needs: [changes, set-mode] if: needs.set-mode.outputs.cpp_required == 'true' strategy: @@ -365,7 +371,12 @@ jobs: return "$status" } lane_parallelism=4 + lane_watchdog=18m case "${{ matrix.lane }}" in + system) + lane_parallelism=2 + lane_watchdog=24m + ;; amr-base|amr-compressible) lane_parallelism=2 ;; esac export NINJA_STATUS='[%f/%t elapsed=%es active=%r] ' @@ -381,7 +392,7 @@ jobs: --contract-file "$RUNNER_TEMP/cpp-prewarm-contract-${{ matrix.lane }}.json" ) test "${#object_targets[@]}" -gt 0 - run_with_heartbeat "C++ prewarm ${{ matrix.lane }}" 18m \ + run_with_heartbeat "C++ prewarm ${{ matrix.lane }}" "$lane_watchdog" \ cmake --build --preset ci-kokkos --parallel "$lane_parallelism" \ --target "${object_targets[@]}" ccache -s @@ -592,6 +603,17 @@ jobs: --verify-contracts "${compile_contracts[@]}" read -r -a cpp_targets <<< "${{ steps.test-plan.outputs.cpp_shard_targets }}" if [ "${#cpp_targets[@]}" -eq 0 ]; then + # Shard 0 owns target-less CTest contracts even when affected-test routing + # assigns it no executable target. + if [ "${{ matrix.shard }}" -eq 0 ]; then + ctest_inventory="$RUNNER_TEMP/ctest-shard-${{ matrix.shard }}.json" + standalone_regex_file="$RUNNER_TEMP/ctest-standalone-shard-${{ matrix.shard }}.regex" + ctest --preset ci-kokkos -N --show-only=json-v1 > "$ctest_inventory" + python3 scripts/ci_select_tests.py verify-cpp-target-labels \ + --ctest-json "$ctest_inventory" \ + --targets "${cpp_targets[@]}" \ + --standalone-regex-file "$standalone_regex_file" + fi echo "No affected C++ tests selected for shard ${{ matrix.shard }}." exit 0 fi @@ -715,6 +737,9 @@ jobs: - name: M3 AMR and multi-layout gate manifest run: python3 scripts/run_m3_gate.py --check-only + - name: M4 native runtime and scientific I/O gate manifest + run: python3 scripts/run_m4_gate.py --check-only + - name: Generated component catalog env: PYTHONPATH: ${{ github.workspace }}/python @@ -732,7 +757,7 @@ jobs: gate-python-prewarm: name: ubuntu-latest / Kokkos Serial (Python prewarm ${{ matrix.lane }}) runs-on: ubuntu-latest - timeout-minutes: 22 + timeout-minutes: 30 needs: [changes, set-mode] if: needs.set-mode.outputs.python_required == 'true' strategy: @@ -828,15 +853,19 @@ jobs: --contract-file "$RUNNER_TEMP/python-prewarm-contract-${{ matrix.lane }}.json" ) test "${#object_targets[@]}" -gt 0 - # Four simultaneous GCC frontends for the heavy AMR block seams exhaust a 16 GiB hosted - # runner. Two semantic, disjoint lanes keep their independently measured critical paths - # below the watchdog; each uses two frontends and preserves O3. The lighter System lane - # retains all four runner cores. + # Four simultaneous GCC frontends now also exhaust the 16 GiB runner on the grown System + # seam. Bound that lane to two frontends and give its cold O3 path the same measured + # watchdog margin as OpenMP; the four semantic lanes still execute independently. lane_parallelism=4 + lane_watchdog=18m case "${{ matrix.lane }}" in + system) + lane_parallelism=2 + lane_watchdog=24m + ;; amr-base|amr-compressible) lane_parallelism=2 ;; esac - run_with_heartbeat "Python prewarm ${{ matrix.lane }}" 18m \ + run_with_heartbeat "Python prewarm ${{ matrix.lane }}" "$lane_watchdog" \ cmake --build --preset ci-kokkos-python --parallel "$lane_parallelism" \ --target "${object_targets[@]}" ccache -s @@ -1438,8 +1467,8 @@ jobs: name: ubuntu-24.04 / MPI + Kokkos Serial (C++ prewarm ${{ matrix.lane }}) runs-on: ubuntu-24.04 # setup-kokkos can consume ~15 minutes on a cold runner. Keep enough room - # for the explicit 18-minute compile watchdog and artifact publication. - timeout-minutes: 40 + # for the System lane's explicit 24-minute compile watchdog and artifact publication. + timeout-minutes: 50 needs: [set-mode, changes] if: needs.set-mode.outputs.mpi_required == 'true' strategy: @@ -1508,7 +1537,12 @@ jobs: return "$status" } lane_parallelism=4 + lane_watchdog=18m case "${{ matrix.lane }}" in + system) + lane_parallelism=2 + lane_watchdog=24m + ;; amr-base|amr-compressible) lane_parallelism=2 ;; esac mpi_cmake_args=() @@ -1531,7 +1565,7 @@ jobs: --contract-file "$RUNNER_TEMP/mpi-prewarm-contract-${{ matrix.lane }}.json" ) test "${#object_targets[@]}" -gt 0 - run_with_heartbeat "MPI prewarm ${{ matrix.lane }}" 18m \ + run_with_heartbeat "MPI prewarm ${{ matrix.lane }}" "$lane_watchdog" \ cmake --build --preset ci-mpi --parallel "$lane_parallelism" \ --target "${object_targets[@]}" ccache -s @@ -1595,7 +1629,7 @@ jobs: # The native build, processor-grouped CTest plan, and Python MPI contract # fence run sequentially. Each C++ launch retains its configured bounded # TIMEOUT; grouping removes PROCESSORS head-of-line blocking without skips. - timeout-minutes: 70 + timeout-minutes: 180 needs: [set-mode, changes, gate-mpi-prewarm] # Suite complete, ou PR qui modifie directement le chemin distribue/MPI. if: needs.set-mode.outputs.mpi_required == 'true' @@ -1632,7 +1666,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ ccache libeigen3-dev libhdf5-openmpi-dev libopenmpi-dev ninja-build openmpi-bin \ pybind11-dev python3-dev python3-h5py \ - python3-numpy python3-pytest + python3-numpy python3-pytest python3-vtk9 - name: Resolve runner and compiler cache identity id: kokkos-platform @@ -1717,7 +1751,7 @@ jobs: test -s build-mpi/mpi-ctest-groups.tsv - name: Configure + build (MPI + Kokkos Serial) - timeout-minutes: 22 + timeout-minutes: 35 # Flags : preset ci-mpi (source unique, cf. CMakePresets.json) ; Kokkos_ROOT vient de # $KOKKOS_PREFIX (env du job, install en cache). ccache auto-detecte. run: | @@ -1772,6 +1806,10 @@ jobs: --build-dir build-mpi \ --verify-contracts "${compile_contracts[@]}" read -r -a mpi_targets <<< "${{ steps.mpi-test-plan.outputs.cpp_label_targets }}" + mapfile -t m4_targets < <( + /usr/bin/python3 scripts/run_m4_gate.py --list-ctest-targets + ) + test "${#m4_targets[@]}" -gt 0 export NINJA_STATUS='[%f/%t elapsed=%es active=%r] ' # The monolithic Python module link is memory-heavy. Keep it isolated # from test compilation/linking so a small hosted runner cannot evict @@ -1780,6 +1818,8 @@ jobs: cmake --build --preset ci-mpi --parallel 1 --target _pops run_with_heartbeat "MPI native test build" 8m \ cmake --build --preset ci-mpi --parallel 4 --target "${mpi_targets[@]}" + run_with_heartbeat "M4 native test build" 10m \ + cmake --build --preset ci-mpi --parallel 4 --target "${m4_targets[@]}" - name: Installed package smoke (MPI-only + collective HDF5) run: | @@ -1935,6 +1975,41 @@ jobs: timeout --signal=TERM --kill-after=30s 25m \ /usr/bin/python3 -m pytest -q -ra --maxfail=1 "$mpi_orchestrator" done < build-mpi/python-mpi-orchestrators.txt + + - name: M4 complete native runtime and scientific I/O gate + timeout-minutes: 45 + env: + PYTHONPATH: ${{ github.workspace }}/build-mpi/python-package:${{ github.workspace }} + POPS_INCLUDE: ${{ github.workspace }}/include + POPS_KOKKOS_ROOT: ${{ github.workspace }}/.kokkos-install + Kokkos_ROOT: ${{ github.workspace }}/.kokkos-install + POPS_CACHE_DIR: ${{ github.workspace }}/.pops-ci/m4-dsl-cache + POPS_KEEP_GENERATED: "1" + POPS_REQUIRE_MPI_TESTS: "1" + POPS_REQUIRE_NATIVE_TESTS: "1" + # Ubuntu 24.04 OpenMPI 4 OMPIO selects sharedfp/lockedfile during + # HDF5 MPI_File_open and aborts inside its fortified sprintf path. + # ROMIO is the packaged OpenMPI MPI-IO component and exercises the + # same collective HDF5 contract without that implementation defect. + OMPI_MCA_io: "^ompio" + run: | + # These readers are mandatory capabilities of this lane. Imports happen before the gate + # so a missing apt module cannot masquerade as a scientific skip. + /usr/bin/python3 - <<'PY' + import h5py + import numpy + from vtkmodules.vtkIOXML import ( + vtkXMLPUnstructuredGridReader, + vtkXMLUnstructuredGridReader, + ) + + print("M4 readers:", numpy.__version__, h5py.__version__) + print(vtkXMLPUnstructuredGridReader, vtkXMLUnstructuredGridReader) + PY + /usr/bin/python3 scripts/run_m4_gate.py \ + --build-dir build-mpi \ + --mpi-exec mpiexec + - name: ccache stats (MPI) if: always() run: ccache -s diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 09b73a907..ea567fc61 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -142,11 +142,12 @@ jobs: else echo "racine propre (aucun artefact a la racine)" fi - - name: Couverture manifest de tests (test_manifest.toml, informatif) + - name: Couverture manifest de tests (test_manifest.toml, bloquante) if: always() run: | # gen_test_counts.py --check-matrix liste les tests disque absents de tests/test_manifest.toml. - # Non bloquant (set +e) : informatif jusqu'a ce que le manifest soit complet, puis bloquant. + # Le manifest est complet : toute nouvelle entree disque non declaree est une regression. + # Capturer le statut permet de publier le detail avant d'echouer explicitement. set +e out=$(python3 docs/gen_test_counts.py --check-matrix) rc=$? @@ -154,15 +155,18 @@ jobs: echo "$out" n=$(printf '%s\n' "$out" | grep -c '^MISSING' || true) # grep -c exit 1 si 0 match -> || true (sinon set -e tue l'etape quand la matrice est complete) if [ "$rc" -ne 0 ]; then - echo "::warning::tests/test_manifest.toml : $n test(s) sans entree (anti-derive)" + echo "::error::tests/test_manifest.toml : $n test(s) sans entree (anti-derive)" fi { echo "### Couverture manifest de tests" echo "" echo "- Tests absents de \`tests/test_manifest.toml\` : **$n**" echo "" - echo "_Informatif : ajouter l'entree du test dans le manifest ; bloquant une fois complet._" + echo "_Bloquant : tout test doit appartenir au manifest autoritatif._" } >> "$GITHUB_STEP_SUMMARY" + if [ "$rc" -ne 0 ]; then + exit "$rc" + fi # --- Prewarm natif : contrats exacts par profil qualite ---------------------------------------- # Warnings, ASan et coverage portent trois jeux de flags incompatibles avec le build de production diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c2714279..776a1a44e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,10 @@ jobs: name: Validate the exact published wheel needs: wheel runs-on: macos-14 - timeout-minutes: 40 + # The complete source suite is already the parallel ``full-source-matrix`` dependency. This + # wheel lane runs the bounded M4/final-example ledger once, then CTest and artifact/restart + # proofs against the exact retained wheel. Keep enough room for an uncached native build. + timeout-minutes: 180 steps: - uses: actions/checkout@v7 with: @@ -61,7 +64,12 @@ jobs: wheels=("$RUNNER_TEMP"/wheelhouse/pops-*.whl) test "${#wheels[@]}" -eq 1 evidence="$RUNNER_TEMP/pops-final-evidence.json" + public_api_evidence="$RUNNER_TEMP/pops-final-evidence-public-api.json" python scripts/run_final_gate.py --wheel "${wheels[0]}" --evidence "$evidence" + python scripts/prove_public_api_parity.py \ + --wheel "${wheels[0]}" \ + --installed \ + --evidence "$public_api_evidence" python - <<'PY' from pops.runtime_environment import runtime_environment_report report = runtime_environment_report() @@ -69,7 +77,8 @@ jobs: assert report["mpi_compiled"] is False, report PY python scripts/release_preflight.py \ - --release --tag "$GITHUB_REF_NAME" --installed --evidence "$evidence" + --release --tag "$GITHUB_REF_NAME" --installed --evidence "$evidence" \ + --public-api-evidence "$public_api_evidence" - name: Retain authenticated release evidence uses: actions/upload-artifact@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ed9decc..21aca41b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,89 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning ### Changed +- Spatial providers now publish one exact dimension/geometry/operation matrix. The prepared local + periodic Cartesian residual executes compile-time 1D and 3D metrics, reconstruction, Riemann + fluxes and conservative divergence, while the Box2D/MultiFab runtime still refuses non-2D binds + and embedded/polar characteristic or boundary-linearization routes without metric providers. +- Characteristic no-inflow ghost production is transactional even without a separate primitive + trace-recovery provider: a partially written halo is restored when collective preflight refuses. +- `Program.cadence(substeps=..., stride=...)` now authors the native global cadence as immutable, + identity-bearing Program data and installs it before the Uniform or AMR runtime freezes. +- `AsyncScientificOutput` now accepts fields, diagnostics, or both on one exact schedule. Diagnostic + reductions, including the five-term `Balance` ledger, are captured transactionally before the + accepted snapshot is detached; the asynchronous worker receives only immutable arrays and + scalars. Sparse Balance cadences elide off-cadence reductions, publish an exact zero ledger for + held Program strides, and replay accepted state without reopening the native mailbox. +- ParaView output now has one collection-authoring keyword: `collection`. The deprecated + `ParaView(series=...)` compatibility route is deleted instead of being retained beside the + canonical PVD collection contract. - Release codesign now preserves an existing valid ad-hoc signature and refuses publication when post-install signing changes the retained wheel's native bytes, so the published wheel and the runtime exercised by conformance and final examples are byte-identical. +- Final release evidence now authenticates one exact runtime acceptance and one exact qualification + Pytest node for each normative example inside the all-pass installed-wheel JUnit lane. Missing, + renamed, skipped, xfailed, mocked, duplicated, or unattested example proofs fail before release + publication. +- Canonical authoring now keeps one explicit projection/construction route: use + `pops.physics.Model.lower()` for advanced Module inspection and + `MomentModel.build()` for recorded moment specifications. The duplicate facade aliases were + removed instead of deprecated. +- The final HyQMOM15 executable now checks realizability and the conserved `M00` + particle number for rejected, accepted, restored, and continued runtime snapshots. Its JSON + evidence reports the measured integral and maximum relative drift against the documented + `1e-10` acceptance threshold. It now authors the six fifth-order relations through a public + `@closure(4)` value and authenticates that every typed provisional store belongs to the rejected + Program transaction, without adding a HyQMOM-specific native route. +- The final scalar-advection acceptance now authenticates the public AMR regrid counters and strict + checkpoint capability. A pre-existing refined patch layout is no longer sufficient: the accepted + run and both continuation paths must complete the same topology-changing regrid windows, and + restart must preserve `regrid_count` and `topology_epoch` exactly. +- Private uniform and AMR Python runtime wrappers no longer expose `add_block`; native-brick and + compiled-package installation share the existing type-dispatched `add_equation` seam used below + `pops.bind`, while public authoring remains `Case.block(...)`. +- Type-erased variable-recovery reports now retain the selected and last-attempted method kinds. + Runtime failures name the actual recovery route instead of exposing only a plan-local integer, + while rejected outcomes keep the selected method explicitly unknown. +- Capability reports now distinguish the delivered typed Riemann rejection path from an unavailable + prepared recovery policy. Rusanov, HLL, HLLC, and Roe advertise their common device-copyable + `FluxEvaluation` and transactional rejection, while ordered fallback chains, + requested-versus-used solver diagnostics, counters, and restart metadata fail closed instead of + being inferred from the selected solver. +- Variable-recovery capability reports now separate the delivered prepared closed-form consumers + from the complete ADC-755 deletion gate. System materialization and every production face route + advertise typed publication control, while the remaining source, AMR-transfer, boundary, + inverse-conversion, cache/restart, backend, and performance families fail closed as an unavailable + complete-consumer cutover. +- ADC-749 carries exact periodic face identifications through the model-aware hyperbolic boundary + plan. Uniform scalar layouts execute mapped periodic halos (including cross-axis maps); mapped + vector/axial component transforms and AMR mapped periodic fill-patch/regrid remain explicit + fail-closed capabilities until their model-aware component and hierarchy contracts are available. +- ADC-749 now makes numerical resolution the fail-closed acceptance boundary for built-in transport + descriptors. Characteristic closures without a prepared eigenstructure, forged representation + converters, unsupported analytic dependencies, and mixed logical clocks can no longer survive as + inert metadata and fail only during compile or bind. +- Boundary provider identities now retain an immutable typed law such as inflow, ghost formula, + directional transport, or no-flux. Resolution no longer has to infer semantics from a handle name + or output port. The typed `BoundaryFlux` ABI now transforms outward-normal face flux after the + Riemann solve and before divergence/reflux on prepared 2D Cartesian host batches; device-native, + embedded/cut-cell and high-level convenience routes remain explicitly unavailable. - Strict AMR checkpoint payload v7 now persists the accepted shared-interface flux audit together with Program clocks, histories, tagging state, conservative ledger and synchronization report. Restart validates every fragment's topology epoch, level pair, exact clock window, resolved rational stage weight, geometry and duration before publishing the image; rejected restart or Program attempts leave the previous accepted report byte-exact. +- The final release gate now proves an external source component against the exact installed wheel: + its isolated AOT lane clears the checkout-owned `POPS_INCLUDE`, requires the wheel-owned signed + header tree and native Kokkos extension, compiles/installs/loads the component, and retains one + exact no-skip/no-xfail JUnit result whose node ID and command are reauthenticated by preflight. +- External AMR `Reflux` components now use the normalized public provider route from + `AMR(..., reflux=...)` through resolve, compiled provenance and transactional native + installation; the builtin flux-register kernel follows the same reported contract. +- Generated physical-flux bricks now make their qualified provider requirements executable native + ABI evidence: the binder validates every row at compile time and reads only its declared storage + slots instead of scanning the model's complete auxiliary width. Physical laws consume that exact + pack directly through compile-time provider reads; `PhysicalFluxView` no longer reconstructs a + process-wide `Aux` value. - AMR checkpoint capability reports now distinguish same-rank bit-identical replay from non-bit-identical rank-count rematerialization with Dense persisted histories. The explicit `RegridOnRestart()` policy now restores and authenticates the recorded accepted state before one @@ -34,8 +109,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning and the M3 proof requires the successful transform to advance that persistent cycle exactly once. This bounded route is one AMR layout at unchanged MPI cardinality. Serial and exact `MPI_COMM_WORLD` shared-interface groups use the same atomic rematerialization, consensus and - retry route; rank-changing dynamic interface rematerialization, elliptic fields, and bootstrap - staggered caches remain refused. Dense-history fingerprints are phase-local all-rank consensus + retry route; the MPI proof injects one rank-local fault after the native transform, verifies exact + rollback on every rank, retries with one collective receipt identity, then executes the + rematerialized interface. Active-depth changes, unsupported non-finest replacements at depth + greater than two, rank-changing dynamic interface rematerialization, elliptic fields, and + bootstrap staggered caches remain refused. Dense-history fingerprints are phase-local all-rank consensus witnesses, not a false bitwise-equality condition across interpolation; accepted solution components retain their independent native composite-conservation check. - Native `SymbolicTagger` hysteresis is now a checkpointed accepted-state capability. The M3 gate @@ -56,7 +134,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning weights. The fragments authenticate the paired RHS update and are deliberately not a second reflux source. `AMRRegrid.frozen()` now exposes the materialize-once public hierarchy policy, and the installed shared-interface route covers every materialized level of a frozen hierarchy, plus - a serial dynamic hierarchy whose complete configured depth is active at bind, with exact + a dynamic hierarchy whose complete configured depth is active at bind, with exact SSPRK2/subcycling evaluation when both endpoint hierarchies provide matching full-face coverage. Every interior level contributes its canonical evaluation to both adjacent, level-qualified coarse/fine audit pairs. A depth-preserving finest-transition regrid rematerializes face cells, @@ -70,19 +148,23 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning level route is installed before it becomes the parent of another transition, so proper-nesting may cross only the exact physical faces deliberately omitted from their paired boundary plans. One-sided tag propagation, dynamic active-depth changes, non-finest dynamic replacements at depth - greater than two, rank-changing dynamic refined rematerialization, implicit JVP and - historical-rate paths remain fail-closed. + greater than two, implicit JVP and historical-rate paths remain fail-closed. Depth-preserving + refined `MPI_COMM_WORLD` rematerialization now stages one detached collective registry; a + rank-local preparation failure rolls back the layout, topology epoch, evaluator audit count and + executable registry exactly before a retry may publish the replacement hierarchy. Rank-changing + dynamic refined rematerialization remains fail-closed. Each interface endpoint now carries the exact projection Handle, reconstruction-provider identity, operation and provider-derived trace depth into the native collective plan identity `pops.multiblock.interface-plan.v2`. The type-erased scheduler continues to execute only authenticated cell-average projections; MUSCL/WENO face reconstruction is rejected with its retained provider/depth contract until a mapped-halo reconstruction provider is installed, rather than being silently lowered. -- Strict accepted-state checkpoints now use Uniform payload v5 and AMR payload v6. They persist the +- Strict accepted-state checkpoints now use Uniform payload v5 and AMR payload v7. They persist the held Program cadence window, last accepted Program interval, and runtime-owned AMR tagging hysteresis; commit clock/tagging restoration transactionally; and allow selective history replay - only for the exact ring/depth authority exported by the installed artifact. AMR v5 images are - rejected fail-closed rather than silently restarting without their missing hysteresis state. + only for the exact ring/depth authority exported by the installed artifact. AMR v6 and earlier + images are rejected fail-closed rather than silently restarting without current accepted-state + provenance. Explicit AMR bootstrap also republishes the Program's level-qualified accepted image before each hierarchy transition commits, so a checkpoint taken before the first accepted step (after the required zero-step `pops.run` establishes its controls identity) already covers every active level. diff --git a/benchmarks/adc757/CMakeLists.txt b/benchmarks/adc757/CMakeLists.txt new file mode 100644 index 000000000..70af3738b --- /dev/null +++ b/benchmarks/adc757/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.21) + +project(PoPSAdc757Campaign LANGUAGES C CXX) + +set(POPS_ADC757_REVISION "unknown" CACHE STRING "Resolved source revision recorded in evidence") +set(POPS_ADC757_WHEEL_SHA256 "" CACHE STRING "Exact installed wheel digest") +set(POPS_ADC757_MODULE_ABI_SHA256 "" CACHE STRING "Exact installed module ABI digest") +set(POPS_ADC757_INCLUDE_ROOT "" CACHE PATH "Authenticated include root from the installed wheel") + +foreach(_digest POPS_ADC757_WHEEL_SHA256 POPS_ADC757_MODULE_ABI_SHA256) + string(LENGTH "${${_digest}}" _digest_length) + if(NOT _digest_length EQUAL 64 OR NOT "${${_digest}}" MATCHES "^[0-9a-f]+$") + message(FATAL_ERROR "${_digest} must be one lowercase sha256 digest") + endif() +endforeach() + +# Python wheels intentionally do not install popsConfig.cmake. Consume their authenticated header +# payload directly instead of building a second PoPS copy from the archived source. runtime_probe.py +# has already proved that this include tree has the signature baked into the installed _pops module. +if(NOT EXISTS "${POPS_ADC757_INCLUDE_ROOT}/pops/core/foundation/types.hpp") + message(FATAL_ERROR + "POPS_ADC757_INCLUDE_ROOT is not the authenticated installed PoPS header tree: " + "${POPS_ADC757_INCLUDE_ROOT}") +endif() +find_package(Kokkos CONFIG REQUIRED) +find_package(MPI REQUIRED COMPONENTS CXX) +add_library(pops_adc757_installed INTERFACE) +target_include_directories(pops_adc757_installed INTERFACE "${POPS_ADC757_INCLUDE_ROOT}") +target_compile_features(pops_adc757_installed INTERFACE cxx_std_20) +target_compile_definitions(pops_adc757_installed INTERFACE POPS_HAS_KOKKOS POPS_HAS_MPI) +target_link_libraries(pops_adc757_installed INTERFACE Kokkos::kokkos MPI::MPI_CXX) + +add_executable(adc757_heterogeneous_numerics heterogeneous_numerics.cpp) +target_compile_features(adc757_heterogeneous_numerics PRIVATE cxx_std_20) +target_link_libraries(adc757_heterogeneous_numerics PRIVATE pops_adc757_installed) +target_compile_definitions(adc757_heterogeneous_numerics PRIVATE + POPS_ADC757_REVISION="${POPS_ADC757_REVISION}" + POPS_ADC757_WHEEL_SHA256="${POPS_ADC757_WHEEL_SHA256}" + POPS_ADC757_MODULE_ABI_SHA256="${POPS_ADC757_MODULE_ABI_SHA256}" + POPS_ADC757_BUILD_ID="${CMAKE_CXX_COMPILER_ID}-${CMAKE_CXX_COMPILER_VERSION}-${CMAKE_BUILD_TYPE}") + +set_target_properties(adc757_heterogeneous_numerics PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") diff --git a/benchmarks/adc757/README.md b/benchmarks/adc757/README.md new file mode 100644 index 000000000..708a9a239 --- /dev/null +++ b/benchmarks/adc757/README.md @@ -0,0 +1,57 @@ +# ADC-757 heterogeneous numerics campaign + +This is a non-routine hardware qualification campaign. It is deliberately absent from ordinary +CI because a valid result requires exact retained-wheel installations of the same scientific +scenario in Serial, threaded, accelerator and accelerator+MPI modes, plus at least two MPI ranks, +one distinct accelerator per rank, and two native Kokkos streams per accelerator. + +The ABBA executable is only a microbenchmark. It cannot close ADC-757 by itself. Before compiling +that executable, the ROMEO driver now: + +1. builds and installs one retained candidate wheel through `scripts/build_python.sh --mpi`; +2. authenticates every installed wheel member with `scripts/prove_installed_wheel.py`, then binds the + native harness to that wheel's signed header tree (wheels intentionally omit `popsConfig.cmake`); +3. runs `pops.runtime.doctor.doctor()` against that installation and records its exact result; +4. requires an `installed-runtime-matrix.v1` receipt for one identical AMR-advection scenario under + Serial, threaded, GPU and GPU+MPI execution, including artifact identity, module/artifact ABI and + a common solution digest; +5. requires native receipts proving that the GPU+MPI artifact actually consumed cell-local time and + accepted-boundary AMR ownership migration, with no fallback and at least one post-migration step. + +Header presence and vector updates are never promoted to runtime evidence. On the current bounded +base, `runtime_probe.py` therefore writes an explicit refusal and exits before ABBA: ADC-757C's live +`decide_rebalance/apply_rebalance_decision` route and ADC-757G's accepted local-time publication must +first be integrated into the exact candidate, then exercised by a receipt-producing runtime driver. + +The native harness exercises two routes: + +- `prepared_local_time`: the baseline advances every cell at the smallest step; the candidate + advances the slow partition only when due and submits the two partitions to prepared streams; +- `cost_aware_load_balance`: the baseline uses round-robin ownership; the candidate uses prepared + task costs, migrates ownership with a timed `MPI_Alltoallv`, and executes the two local work + partitions concurrently. + +Both microbenchmark routes retain the same numerical result and publish mass, restart, rollback, and +ledger errors. The stream probe runs five paired ABBA blocks and reports overlap only when the +concurrent pair is measurably faster. The outer SLURM driver runs at least five ABBA blocks for each +scenario. `assemble.py` rejects incomplete or reordered measurements and binds every row to the +runtime-matrix digest, retained wheel and module ABI. `verify.py` independently checks the final +report. Neither program substitutes CPU measurements, header detection or inferred overlap for +installed PoPS runtime evidence. +When Kokkos provides `Experimental::partition_space`, PoPS consumes that API directly. The ROMEO +CUDA installation currently uses Kokkos 4.4.1, so the compatibility route creates non-blocking CUDA +streams explicitly, wraps them in Kokkos execution-space instances, and retains RAII ownership until +all lane workspaces and instances have been destroyed. + +On ROMEO, after the candidate revision is available in the checkout configured by +`POPS_ADC757_REPO_ROOT` and after a complete runtime matrix has been produced, set +`POPS_ADC757_RUNTIME_EVIDENCE` to that JSON file and submit with: + +```bash +benchmarks/romeo/submit_adc757_heterogeneous_numerics.sh +``` + +The job uses account `r250127`, the `armgpu` constraint, two MPI ranks and two GH200 GPUs. It +archives the exact revision into `/scratch_p`, compiles the aarch64/CUDA executable inside the +allocation, verifies the rank-local GPU UUIDs, runs the campaign with `srun`, and copies the small +report artifacts to `~/pops-benchmark-results/adc757`. diff --git a/benchmarks/adc757/assemble.py b/benchmarks/adc757/assemble.py new file mode 100755 index 000000000..2084a55c7 --- /dev/null +++ b/benchmarks/adc757/assemble.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""Authenticate ADC-757 ABBA measurements and assemble the closure report.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +import json +import math +from pathlib import Path +import statistics +from typing import Any + + +MEASUREMENT_SCHEMA = "pops.adc757.heterogeneous-numerics.measurement.v1" +REPORT_SCHEMA = "pops.adc757.heterogeneous-numerics.v1" +RUNTIME_SCHEMA = "pops.adc757.installed-runtime-matrix.v1" +RUNTIME_SCENARIO = "adc757_amr_advection_runtime_v1" +RUNTIME_MODES = ("serial", "threaded", "gpu", "gpu_mpi") +SCENARIOS = ("prepared_local_time", "cost_aware_load_balance") +ROUTE_ORDER = ("baseline", "candidate", "candidate", "baseline") +METRICS = ( + "time_to_solution_seconds", + "throughput_cell_updates_per_second", + "memory_traffic_bytes", + "kernel_launches", + "task_count", + "communication_bytes", + "communication_seconds", + "fallback_count", + "useful_work_cell_updates", + "imbalance_ratio", + "migration_bytes", + "migration_seconds", +) +CORRECTNESS = ( + "mass_error", + "restart_max_error", + "rollback_max_error", + "ledger_balance_error", +) + + +class AssemblyError(ValueError): + """Measurements cannot support an ADC-757 closure report.""" + + +def _object(value: Any, where: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise AssemblyError(f"{where} must be an object") + return value + + +def _finite(value: Any, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise AssemblyError(f"{where} must be numeric") + result = float(value) + if not math.isfinite(result) or result < 0.0: + raise AssemblyError(f"{where} must be finite and non-negative") + return result + + +def _canonical_sha256(value: Any) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _validate_runtime_evidence(value: Any, *, revision: str) -> dict[str, Any]: + evidence = _object(value, "installed runtime evidence") + expected = {"schema", "status", "revision", "scenario_id", "modes", "authorities"} + if set(evidence) != expected: + raise AssemblyError(f"installed runtime evidence fields differ: {sorted(evidence)}") + if evidence["schema"] != RUNTIME_SCHEMA: + raise AssemblyError("installed runtime evidence has an unexpected schema") + if evidence["status"] != "passed": + raise AssemblyError("installed runtime evidence did not pass") + if evidence["revision"] != revision: + raise AssemblyError("installed runtime evidence belongs to another revision") + if evidence["scenario_id"] != RUNTIME_SCENARIO: + raise AssemblyError("installed runtime evidence used another scientific scenario") + modes = evidence["modes"] + if ( + not isinstance(modes, list) + or not all(isinstance(item, dict) for item in modes) + or [item.get("id") for item in modes] != list(RUNTIME_MODES) + ): + raise AssemblyError( + f"installed runtime evidence must contain ordered modes {list(RUNTIME_MODES)}" + ) + authorities = _object(evidence["authorities"], "installed runtime authorities") + if set(authorities) != {"cell_local_time", "amr_rebalance_migration"}: + raise AssemblyError("installed runtime evidence has incomplete authority receipts") + for name, raw in authorities.items(): + authority = _object(raw, f"installed runtime authorities.{name}") + if authority.get("consumed") is not True: + raise AssemblyError(f"installed runtime authority {name} was not consumed") + return evidence + + +def _load(path: Path) -> list[dict[str, Any]]: + measurements: list[dict[str, Any]] = [] + for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not raw.strip(): + continue + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise AssemblyError(f"{path}:{line_number}: invalid JSON: {error}") from error + measurement = _object(value, f"measurement[{line_number}]") + if measurement.get("schema") != MEASUREMENT_SCHEMA: + raise AssemblyError(f"{path}:{line_number}: unexpected measurement schema") + measurements.append(measurement) + if not measurements: + raise AssemblyError("the ADC-757 campaign produced no measurements") + return measurements + + +def _validate_measurement(value: dict[str, Any], *, revision: str) -> None: + expected = { + "schema", + "status", + "revision", + "build_identity", + "installed_wheel_sha256", + "module_abi_sha256", + "runtime_evidence_sha256", + "execution_space", + "mpi_ranks", + "scenario", + "route", + "device_assignments", + "streams", + "metrics", + "correctness", + } + if set(value) != expected: + raise AssemblyError(f"measurement fields differ: {sorted(value)}") + if value["status"] != "passed": + raise AssemblyError("a hardware measurement did not pass its native checks") + if value["revision"] != revision: + raise AssemblyError("a hardware measurement belongs to another revision") + if not isinstance(value["build_identity"], str) or not value["build_identity"]: + raise AssemblyError("measurement build_identity must be non-empty") + for field in ( + "installed_wheel_sha256", + "module_abi_sha256", + "runtime_evidence_sha256", + ): + digest = value[field] + if ( + not isinstance(digest, str) + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise AssemblyError(f"measurement {field} must be lowercase sha256") + if not isinstance(value["execution_space"], str) or not value["execution_space"]: + raise AssemblyError("measurement execution_space must be non-empty") + if isinstance(value["mpi_ranks"], bool) or not isinstance(value["mpi_ranks"], int): + raise AssemblyError("measurement mpi_ranks must be an integer") + if value["mpi_ranks"] < 2: + raise AssemblyError("measurement requires at least two MPI ranks") + if value["scenario"] not in SCENARIOS or value["route"] not in set(ROUTE_ORDER): + raise AssemblyError("measurement has an unknown scenario or route") + + assignments = value["device_assignments"] + if not isinstance(assignments, list) or len(assignments) != value["mpi_ranks"]: + raise AssemblyError("device assignment count differs from mpi_ranks") + ranks: list[int] = [] + uuids: list[str] = [] + for assignment in assignments: + item = _object(assignment, "device assignment") + if set(item) != {"rank", "uuid"}: + raise AssemblyError("device assignment fields differ") + ranks.append(item["rank"]) + uuids.append(item["uuid"]) + if sorted(ranks) != list(range(value["mpi_ranks"])) or len(set(uuids)) != len(uuids): + raise AssemblyError("device assignments are incomplete or accelerator UUIDs alias") + + streams = _object(value["streams"], "measurement streams") + if set(streams) != { + "identities", + "correctness_parity", + "overlap_observed", + "workspace_disjoint", + }: + raise AssemblyError("measurement stream fields differ") + identities = streams["identities"] + if not isinstance(identities, list) or len(identities) < 2: + raise AssemblyError("measurement must contain at least two stream identities") + if any(not isinstance(identity, str) or not identity for identity in identities): + raise AssemblyError("stream identities must be non-empty strings") + if len(set(identities)) != len(identities): + raise AssemblyError("measurement stream identities alias") + for field in ("correctness_parity", "overlap_observed", "workspace_disjoint"): + if streams[field] is not True: + raise AssemblyError(f"measurement did not prove streams.{field}") + + metrics = _object(value["metrics"], "measurement metrics") + if set(metrics) != set(METRICS): + raise AssemblyError("measurement metric fields differ") + for name in METRICS: + _finite(metrics[name], f"measurement metrics.{name}") + if _finite(metrics["time_to_solution_seconds"], "time") <= 0.0: + raise AssemblyError("measurement time must be positive") + if _finite(metrics["throughput_cell_updates_per_second"], "throughput") <= 0.0: + raise AssemblyError("measurement throughput must be positive") + + correctness = _object(value["correctness"], "measurement correctness") + if set(correctness) != {"passed", *CORRECTNESS} or correctness["passed"] is not True: + raise AssemblyError("measurement correctness is incomplete or failed") + for name in CORRECTNESS: + if _finite(correctness[name], f"correctness.{name}") > 1.0e-11: + raise AssemblyError(f"measurement correctness.{name} exceeds 1e-11") + + +def _median_metrics(measurements: list[dict[str, Any]]) -> dict[str, float]: + return { + name: statistics.median(float(item["metrics"][name]) for item in measurements) + for name in METRICS + } + + +def assemble( + measurements: list[dict[str, Any]], + *, + revision: str, + minimum_speedup: float, + runtime_evidence: Any, +) -> dict[str, Any]: + if not math.isfinite(minimum_speedup) or minimum_speedup < 1.0: + raise AssemblyError("minimum speedup must be finite and at least one") + installed_runtime = _validate_runtime_evidence(runtime_evidence, revision=revision) + runtime_evidence_sha256 = _canonical_sha256(installed_runtime) + gpu_mpi = installed_runtime["modes"][-1] + expected_wheel_sha256 = gpu_mpi["installation"].get("wheel_sha256") + module_abi_key = gpu_mpi["artifact"].get("module_abi_key") + if not isinstance(module_abi_key, str) or not module_abi_key: + raise AssemblyError("installed GPU+MPI runtime has no module ABI key") + expected_module_abi_sha256 = hashlib.sha256(module_abi_key.encode("utf-8")).hexdigest() + for measurement in measurements: + _validate_measurement(measurement, revision=revision) + if measurement["runtime_evidence_sha256"] != runtime_evidence_sha256: + raise AssemblyError( + "a hardware measurement is not bound to the installed runtime evidence" + ) + if measurement["installed_wheel_sha256"] != expected_wheel_sha256: + raise AssemblyError("a hardware measurement used another installed wheel") + if measurement["module_abi_sha256"] != expected_module_abi_sha256: + raise AssemblyError("a hardware measurement used another installed module ABI") + + first = measurements[0] + stable_fields = ( + "build_identity", + "installed_wheel_sha256", + "module_abi_sha256", + "runtime_evidence_sha256", + "execution_space", + "mpi_ranks", + "device_assignments", + ) + for measurement in measurements[1:]: + for field in stable_fields: + if measurement[field] != first[field]: + raise AssemblyError(f"measurement {field} changed during the campaign") + + reports: list[dict[str, Any]] = [] + for scenario in SCENARIOS: + selected = [item for item in measurements if item["scenario"] == scenario] + if len(selected) < 20 or len(selected) % 4 != 0: + raise AssemblyError(f"{scenario} requires at least five complete ABBA blocks") + blocks: list[list[float]] = [] + for offset in range(0, len(selected), 4): + block = selected[offset : offset + 4] + routes = tuple(item["route"] for item in block) + if routes != ROUTE_ORDER: + raise AssemblyError(f"{scenario} block {offset // 4} is not ordered A,B,B,A") + blocks.append([float(item["metrics"]["time_to_solution_seconds"]) for item in block]) + baseline = [item for item in selected if item["route"] == "baseline"] + candidate = [item for item in selected if item["route"] == "candidate"] + correctness = { + "passed": True, + **{ + name: max(float(item["correctness"][name]) for item in selected) + for name in CORRECTNESS + }, + } + reports.append( + { + "id": scenario, + "baseline": _median_metrics(baseline), + "candidate": _median_metrics(candidate), + "correctness": correctness, + "minimum_speedup": minimum_speedup, + "abba_time_to_solution_seconds": blocks, + } + ) + + timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + identities = [f"rank0:{identity}" for identity in first["streams"]["identities"]] + topology = ";".join( + f"rank={item['rank']},uuid={item['uuid']}" for item in first["device_assignments"] + ) + return { + "schema": REPORT_SCHEMA, + "status": "passed", + "provenance": { + "revision": revision, + "build_identity": first["build_identity"], + "installed_wheel_sha256": first["installed_wheel_sha256"], + "module_abi_sha256": first["module_abi_sha256"], + "runtime_evidence_sha256": runtime_evidence_sha256, + "mpi_ranks": first["mpi_ranks"], + "topology_identity": topology, + "timestamp_utc": timestamp, + }, + "protocol": { + "ordering": "ABBA", + "clock": "steady_clock", + "device_fence": "before_and_after", + "mpi_barrier": "before_and_after", + "rank_aggregation": "max", + "warmups": 2, + }, + "device": { + "execution_space": first["execution_space"], + "assignments": first["device_assignments"], + }, + "streams": { + "identities": identities, + "correctness_parity": True, + "overlap_observed": True, + "workspace_disjoint": True, + }, + "installed_runtime": installed_runtime, + "scenarios": reports, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--device-inventory-output", type=Path) + parser.add_argument("--runtime-evidence", type=Path, required=True) + parser.add_argument("--expected-revision", required=True) + parser.add_argument("--minimum-speedup", type=float, default=1.01) + args = parser.parse_args() + try: + runtime_evidence = json.loads(args.runtime_evidence.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise AssemblyError(f"cannot load installed runtime evidence: {error}") from error + report = assemble( + _load(args.input), + revision=args.expected_revision, + minimum_speedup=args.minimum_speedup, + runtime_evidence=runtime_evidence, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + if args.device_inventory_output is not None: + args.device_inventory_output.parent.mkdir(parents=True, exist_ok=True) + assignments = report["device"]["assignments"] + args.device_inventory_output.write_text( + "".join(f"{item['rank']}\t{item['uuid']}\n" for item in assignments), + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/adc757/heterogeneous_numerics.cpp b/benchmarks/adc757/heterogeneous_numerics.cpp new file mode 100644 index 000000000..9176e5a8b --- /dev/null +++ b/benchmarks/adc757/heterogeneous_numerics.cpp @@ -0,0 +1,812 @@ +// ADC-757 out-of-CI heterogeneous numerics campaign. +// +// This executable refuses non-accelerator or single-rank runs. It uses PoPS' prepared stream +// authority for every measured kernel, performs real rank-to-rank migration for the load-balance +// scenario, and reports one baseline/candidate measurement. The SLURM driver invokes it in ABBA +// order; assemble.py authenticates the ordering and builds the closure report. + +#include +#include + +#include + +#ifndef POPS_HAS_MPI +#error "The ADC-757 heterogeneous campaign requires a real MPI build" +#endif +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef POPS_ADC757_REVISION +#define POPS_ADC757_REVISION "unknown" +#endif +#ifndef POPS_ADC757_BUILD_ID +#define POPS_ADC757_BUILD_ID "unknown" +#endif +#ifndef POPS_ADC757_WHEEL_SHA256 +#define POPS_ADC757_WHEEL_SHA256 "" +#endif +#ifndef POPS_ADC757_MODULE_ABI_SHA256 +#define POPS_ADC757_MODULE_ABI_SHA256 "" +#endif + +namespace { + +using Executor = pops::runtime::accelerator::PreparedAcceleratorStreamExecutor; +using Clock = std::chrono::steady_clock; + +constexpr std::string_view kMeasurementSchema = "pops.adc757.heterogeneous-numerics.measurement.v1"; +constexpr int kLocalSubsteps = 8; + +enum class Scenario { PreparedLocalTime, CostAwareLoadBalance }; +enum class Route { Baseline, Candidate }; + +struct Config { + Scenario scenario = Scenario::PreparedLocalTime; + Route route = Route::Baseline; + std::int64_t extent = 32768; + int inner_iterations = 96; + int migration_values_per_task = 4096; + std::string runtime_evidence_sha256; +}; + +struct Metrics { + double time_to_solution_seconds = 0.0; + double throughput_cell_updates_per_second = 0.0; + double memory_traffic_bytes = 0.0; + double kernel_launches = 0.0; + double task_count = 0.0; + double communication_bytes = 0.0; + double communication_seconds = 0.0; + double fallback_count = 0.0; + double useful_work_cell_updates = 0.0; + double imbalance_ratio = 1.0; + double migration_bytes = 0.0; + double migration_seconds = 0.0; +}; + +struct Correctness { + bool passed = false; + double mass_error = 0.0; + double restart_max_error = 0.0; + double rollback_max_error = 0.0; + double ledger_balance_error = 0.0; +}; + +struct TimedResult { + double seconds = 0.0; + double communication_seconds = 0.0; +}; + +struct Task { + int id = 0; + int weight = 0; + int baseline_owner = 0; + int candidate_owner = 0; +}; + +void mpi_check(int code, const char* operation) { + if (code == MPI_SUCCESS) + return; + char message[MPI_MAX_ERROR_STRING] = {}; + int length = 0; + MPI_Error_string(code, message, &length); + throw std::runtime_error(std::string(operation) + " failed: " + + std::string(message, static_cast(std::max(length, 0)))); +} + +int parse_positive_int(const char* text, const char* option) { + char* end = nullptr; + const long value = std::strtol(text, &end, 10); + if (end == text || *end != '\0' || value <= 0 || value > 100'000'000) + throw std::invalid_argument(std::string(option) + " requires a positive bounded integer"); + return static_cast(value); +} + +std::string parse_sha256(const char* text, const char* option) { + const std::string value(text); + if (value.size() != 64 || std::any_of(value.begin(), value.end(), [](char character) { + return !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')); + })) + throw std::invalid_argument(std::string(option) + " requires one lowercase sha256 digest"); + return value; +} + +Config parse_config(int argc, char** argv) { + Config config; + bool have_scenario = false; + bool have_route = false; + for (int index = 1; index < argc; ++index) { + const std::string argument(argv[index]); + const auto value = [&](const char* prefix) -> const char* { + const std::string key(prefix); + return argument.rfind(key, 0) == 0 ? argument.c_str() + key.size() : nullptr; + }; + if (const char* raw = value("--scenario=")) { + have_scenario = true; + if (std::string_view(raw) == "prepared_local_time") + config.scenario = Scenario::PreparedLocalTime; + else if (std::string_view(raw) == "cost_aware_load_balance") + config.scenario = Scenario::CostAwareLoadBalance; + else + throw std::invalid_argument("unknown ADC-757 scenario: " + std::string(raw)); + } else if (const char* raw = value("--route=")) { + have_route = true; + if (std::string_view(raw) == "baseline") + config.route = Route::Baseline; + else if (std::string_view(raw) == "candidate") + config.route = Route::Candidate; + else + throw std::invalid_argument("unknown ADC-757 route: " + std::string(raw)); + } else if (const char* raw = value("--extent=")) { + config.extent = parse_positive_int(raw, "--extent"); + } else if (const char* raw = value("--inner-iterations=")) { + config.inner_iterations = parse_positive_int(raw, "--inner-iterations"); + } else if (const char* raw = value("--migration-values-per-task=")) { + config.migration_values_per_task = parse_positive_int(raw, "--migration-values-per-task"); + } else if (const char* raw = value("--runtime-evidence-sha256=")) { + config.runtime_evidence_sha256 = parse_sha256(raw, "--runtime-evidence-sha256"); + } else { + throw std::invalid_argument("unknown ADC-757 campaign option: " + argument); + } + } + if (!have_scenario || !have_route || config.runtime_evidence_sha256.empty()) + throw std::invalid_argument("--scenario, --route and --runtime-evidence-sha256 are required"); + if (config.extent < 4096) + throw std::invalid_argument("--extent must be at least 4096 cells"); + if (config.inner_iterations > 1'000'000) + throw std::invalid_argument("--inner-iterations must not exceed 1000000"); + if (config.migration_values_per_task > 1'000'000) + throw std::invalid_argument("--migration-values-per-task must not exceed 1000000"); + return config; +} + +const char* scenario_name(Scenario scenario) { + return scenario == Scenario::PreparedLocalTime ? "prepared_local_time" + : "cost_aware_load_balance"; +} + +const char* route_name(Route route) { + return route == Route::Baseline ? "baseline" : "candidate"; +} + +struct UpdateKernel { + double* values = nullptr; + double increment = 0.0; + int work = 0; + + KOKKOS_INLINE_FUNCTION void operator()(std::int64_t index) const { + double burn = 1.0 + static_cast(index % 97) * 1.0e-4; + for (int iteration = 0; iteration < work; ++iteration) + burn = burn * 1.00000011920928955078125 + 1.7e-7; + values[index] += increment + burn * 1.0e-30; + } +}; + +void reset_workspaces(Executor& executor, double value = 1.0) { + for (std::size_t lane = 0; lane < executor.size(); ++lane) + Kokkos::deep_copy(executor.instance(lane), executor.workspace(lane), value); + executor.fence_all(); +} + +void launch_update(Executor& executor, std::size_t lane, std::int64_t extent, int work, + double increment, const char* label) { + executor.launch_for(lane, label, extent, + UpdateKernel{executor.workspace_data(lane), increment, work}); +} + +void run_local_time_route(Executor& executor, const Config& config, Route route) { + reset_workspaces(executor); + if (route == Route::Baseline) { + for (int substep = 0; substep < kLocalSubsteps; ++substep) { + launch_update(executor, 0, config.extent, config.inner_iterations, 1.0 / kLocalSubsteps, + "pops_adc757_global_fast"); + executor.fence(0); + launch_update(executor, 1, config.extent, config.inner_iterations, 1.0 / kLocalSubsteps, + "pops_adc757_global_slow"); + executor.fence(1); + } + return; + } + for (int substep = 0; substep < kLocalSubsteps; ++substep) + launch_update(executor, 0, config.extent, config.inner_iterations, 1.0 / kLocalSubsteps, + "pops_adc757_local_fast"); + launch_update(executor, 1, config.extent, config.inner_iterations, 1.0, "pops_adc757_local_slow"); + executor.fence_all(); +} + +template +double maximum_error(const View& lhs, const View& rhs) { + if (lhs.extent(0) != rhs.extent(0)) + throw std::logic_error("ADC-757 parity views have different extents"); + double error = 0.0; + for (std::size_t index = 0; index < lhs.extent(0); ++index) + error = std::max(error, std::fabs(lhs(index) - rhs(index))); + return error; +} + +template +double maximum_error_from_value(const View& values, double expected) { + double error = 0.0; + for (std::size_t index = 0; index < values.extent(0); ++index) + error = std::max(error, std::fabs(values(index) - expected)); + return error; +} + +template +double host_sum(const View& values) { + double sum = 0.0; + for (std::size_t index = 0; index < values.extent(0); ++index) + sum += values(index); + return sum; +} + +Correctness validate_local_time(Executor& executor, const Config& config) { + run_local_time_route(executor, config, Route::Baseline); + const auto baseline_fast = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(0)); + const auto baseline_slow = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(1)); + + run_local_time_route(executor, config, Route::Candidate); + const auto candidate_fast = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(0)); + const auto candidate_slow = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(1)); + + const double parity_error = std::max(maximum_error(baseline_fast, candidate_fast), + maximum_error(baseline_slow, candidate_slow)); + const double mass_error = std::max(maximum_error_from_value(candidate_fast, 2.0), + maximum_error_from_value(candidate_slow, 2.0)); + const double ledger_error = std::fabs((host_sum(baseline_fast) + host_sum(baseline_slow)) - + (host_sum(candidate_fast) + host_sum(candidate_slow))) / + static_cast(2 * config.extent); + + reset_workspaces(executor); + for (int substep = 0; substep < kLocalSubsteps / 2; ++substep) + launch_update(executor, 0, config.extent, config.inner_iterations, 1.0 / kLocalSubsteps, + "pops_adc757_restart_first_half"); + launch_update(executor, 1, config.extent, config.inner_iterations, 1.0, + "pops_adc757_restart_slow"); + executor.fence_all(); + const auto accepted_fast = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(0)); + const auto accepted_slow = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(1)); + + for (int substep = kLocalSubsteps / 2; substep < kLocalSubsteps; ++substep) + launch_update(executor, 0, config.extent, config.inner_iterations, 1.0 / kLocalSubsteps, + "pops_adc757_restart_second_half"); + executor.fence_all(); + const auto restarted_fast = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(0)); + const auto restarted_slow = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(1)); + const double restart_error = std::max(maximum_error(candidate_fast, restarted_fast), + maximum_error(candidate_slow, restarted_slow)); + + launch_update(executor, 0, config.extent, config.inner_iterations, 17.0, + "pops_adc757_rejected_attempt"); + launch_update(executor, 1, config.extent, config.inner_iterations, -11.0, + "pops_adc757_rejected_attempt_slow"); + executor.fence_all(); + Kokkos::deep_copy(executor.instance(0), executor.workspace(0), accepted_fast); + Kokkos::deep_copy(executor.instance(1), executor.workspace(1), accepted_slow); + executor.fence_all(); + const auto rolled_back_fast = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(0)); + const auto rolled_back_slow = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(1)); + const double rollback_error = std::max(maximum_error(accepted_fast, rolled_back_fast), + maximum_error(accepted_slow, rolled_back_slow)); + + const double global_parity = pops::all_reduce_max(parity_error); + Correctness result; + result.mass_error = pops::all_reduce_max(mass_error); + result.restart_max_error = pops::all_reduce_max(restart_error); + result.rollback_max_error = pops::all_reduce_max(rollback_error); + result.ledger_balance_error = pops::all_reduce_max(ledger_error); + result.passed = global_parity <= 1.0e-11 && result.mass_error <= 1.0e-11 && + result.restart_max_error <= 1.0e-11 && result.rollback_max_error <= 1.0e-11 && + result.ledger_balance_error <= 1.0e-11; + return result; +} + +std::vector make_tasks(int ranks) { + constexpr int tasks_per_rank = 8; + const int task_count = tasks_per_rank * ranks; + std::vector tasks(static_cast(task_count)); + for (int task = 0; task < task_count; ++task) { + const int baseline_owner = task % ranks; + const int weight = baseline_owner == 0 ? 16 + (task % 3) : 1 + (task % 3); + tasks[static_cast(task)] = {task, weight, baseline_owner, -1}; + } + + std::vector order(static_cast(task_count)); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), [&](int lhs, int rhs) { + return tasks[static_cast(lhs)].weight > + tasks[static_cast(rhs)].weight; + }); + std::vector loads(static_cast(ranks), 0); + for (const int task_index : order) { + const auto least = std::min_element(loads.begin(), loads.end()); + const int owner = static_cast(std::distance(loads.begin(), least)); + tasks[static_cast(task_index)].candidate_owner = owner; + loads[static_cast(owner)] += tasks[static_cast(task_index)].weight; + } + return tasks; +} + +std::vector owner_loads(const std::vector& tasks, int ranks, Route route) { + std::vector loads(static_cast(ranks), 0); + for (const Task& task : tasks) { + const int owner = route == Route::Baseline ? task.baseline_owner : task.candidate_owner; + loads[static_cast(owner)] += task.weight; + } + return loads; +} + +double imbalance_ratio(const std::vector& loads) { + const double total = static_cast(std::accumulate(loads.begin(), loads.end(), 0L)); + const double average = total / static_cast(loads.size()); + return static_cast(*std::max_element(loads.begin(), loads.end())) / average; +} + +class MigrationPlan { + public: + MigrationPlan(const std::vector& tasks, int values_per_task) + : send_counts_(static_cast(pops::n_ranks()), 0), + receive_counts_(static_cast(pops::n_ranks()), 0), + send_displacements_(static_cast(pops::n_ranks()), 0), + receive_displacements_(static_cast(pops::n_ranks()), 0) { + const std::size_t bytes_per_task = static_cast(values_per_task) * sizeof(double); + if (bytes_per_task > static_cast(std::numeric_limits::max())) + throw std::overflow_error("ADC-757 migration task payload exceeds MPI int count"); + for (const Task& task : tasks) + if (task.baseline_owner == pops::my_rank() && task.candidate_owner != task.baseline_owner) { + int& count = send_counts_[static_cast(task.candidate_owner)]; + if (count > std::numeric_limits::max() - static_cast(bytes_per_task)) + throw std::overflow_error("ADC-757 migration send count overflows MPI int"); + count += static_cast(bytes_per_task); + } + mpi_check(MPI_Alltoall(send_counts_.data(), 1, MPI_INT, receive_counts_.data(), 1, MPI_INT, + MPI_COMM_WORLD), + "MPI_Alltoall(ADC-757 migration counts)"); + for (int rank = 1; rank < pops::n_ranks(); ++rank) { + send_displacements_[static_cast(rank)] = + send_displacements_[static_cast(rank - 1)] + + send_counts_[static_cast(rank - 1)]; + receive_displacements_[static_cast(rank)] = + receive_displacements_[static_cast(rank - 1)] + + receive_counts_[static_cast(rank - 1)]; + } + const int send_bytes = send_displacements_.back() + send_counts_.back(); + const int receive_bytes = receive_displacements_.back() + receive_counts_.back(); + send_.resize(static_cast(send_bytes)); + receive_.resize(static_cast(receive_bytes)); + + std::vector cursors = send_displacements_; + for (const Task& task : tasks) + if (task.baseline_owner == pops::my_rank() && task.candidate_owner != task.baseline_owner) { + const int destination = task.candidate_owner; + int& cursor = cursors[static_cast(destination)]; + const unsigned char value = static_cast((task.id % 251) + 1); + std::fill_n(send_.data() + cursor, bytes_per_task, value); + cursor += static_cast(bytes_per_task); + } + + const long local_bytes = static_cast(send_.size()); + global_bytes_ = pops::all_reduce_sum(local_bytes); + if (global_bytes_ <= 0) + throw std::runtime_error("ADC-757 cost-aware plan did not migrate any task data"); + } + + double migrate() { + const auto begin = Clock::now(); + mpi_check(MPI_Alltoallv(send_.data(), send_counts_.data(), send_displacements_.data(), MPI_BYTE, + receive_.data(), receive_counts_.data(), receive_displacements_.data(), + MPI_BYTE, MPI_COMM_WORLD), + "MPI_Alltoallv(ADC-757 task migration)"); + const auto end = Clock::now(); + return std::chrono::duration(end - begin).count(); + } + + [[nodiscard]] long global_bytes() const noexcept { return global_bytes_; } + + [[nodiscard]] double checksum_error() const { + const double sent = std::accumulate(send_.begin(), send_.end(), 0.0); + const double received = std::accumulate(receive_.begin(), receive_.end(), 0.0); + return std::fabs(pops::all_reduce_sum(sent) - pops::all_reduce_sum(received)); + } + + [[nodiscard]] std::pair restart_and_rollback_errors() { + const std::vector accepted = receive_; + std::vector checkpoint; + checkpoint.reserve(sizeof(std::uint64_t) + accepted.size()); + const std::uint64_t extent = static_cast(accepted.size()); + const auto* extent_bytes = reinterpret_cast(&extent); + checkpoint.insert(checkpoint.end(), extent_bytes, extent_bytes + sizeof(extent)); + checkpoint.insert(checkpoint.end(), accepted.begin(), accepted.end()); + + std::fill(receive_.begin(), receive_.end(), 0xff); + std::uint64_t restored_extent = 0; + std::memcpy(&restored_extent, checkpoint.data(), sizeof(restored_extent)); + if (restored_extent != accepted.size()) + return {1.0, 1.0}; + std::copy(checkpoint.begin() + static_cast(sizeof(restored_extent)), + checkpoint.end(), receive_.begin()); + const double restart_error = receive_ == accepted ? 0.0 : 1.0; + + for (unsigned char& value : receive_) + value ^= 0x5a; + receive_ = accepted; + const double rollback_error = receive_ == accepted ? 0.0 : 1.0; + return {pops::all_reduce_max(restart_error), pops::all_reduce_max(rollback_error)}; + } + + private: + std::vector send_counts_; + std::vector receive_counts_; + std::vector send_displacements_; + std::vector receive_displacements_; + std::vector send_; + std::vector receive_; + long global_bytes_ = 0; +}; + +std::array split_candidate_load(const std::vector& tasks, int rank) { + std::array lanes{0, 0}; + std::vector local_weights; + for (const Task& task : tasks) + if (task.candidate_owner == rank) + local_weights.push_back(task.weight); + std::sort(local_weights.begin(), local_weights.end(), std::greater<>()); + for (const int weight : local_weights) { + const std::size_t lane = lanes[0] <= lanes[1] ? 0u : 1u; + lanes[lane] += weight; + } + return lanes; +} + +int checked_kernel_work(long weight, int inner_iterations) { + if (weight < 0 || weight > static_cast(std::numeric_limits::max() / inner_iterations)) + throw std::overflow_error("ADC-757 kernel work exceeds the prepared integer range"); + return static_cast(weight * inner_iterations); +} + +void run_load_balance_route(Executor& executor, const Config& config, Route route, + const std::vector& tasks, MigrationPlan& migration, + double& local_communication_seconds) { + reset_workspaces(executor); + if (route == Route::Baseline) { + const auto loads = owner_loads(tasks, pops::n_ranks(), Route::Baseline); + const long work = loads[static_cast(pops::my_rank())]; + launch_update(executor, 0, config.extent, checked_kernel_work(work, config.inner_iterations), + 1.0, "pops_adc757_round_robin_load"); + executor.fence(0); + local_communication_seconds = 0.0; + return; + } + + local_communication_seconds = migration.migrate(); + const std::array lane_loads = split_candidate_load(tasks, pops::my_rank()); + for (std::size_t lane = 0; lane < lane_loads.size(); ++lane) + if (lane_loads[lane] != 0) + launch_update(executor, lane, config.extent, + checked_kernel_work(lane_loads[lane], config.inner_iterations), 1.0, + "pops_adc757_cost_aware_load"); + executor.fence_all(); +} + +Correctness validate_load_balance(MigrationPlan& migration, const std::vector& tasks) { + migration.migrate(); + const double checksum_error = migration.checksum_error(); + const auto [restart_error, rollback_error] = migration.restart_and_rollback_errors(); + const long baseline_weight = std::accumulate( + tasks.begin(), tasks.end(), 0L, [](long sum, const Task& task) { return sum + task.weight; }); + const auto candidate_loads = owner_loads(tasks, pops::n_ranks(), Route::Candidate); + const long candidate_weight = std::accumulate(candidate_loads.begin(), candidate_loads.end(), 0L); + Correctness result; + result.mass_error = checksum_error; + result.restart_max_error = restart_error; + result.rollback_max_error = rollback_error; + result.ledger_balance_error = std::fabs(static_cast(baseline_weight - candidate_weight)); + result.passed = result.mass_error <= 1.0e-11 && result.restart_max_error <= 1.0e-11 && + result.rollback_max_error <= 1.0e-11 && result.ledger_balance_error <= 1.0e-11; + return result; +} + +template +TimedResult measure(Function&& function, Executor& executor) { + executor.fence_all(); + pops::barrier(); + double local_communication_seconds = 0.0; + const auto begin = Clock::now(); + function(local_communication_seconds); + executor.fence_all(); + const auto end = Clock::now(); + pops::barrier(); + return {pops::all_reduce_max(std::chrono::duration(end - begin).count()), + pops::all_reduce_max(local_communication_seconds)}; +} + +double median(std::vector values) { + if (values.empty()) + throw std::logic_error("ADC-757 median requires samples"); + std::sort(values.begin(), values.end()); + const std::size_t middle = values.size() / 2; + return values.size() % 2 == 0 ? 0.5 * (values[middle - 1] + values[middle]) : values[middle]; +} + +bool observe_stream_overlap(Executor& executor, const Config& config) { + const std::int64_t extent = std::min(config.extent, 4096); + const int work = static_cast( + std::min(static_cast(config.inner_iterations) * 64, 100'000)); + auto run_sequential = [&](double&) { + reset_workspaces(executor); + launch_update(executor, 0, extent, work, 0.0, "pops_adc757_overlap_a0"); + executor.fence(0); + launch_update(executor, 1, extent, work, 0.0, "pops_adc757_overlap_a1"); + executor.fence(1); + }; + auto run_concurrent = [&](double&) { + reset_workspaces(executor); + launch_update(executor, 0, extent, work, 0.0, "pops_adc757_overlap_b0"); + launch_update(executor, 1, extent, work, 0.0, "pops_adc757_overlap_b1"); + executor.fence_all(); + }; + for (int warmup = 0; warmup < 2; ++warmup) { + double ignored_communication_seconds = 0.0; + run_sequential(ignored_communication_seconds); + run_concurrent(ignored_communication_seconds); + } + std::vector ratios; + ratios.reserve(5); + for (int block = 0; block < 5; ++block) { + const double a1 = measure(run_sequential, executor).seconds; + const double b1 = measure(run_concurrent, executor).seconds; + const double b2 = measure(run_concurrent, executor).seconds; + const double a2 = measure(run_sequential, executor).seconds; + ratios.push_back(std::sqrt((b1 * b2) / (a1 * a2))); + } + return pops::all_reduce_max(median(std::move(ratios))) < 0.95; +} + +std::vector gather_device_uuids() { + constexpr std::size_t capacity = 128; + std::string local_uuid; +#if defined(KOKKOS_ENABLE_CUDA) + int device = -1; + cudaUUID_t uuid{}; + const cudaError_t device_status = cudaGetDevice(&device); + const cudaError_t uuid_status = + device_status == cudaSuccess ? cudaDeviceGetUuid(&uuid, device) : device_status; + if (device_status == cudaSuccess && uuid_status == cudaSuccess) { + std::ostringstream encoded; + encoded << "GPU-" << std::hex << std::setfill('0'); + for (const char byte : uuid.bytes) + encoded << std::setw(2) << static_cast(static_cast(byte)); + local_uuid = encoded.str(); + } +#else + // CUDA supplies a stable physical UUID directly. Other accelerator runtimes may inject an + // equivalent rank-local identifier until their Kokkos device API standardizes one. + const char* environment = std::getenv("POPS_ADC757_DEVICE_UUID"); + if (environment != nullptr) + local_uuid = environment; +#endif + const bool invalid = local_uuid.empty() || local_uuid.size() >= capacity; + if (pops::all_reduce_max(static_cast(invalid ? 1 : 0)) != 0) + throw std::runtime_error("every rank requires one bounded physical accelerator UUID"); + std::array local{}; + std::memcpy(local.data(), local_uuid.data(), local_uuid.size()); + std::vector gathered(capacity * static_cast(pops::n_ranks())); + mpi_check(MPI_Allgather(local.data(), static_cast(capacity), MPI_CHAR, gathered.data(), + static_cast(capacity), MPI_CHAR, MPI_COMM_WORLD), + "MPI_Allgather(ADC-757 device UUIDs)"); + std::vector result; + result.reserve(static_cast(pops::n_ranks())); + for (int rank = 0; rank < pops::n_ranks(); ++rank) + result.emplace_back(gathered.data() + static_cast(rank) * capacity); + if (std::set(result.begin(), result.end()).size() != result.size()) + throw std::runtime_error("ADC-757 requires one distinct accelerator UUID per MPI rank"); + return result; +} + +std::string json_escape(std::string_view text) { + std::string escaped; + escaped.reserve(text.size()); + for (const char character : text) { + if (character == '"' || character == '\\') + escaped.push_back('\\'); + escaped.push_back(character); + } + return escaped; +} + +void write_metrics(std::ostream& output, const Metrics& metrics) { + output << "{\"time_to_solution_seconds\":" << metrics.time_to_solution_seconds + << ",\"throughput_cell_updates_per_second\":" << metrics.throughput_cell_updates_per_second + << ",\"memory_traffic_bytes\":" << metrics.memory_traffic_bytes + << ",\"kernel_launches\":" << metrics.kernel_launches + << ",\"task_count\":" << metrics.task_count + << ",\"communication_bytes\":" << metrics.communication_bytes + << ",\"communication_seconds\":" << metrics.communication_seconds + << ",\"fallback_count\":" << metrics.fallback_count + << ",\"useful_work_cell_updates\":" << metrics.useful_work_cell_updates + << ",\"imbalance_ratio\":" << metrics.imbalance_ratio + << ",\"migration_bytes\":" << metrics.migration_bytes + << ",\"migration_seconds\":" << metrics.migration_seconds << '}'; +} + +void write_correctness(std::ostream& output, const Correctness& correctness) { + output << "{\"passed\":" << (correctness.passed ? "true" : "false") + << ",\"mass_error\":" << correctness.mass_error + << ",\"restart_max_error\":" << correctness.restart_max_error + << ",\"rollback_max_error\":" << correctness.rollback_max_error + << ",\"ledger_balance_error\":" << correctness.ledger_balance_error << '}'; +} + +int run(const Config& config) { + static_cast(parse_sha256(POPS_ADC757_WHEEL_SHA256, "installed wheel identity")); + static_cast(parse_sha256(POPS_ADC757_MODULE_ABI_SHA256, "installed module ABI identity")); + if (pops::n_ranks() < 2) + throw std::runtime_error("ADC-757 heterogeneous evidence requires at least two MPI ranks"); + if (!Executor::backend_can_partition_authentic_streams()) + throw std::runtime_error(std::string("ADC-757 refuses non-accelerator Kokkos backend ") + + Kokkos::DefaultExecutionSpace::name()); + + std::unique_ptr prepared_executor; + std::string local_preparation_error; + try { + prepared_executor = + std::make_unique(Executor::prepare(2, static_cast(config.extent))); + } catch (const std::exception& error) { + local_preparation_error = error.what(); + } + if (pops::all_reduce_max(static_cast(local_preparation_error.empty() ? 0 : 1)) != 0) + throw std::runtime_error( + "accelerator stream preparation failed on at least one MPI rank" + + (local_preparation_error.empty() ? std::string{} : ": " + local_preparation_error)); + Executor& executor = *prepared_executor; + const std::vector device_uuids = gather_device_uuids(); + const bool overlap_observed = observe_stream_overlap(executor, config); + + std::vector tasks = make_tasks(pops::n_ranks()); + MigrationPlan migration(tasks, config.migration_values_per_task); + const Correctness correctness = config.scenario == Scenario::PreparedLocalTime + ? validate_local_time(executor, config) + : validate_load_balance(migration, tasks); + + auto selected_route = [&](double& local_communication_seconds) { + if (config.scenario == Scenario::PreparedLocalTime) { + run_local_time_route(executor, config, config.route); + local_communication_seconds = 0.0; + } else { + run_load_balance_route(executor, config, config.route, tasks, migration, + local_communication_seconds); + } + }; + for (int warmup = 0; warmup < 2; ++warmup) { + double communication = 0.0; + selected_route(communication); + executor.fence_all(); + pops::barrier(); + } + const TimedResult timing = measure(selected_route, executor); + + Metrics metrics; + metrics.time_to_solution_seconds = timing.seconds; + metrics.communication_seconds = timing.communication_seconds; + if (config.scenario == Scenario::PreparedLocalTime) { + const double updates_per_rank = + static_cast(config.extent) * + (config.route == Route::Baseline ? 2.0 * kLocalSubsteps : kLocalSubsteps + 1.0); + metrics.useful_work_cell_updates = updates_per_rank * pops::n_ranks(); + metrics.kernel_launches = + (config.route == Route::Baseline ? 2.0 * kLocalSubsteps : kLocalSubsteps + 1.0) * + pops::n_ranks(); + metrics.task_count = metrics.kernel_launches; + } else { + const long total_weight = + std::accumulate(tasks.begin(), tasks.end(), 0L, + [](long sum, const Task& task) { return sum + task.weight; }); + metrics.useful_work_cell_updates = static_cast(config.extent) * total_weight; + metrics.task_count = static_cast(tasks.size()); + metrics.kernel_launches = (config.route == Route::Baseline ? 1.0 : 2.0) * pops::n_ranks(); + metrics.imbalance_ratio = imbalance_ratio(owner_loads(tasks, pops::n_ranks(), config.route)); + if (config.route == Route::Candidate) { + metrics.communication_bytes = static_cast(migration.global_bytes()); + metrics.migration_bytes = static_cast(migration.global_bytes()); + metrics.migration_seconds = timing.communication_seconds; + } + } + metrics.memory_traffic_bytes = 2.0 * sizeof(double) * metrics.useful_work_cell_updates; + metrics.throughput_cell_updates_per_second = + metrics.useful_work_cell_updates / metrics.time_to_solution_seconds; + + const bool local_pass = correctness.passed && overlap_observed && + executor.evidence().independent_streams && + executor.evidence().disjoint_workspaces && timing.seconds > 0.0; + const bool passed = pops::all_reduce_min(static_cast(local_pass ? 1 : 0)) == 1; + if (pops::my_rank() == 0) { + std::ostringstream output; + output << std::setprecision(17); + output << "{\"schema\":\"" << kMeasurementSchema << "\",\"status\":\"" + << (passed ? "passed" : "failed") << "\",\"revision\":\"" + << json_escape(POPS_ADC757_REVISION) << "\",\"build_identity\":\"" + << json_escape(std::string(POPS_ADC757_BUILD_ID) + "-" + + Kokkos::DefaultExecutionSpace::name()) + << "\",\"installed_wheel_sha256\":\"" << POPS_ADC757_WHEEL_SHA256 + << "\",\"module_abi_sha256\":\"" << POPS_ADC757_MODULE_ABI_SHA256 + << "\",\"runtime_evidence_sha256\":\"" << config.runtime_evidence_sha256 + << "\",\"execution_space\":\"" << Kokkos::DefaultExecutionSpace::name() + << "\",\"mpi_ranks\":" << pops::n_ranks() << ",\"scenario\":\"" + << scenario_name(config.scenario) << "\",\"route\":\"" << route_name(config.route) + << "\",\"device_assignments\":["; + for (int rank = 0; rank < pops::n_ranks(); ++rank) { + if (rank != 0) + output << ','; + output << "{\"rank\":" << rank << ",\"uuid\":\"" + << json_escape(device_uuids[static_cast(rank)]) << "\"}"; + } + output << "],\"streams\":{\"identities\":["; + for (std::size_t lane = 0; lane < executor.size(); ++lane) { + if (lane != 0) + output << ','; + output << "\"" << json_escape(executor.stream_identity(lane)) << "\""; + } + output << "],\"correctness_parity\":" << (correctness.passed ? "true" : "false") + << ",\"overlap_observed\":" << (overlap_observed ? "true" : "false") + << ",\"workspace_disjoint\":" + << (executor.evidence().disjoint_workspaces ? "true" : "false") << "},\"metrics\":"; + write_metrics(output, metrics); + output << ",\"correctness\":"; + write_correctness(output, correctness); + output << '}'; + std::cout << output.str() << '\n'; + } + return passed ? 0 : 1; +} + +} // namespace + +int main(int argc, char** argv) { + pops::comm_init(&argc, &argv); + Kokkos::initialize(argc, argv); + int failed = 0; + try { + failed = run(parse_config(argc, argv)); + } catch (const std::exception& error) { + if (pops::my_rank() == 0) + std::fprintf(stderr, "ADC-757 heterogeneous campaign failed: %s\n", error.what()); + failed = 1; + } + const long collective_failure = pops::all_reduce_max(static_cast(failed)); + pops::barrier(); + Kokkos::finalize(); + pops::comm_finalize(); + return collective_failure == 0 ? 0 : 1; +} diff --git a/benchmarks/adc757/runtime_probe.py b/benchmarks/adc757/runtime_probe.py new file mode 100644 index 000000000..e887da7c9 --- /dev/null +++ b/benchmarks/adc757/runtime_probe.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Fail-closed preflight for installed-runtime ADC-757 hardware evidence. + +The current benchmark vector kernels are not a PoPS runtime scenario. This probe authenticates the +retained wheel and its live installation, runs ``pops.runtime.doctor.doctor()``, records the module +ABI, and then refuses closure until one exact four-mode runtime matrix plus native local-time and +AMR-migration receipts exists. It never turns header presence into positive runtime evidence. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import runpy +import sys +from typing import Any + + +REFUSAL_SCHEMA = "pops.adc757.installed-runtime-refusal.v1" +RUNTIME_SCHEMA = "pops.adc757.installed-runtime-matrix.v1" +RUNTIME_MODES = ("serial", "threaded", "gpu", "gpu_mpi") + + +class RuntimeProbeError(RuntimeError): + """The installed candidate cannot even support an authenticated refusal.""" + + +def _object(value: Any, where: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise RuntimeProbeError(f"{where} must be an object") + return value + + +def _outside(path: Path, root: Path, *, where: str) -> Path: + resolved = path.resolve() + try: + resolved.relative_to(root.resolve()) + except ValueError: + return resolved + raise RuntimeProbeError(f"{where} resolved inside the source checkout: {resolved}") + + +def _sha256_json(value: Any) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _load_json(path: Path, where: str) -> dict[str, Any]: + try: + return _object(json.loads(path.read_text(encoding="utf-8")), where) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeProbeError(f"cannot load {where}: {error}") from error + + +def _required_text(value: Any, where: str) -> str: + if not isinstance(value, str) or not value: + raise RuntimeProbeError(f"{where} must be non-empty text") + return value + + +def _installed_candidate( + proof: dict[str, Any], *, source_root: Path, expected_revision: str +) -> tuple[dict[str, Any], str, dict[str, Any]]: + required = { + "schema_version", + "python_executable", + "distribution_root", + "package_file", + "native_extension", + "native_member", + "native_sha256", + "installed_member_count", + "installed_tree_sha256", + "proof_script_sha256", + "version", + "wheel_path", + "wheel_sha256", + } + if set(proof) != required or proof["schema_version"] != 2: + raise RuntimeProbeError("installed wheel proof does not have the exact v2 contract") + package_file = _outside( + Path(_required_text(proof["package_file"], "wheel proof package_file")), + source_root, + where="installed package", + ) + native_extension = _outside( + Path(_required_text(proof["native_extension"], "wheel proof native_extension")), + source_root, + where="installed native extension", + ) + python_executable = _outside( + Path(_required_text(proof["python_executable"], "wheel proof python_executable")), + source_root, + where="installed Python", + ) + + import pops + from pops import _pops + from pops.codegen import abi as pops_abi + from pops.codegen import toolchain + from pops.runtime.doctor import doctor + + if Path(pops.__file__).resolve() != package_file: + raise RuntimeProbeError("the imported pops package differs from the retained wheel proof") + if Path(_pops.__file__).resolve() != native_extension: + raise RuntimeProbeError("the imported pops extension differs from the retained wheel proof") + if Path(sys.executable).resolve() != python_executable: + raise RuntimeProbeError("the live Python differs from the retained wheel proof") + + raw_checks = _object(doctor(verbose=False), "pops doctor result") + normalized_checks: dict[str, dict[str, Any]] = {} + for name, raw in sorted(raw_checks.items()): + if not isinstance(raw, tuple) or len(raw) != 2 or not isinstance(raw[0], bool): + raise RuntimeProbeError(f"pops doctor check {name!r} is malformed") + normalized_checks[name] = {"passed": raw[0], "detail": repr(raw[1])} + doctor_result = { + "passed": bool(normalized_checks) + and all(item["passed"] for item in normalized_checks.values()), + "checks_sha256": _sha256_json(normalized_checks), + } + module_abi_key = _required_text(_pops.abi_key(), "installed module ABI key") + include_root = _outside( + Path(toolchain.pops_include()), source_root, where="installed PoPS include root" + ) + baked_signature = pops_abi.module_header_signature() + if not isinstance(baked_signature, str) or not baked_signature: + raise RuntimeProbeError("installed module has no baked header signature") + if toolchain.pops_header_signature(include_root) != baked_signature: + raise RuntimeProbeError("installed headers differ from the module ABI signature") + + def header_support(relative: str, needles: tuple[str, ...]) -> bool: + path = include_root / relative + if not path.is_file(): + return False + source = path.read_text(encoding="utf-8") + return all(needle in source for needle in needles) + + detected_support = { + "cell_local_time_commit_receipt_primitives": header_support( + "pops/runtime/program/cell_temporal_partition_executor.hpp", + ("PreparedBatchedCellTemporalExecutor", "prepare_commit_attempt"), + ), + "amr_rebalance_migration_primitives": header_support( + "pops/runtime/amr/amr_runtime.hpp", + ("decide_rebalance", "apply_rebalance_decision"), + ), + } + installation = { + "revision": expected_revision, + "version": _required_text(proof["version"], "wheel proof version"), + "wheel_name": Path(_required_text(proof["wheel_path"], "wheel proof path")).name, + "wheel_sha256": _required_text(proof["wheel_sha256"], "wheel proof wheel_sha256"), + "installed_tree_sha256": _required_text( + proof["installed_tree_sha256"], "wheel proof installed_tree_sha256" + ), + "native_sha256": _required_text(proof["native_sha256"], "wheel proof native_sha256"), + "package_file": str(package_file), + "native_extension": str(native_extension), + "python_executable": str(python_executable), + "outside_source_checkout": True, + } + return installation, module_abi_key, {"doctor": doctor_result, "support": detected_support} + + +def refusal_payload( + *, + revision: str, + installation: dict[str, Any], + module_abi_key: str, + doctor: dict[str, Any], + support: dict[str, Any], +) -> dict[str, Any]: + blockers: list[dict[str, str]] = [] + if doctor["passed"] is not True: + blockers.append( + { + "code": "pops_doctor_failed", + "detail": "the exact installed candidate did not pass every pops.doctor check", + } + ) + if not support["cell_local_time_commit_receipt_primitives"]: + blockers.append( + { + "code": "adc757g_local_time_runtime_unavailable", + "detail": ( + "the installed candidate lacks the accepted local-time publication primitives " + "required for a native runtime receipt" + ), + } + ) + if not support["amr_rebalance_migration_primitives"]: + blockers.append( + { + "code": "adc757c_amr_migration_runtime_unavailable", + "detail": ( + "the installed candidate lacks decide_rebalance/apply_rebalance_decision and " + "cannot prove real accepted-boundary ownership migration" + ), + } + ) + blockers.append( + { + "code": "installed_runtime_matrix_receipts_unavailable", + "detail": ( + "no authenticated serial/threaded/GPU/GPU+MPI same-scenario matrix with artifact, " + "ABI, solution and C/G authority receipts was supplied; vector kernels are not a " + "PoPS runtime proof" + ), + } + ) + return { + "schema": REFUSAL_SCHEMA, + "status": "refused", + "revision": revision, + "installation": installation, + "doctor": doctor, + "module_abi_key": module_abi_key, + "detected_support": support, + "blockers": blockers, + } + + +def _accept_external_matrix( + raw: Any, + *, + revision: str, + installation: dict[str, Any], + module_abi_key: str, + doctor: dict[str, Any], + support: dict[str, Any], +) -> dict[str, Any]: + matrix = _object(raw, "installed runtime matrix") + expected = {"schema", "status", "revision", "scenario_id", "modes", "authorities"} + if set(matrix) != expected or matrix.get("schema") != RUNTIME_SCHEMA: + raise RuntimeProbeError("installed runtime matrix has an unexpected contract") + if matrix.get("status") != "passed" or matrix.get("revision") != revision: + raise RuntimeProbeError("installed runtime matrix did not pass for the candidate revision") + modes = matrix.get("modes") + if ( + not isinstance(modes, list) + or not all(isinstance(item, dict) for item in modes) + or [item.get("id") for item in modes] != list(RUNTIME_MODES) + ): + raise RuntimeProbeError(f"installed runtime matrix requires ordered modes {RUNTIME_MODES}") + if doctor.get("passed") is not True: + raise RuntimeProbeError("the live exact wheel did not pass pops.doctor") + unavailable = [name for name, available in support.items() if available is not True] + if unavailable: + raise RuntimeProbeError( + "the live exact wheel lacks required C/G runtime primitives: " + ", ".join(unavailable) + ) + verifier = runpy.run_path(str(Path(__file__).with_name("verify.py"))) + evidence_error = verifier["EvidenceError"] + try: + verifier["validate_installed_runtime"](matrix, expected_revision=revision) + except evidence_error as error: + raise RuntimeProbeError(f"installed runtime matrix was refused: {error}") from error + + gpu_mpi = _object(modes[-1], "installed runtime gpu_mpi mode") + live_installation = _object(gpu_mpi.get("installation"), "gpu_mpi installation") + expected_installation = { + "wheel_name": installation["wheel_name"], + "wheel_sha256": installation["wheel_sha256"], + "installed_tree_sha256": installation["installed_tree_sha256"], + "native_sha256": installation["native_sha256"], + "package_file": installation["package_file"], + "native_extension": installation["native_extension"], + "python_executable": installation["python_executable"], + "outside_source_checkout": True, + } + if live_installation != expected_installation: + raise RuntimeProbeError("gpu_mpi runtime evidence belongs to another installed wheel") + if gpu_mpi.get("doctor") != doctor: + raise RuntimeProbeError("gpu_mpi runtime evidence belongs to another pops.doctor result") + artifact = _object(gpu_mpi.get("artifact"), "gpu_mpi artifact") + if artifact.get("module_abi_key") != module_abi_key: + raise RuntimeProbeError("gpu_mpi runtime evidence belongs to another module ABI") + return matrix + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel-proof", type=Path, required=True) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--expected-revision", required=True) + parser.add_argument("--runtime-evidence", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + try: + proof = _load_json(args.wheel_proof, "installed wheel proof") + installation, module_abi_key, audit = _installed_candidate( + proof, + source_root=args.source_root, + expected_revision=args.expected_revision, + ) + refusal = refusal_payload( + revision=args.expected_revision, + installation=installation, + module_abi_key=module_abi_key, + doctor=audit["doctor"], + support=audit["support"], + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + if args.runtime_evidence is not None: + accepted = _accept_external_matrix( + _load_json(args.runtime_evidence, "installed runtime matrix"), + revision=args.expected_revision, + installation=installation, + module_abi_key=module_abi_key, + doctor=audit["doctor"], + support=audit["support"], + ) + args.output.write_text( + json.dumps(accepted, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + args.output.write_text( + json.dumps(refusal, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + except (RuntimeProbeError, OSError, ValueError) as error: + print(f"ADC-757 installed runtime preflight failed: {error}", file=sys.stderr) + return 3 + for blocker in refusal["blockers"]: + print( + f"ADC-757 runtime evidence refused [{blocker['code']}]: {blocker['detail']}", + file=sys.stderr, + ) + return 4 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/adc757/verify.py b/benchmarks/adc757/verify.py new file mode 100644 index 000000000..7392d6bfb --- /dev/null +++ b/benchmarks/adc757/verify.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +"""Validate real heterogeneous ADC-757 numerics/performance evidence. + +This verifier never manufactures hardware evidence. It consumes one report produced by the +non-routine device campaign and refuses CPU runs, aliased streams, incomplete numerical parity, +or a candidate that moves less useful work without improving end-to-end time to solution. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +from pathlib import Path +import re +import statistics +import sys +from typing import Any + + +SCHEMA = "pops.adc757.heterogeneous-numerics.v1" +RUNTIME_SCHEMA = "pops.adc757.installed-runtime-matrix.v1" +RUNTIME_SCENARIO = "adc757_amr_advection_runtime_v1" +RUNTIME_MODES = ("serial", "threaded", "gpu", "gpu_mpi") +RUNTIME_AUTHORITY_IDENTITIES = { + "cell_local_time": "pops.local-time.runtime@1", + "amr_rebalance_migration": "pops.amr.rebalance.runtime@1", +} +DEVICE_BACKENDS = ("cuda", "hip", "sycl", "openmptarget") +SCENARIOS = ("prepared_local_time", "cost_aware_load_balance") +METRICS = ( + "time_to_solution_seconds", + "throughput_cell_updates_per_second", + "memory_traffic_bytes", + "kernel_launches", + "task_count", + "communication_bytes", + "communication_seconds", + "fallback_count", + "useful_work_cell_updates", + "imbalance_ratio", + "migration_bytes", + "migration_seconds", +) + + +class EvidenceError(ValueError): + """The supplied report is not closure-quality evidence.""" + + +_SHA256 = re.compile(r"[0-9a-f]{64}") + + +def _mapping(value: Any, where: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise EvidenceError(f"{where} must be an object") + return value + + +def _finite(value: Any, where: str, *, nonnegative: bool = True) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise EvidenceError(f"{where} must be numeric") + result = float(value) + if not math.isfinite(result): + raise EvidenceError(f"{where} must be finite") + if nonnegative and result < 0.0: + raise EvidenceError(f"{where} must be non-negative") + return result + + +def _positive(value: Any, where: str) -> float: + result = _finite(value, where) + if result <= 0.0: + raise EvidenceError(f"{where} must be strictly positive") + return result + + +def _exact_keys(value: dict[str, Any], expected: set[str], where: str) -> None: + if set(value) != expected: + raise EvidenceError( + f"{where} fields are {sorted(value)}, expected exactly {sorted(expected)}" + ) + + +def _canonical_sha256(value: Any) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _nonempty_text(value: Any, where: str) -> str: + if not isinstance(value, str) or not value: + raise EvidenceError(f"{where} must be non-empty text") + return value + + +def _sha256(value: Any, where: str) -> str: + text = _nonempty_text(value, where) + if _SHA256.fullmatch(text) is None: + raise EvidenceError(f"{where} must be one lowercase sha256 digest") + return text + + +def _integer(value: Any, where: str, *, minimum: int) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise EvidenceError(f"{where} must be an integer >= {minimum}") + return value + + +def _validate_installation(raw: Any, where: str) -> None: + installation = _mapping(raw, where) + expected = { + "wheel_name", + "wheel_sha256", + "installed_tree_sha256", + "native_sha256", + "package_file", + "native_extension", + "python_executable", + "outside_source_checkout", + } + _exact_keys(installation, expected, where) + wheel_name = _nonempty_text(installation["wheel_name"], f"{where}.wheel_name") + if not wheel_name.endswith(".whl") or "/" in wheel_name or "\\" in wheel_name: + raise EvidenceError(f"{where}.wheel_name must name one retained wheel") + for name in ("wheel_sha256", "installed_tree_sha256", "native_sha256"): + _sha256(installation[name], f"{where}.{name}") + for name in ("package_file", "native_extension", "python_executable"): + path = _nonempty_text(installation[name], f"{where}.{name}") + if not Path(path).is_absolute(): + raise EvidenceError(f"{where}.{name} must be an absolute installed path") + if installation["outside_source_checkout"] is not True: + raise EvidenceError(f"{where} did not prove an installation outside the source checkout") + + +def _validate_doctor(raw: Any, where: str) -> None: + doctor = _mapping(raw, where) + _exact_keys(doctor, {"passed", "checks_sha256"}, where) + if doctor["passed"] is not True: + raise EvidenceError(f"{where}.passed must be true") + _sha256(doctor["checks_sha256"], f"{where}.checks_sha256") + + +def _validate_runtime_mode(raw: Any, expected_id: str, *, scenario_id: str) -> tuple[str, str]: + where = f"installed_runtime.modes[{expected_id}]" + mode = _mapping(raw, where) + _exact_keys( + mode, {"id", "scenario_id", "installation", "doctor", "artifact", "execution"}, where + ) + if mode["id"] != expected_id or mode["scenario_id"] != scenario_id: + raise EvidenceError(f"{where} does not execute the required scenario") + _validate_installation(mode["installation"], f"{where}.installation") + _validate_doctor(mode["doctor"], f"{where}.doctor") + + artifact = _mapping(mode["artifact"], f"{where}.artifact") + _exact_keys( + artifact, + {"identity", "abi_key", "module_abi_key", "abi_compatible"}, + f"{where}.artifact", + ) + identity = _nonempty_text(artifact["identity"], f"{where}.artifact.identity") + abi_key = _nonempty_text(artifact["abi_key"], f"{where}.artifact.abi_key") + _nonempty_text(artifact["module_abi_key"], f"{where}.artifact.module_abi_key") + if artifact["abi_compatible"] is not True: + raise EvidenceError(f"{where}.artifact did not prove ABI compatibility") + + execution = _mapping(mode["execution"], f"{where}.execution") + _exact_keys( + execution, + {"backend", "mpi_ranks", "accepted_steps", "final_time", "solution_sha256"}, + f"{where}.execution", + ) + backend = _nonempty_text(execution["backend"], f"{where}.execution.backend") + ranks = _integer(execution["mpi_ranks"], f"{where}.execution.mpi_ranks", minimum=1) + _integer(execution["accepted_steps"], f"{where}.execution.accepted_steps", minimum=1) + _positive(execution["final_time"], f"{where}.execution.final_time") + solution = _sha256(execution["solution_sha256"], f"{where}.execution.solution_sha256") + + lower_backend = backend.lower() + if expected_id == "serial": + if "serial" not in lower_backend or ranks != 1: + raise EvidenceError("serial runtime mode requires a one-rank Serial backend") + elif expected_id == "threaded": + if not any(token in lower_backend for token in ("openmp", "threads")) or ranks != 1: + raise EvidenceError("threaded runtime mode requires a one-rank threaded backend") + elif expected_id == "gpu": + if not any(token in lower_backend for token in DEVICE_BACKENDS) or ranks != 1: + raise EvidenceError("gpu runtime mode requires a one-rank accelerator backend") + elif not any(token in lower_backend for token in DEVICE_BACKENDS) or ranks < 2: + raise EvidenceError("gpu_mpi runtime mode requires an accelerator and at least two ranks") + return solution, f"{identity}\0{abi_key}" + + +def _validate_authority( + raw: Any, + where: str, + *, + expected_identity: str, + gpu_mpi_artifact: str, + migration: bool, +) -> None: + authority = _mapping(raw, where) + common = {"consumed", "identity", "artifact_identity", "abi_key", "receipt_sha256"} + specific = ( + {"moved_patches", "migration_bytes", "post_migration_steps"} + if migration + else {"accepted_steps", "fallback_count"} + ) + _exact_keys(authority, common | specific, where) + if authority["consumed"] is not True: + raise EvidenceError(f"{where} was not consumed by the installed runtime") + if authority["identity"] != expected_identity: + raise EvidenceError(f"{where}.identity must be {expected_identity!r}") + artifact_identity = _nonempty_text(authority["artifact_identity"], f"{where}.artifact_identity") + abi_key = _nonempty_text(authority["abi_key"], f"{where}.abi_key") + if f"{artifact_identity}\0{abi_key}" != gpu_mpi_artifact: + raise EvidenceError(f"{where} belongs to another artifact or ABI") + _sha256(authority["receipt_sha256"], f"{where}.receipt_sha256") + if migration: + _integer(authority["moved_patches"], f"{where}.moved_patches", minimum=1) + _integer(authority["migration_bytes"], f"{where}.migration_bytes", minimum=1) + _integer(authority["post_migration_steps"], f"{where}.post_migration_steps", minimum=1) + else: + _integer(authority["accepted_steps"], f"{where}.accepted_steps", minimum=1) + if authority["fallback_count"] != 0: + raise EvidenceError(f"{where}.fallback_count must be zero") + + +def validate_installed_runtime(raw: Any, *, expected_revision: str) -> dict[str, Any]: + runtime = _mapping(raw, "installed_runtime") + _exact_keys( + runtime, + {"schema", "status", "revision", "scenario_id", "modes", "authorities"}, + "installed_runtime", + ) + if runtime["schema"] != RUNTIME_SCHEMA or runtime["status"] != "passed": + raise EvidenceError("installed runtime matrix did not pass its exact contract") + if runtime["revision"] != expected_revision: + raise EvidenceError("installed runtime matrix belongs to another revision") + scenario_id = _nonempty_text(runtime["scenario_id"], "installed_runtime.scenario_id") + if scenario_id != RUNTIME_SCENARIO: + raise EvidenceError("installed runtime matrix used another scientific scenario") + modes = runtime["modes"] + if not isinstance(modes, list) or len(modes) != len(RUNTIME_MODES): + raise EvidenceError(f"installed runtime matrix requires exactly {list(RUNTIME_MODES)}") + solutions: list[str] = [] + gpu_mpi_artifact = "" + for raw_mode, expected_id in zip(modes, RUNTIME_MODES, strict=True): + solution, artifact = _validate_runtime_mode(raw_mode, expected_id, scenario_id=scenario_id) + solutions.append(solution) + if expected_id == "gpu_mpi": + gpu_mpi_artifact = artifact + if len(set(solutions)) != 1: + raise EvidenceError("installed runtime modes do not produce the same solution digest") + + authorities = _mapping(runtime["authorities"], "installed_runtime.authorities") + _exact_keys( + authorities, + {"cell_local_time", "amr_rebalance_migration"}, + "installed_runtime.authorities", + ) + _validate_authority( + authorities["cell_local_time"], + "installed_runtime.authorities.cell_local_time", + expected_identity=RUNTIME_AUTHORITY_IDENTITIES["cell_local_time"], + gpu_mpi_artifact=gpu_mpi_artifact, + migration=False, + ) + _validate_authority( + authorities["amr_rebalance_migration"], + "installed_runtime.authorities.amr_rebalance_migration", + expected_identity=RUNTIME_AUTHORITY_IDENTITIES["amr_rebalance_migration"], + gpu_mpi_artifact=gpu_mpi_artifact, + migration=True, + ) + return runtime + + +def _validate_device(report: dict[str, Any], ranks: int) -> None: + device = _mapping(report.get("device"), "device") + _exact_keys(device, {"execution_space", "assignments"}, "device") + execution_space = str(device["execution_space"]) + if not any(token in execution_space.lower() for token in DEVICE_BACKENDS): + raise EvidenceError( + f"device.execution_space must be a real accelerator backend, got {execution_space!r}" + ) + assignments = device["assignments"] + if not isinstance(assignments, list) or len(assignments) != ranks: + raise EvidenceError("device.assignments must contain one entry per MPI rank") + observed_ranks: list[int] = [] + identities: list[str] = [] + for index, raw in enumerate(assignments): + item = _mapping(raw, f"device.assignments[{index}]") + _exact_keys(item, {"rank", "uuid"}, f"device.assignments[{index}]") + rank = item["rank"] + if isinstance(rank, bool) or not isinstance(rank, int): + raise EvidenceError(f"device.assignments[{index}].rank must be an integer") + uuid = item["uuid"] + if not isinstance(uuid, str) or not uuid: + raise EvidenceError(f"device.assignments[{index}].uuid must be non-empty") + observed_ranks.append(rank) + identities.append(uuid) + if sorted(observed_ranks) != list(range(ranks)): + raise EvidenceError("device assignments do not cover every MPI rank exactly once") + if len(set(identities)) != ranks: + raise EvidenceError("each MPI rank must own one distinct accelerator UUID") + + +def _validate_streams(report: dict[str, Any]) -> None: + streams = _mapping(report.get("streams"), "streams") + _exact_keys( + streams, + {"identities", "correctness_parity", "overlap_observed", "workspace_disjoint"}, + "streams", + ) + identities = streams["identities"] + if not isinstance(identities, list) or len(identities) < 2: + raise EvidenceError("streams.identities must contain at least two prepared streams") + if any(not isinstance(value, str) or not value for value in identities): + raise EvidenceError("every prepared stream identity must be a non-empty string") + if len(set(identities)) != len(identities): + raise EvidenceError("prepared stream identities alias one another") + for field in ("correctness_parity", "overlap_observed", "workspace_disjoint"): + if streams[field] is not True: + raise EvidenceError(f"streams.{field} must be proved true") + + +def _validate_measurement(raw: Any, where: str) -> dict[str, float]: + measurement = _mapping(raw, where) + _exact_keys(measurement, set(METRICS), where) + values = {name: _finite(measurement[name], f"{where}.{name}") for name in METRICS} + _positive(values["time_to_solution_seconds"], f"{where}.time_to_solution_seconds") + _positive( + values["throughput_cell_updates_per_second"], + f"{where}.throughput_cell_updates_per_second", + ) + return values + + +def _validate_correctness(raw: Any, where: str) -> None: + correctness = _mapping(raw, where) + expected = { + "passed", + "mass_error", + "restart_max_error", + "rollback_max_error", + "ledger_balance_error", + } + _exact_keys(correctness, expected, where) + if correctness["passed"] is not True: + raise EvidenceError(f"{where}.passed must be true") + for name in expected - {"passed"}: + value = _finite(correctness[name], f"{where}.{name}") + if value > 1.0e-11: + raise EvidenceError(f"{where}.{name}={value} exceeds 1e-11") + + +def _validate_scenario(raw: Any, expected_id: str) -> None: + scenario = _mapping(raw, f"scenario[{expected_id}]") + _exact_keys( + scenario, + { + "id", + "baseline", + "candidate", + "correctness", + "minimum_speedup", + "abba_time_to_solution_seconds", + }, + f"scenario[{expected_id}]", + ) + if scenario["id"] != expected_id: + raise EvidenceError( + f"scenario id {scenario['id']!r} appears where {expected_id!r} is required" + ) + baseline = _validate_measurement(scenario["baseline"], f"{expected_id}.baseline") + candidate = _validate_measurement(scenario["candidate"], f"{expected_id}.candidate") + _validate_correctness(scenario["correctness"], f"{expected_id}.correctness") + minimum_speedup = _positive(scenario["minimum_speedup"], f"{expected_id}.minimum_speedup") + if minimum_speedup < 1.0: + raise EvidenceError(f"{expected_id}.minimum_speedup must require a net benefit") + blocks = scenario["abba_time_to_solution_seconds"] + if not isinstance(blocks, list) or len(blocks) < 5: + raise EvidenceError(f"{expected_id} requires at least five measured ABBA blocks") + ratios: list[float] = [] + baseline_samples: list[float] = [] + candidate_samples: list[float] = [] + for index, raw_block in enumerate(blocks): + if not isinstance(raw_block, list) or len(raw_block) != 4: + raise EvidenceError(f"{expected_id} ABBA block {index} must contain A,B,B,A") + a1, b1, b2, a2 = ( + _positive(value, f"{expected_id}.abba[{index}][{column}]") + for column, value in enumerate(raw_block) + ) + baseline_samples.extend((a1, a2)) + candidate_samples.extend((b1, b2)) + ratios.append(math.sqrt((a1 * a2) / (b1 * b2))) + measured_baseline = statistics.median(baseline_samples) + measured_candidate = statistics.median(candidate_samples) + if not math.isclose(baseline["time_to_solution_seconds"], measured_baseline, rel_tol=1.0e-12): + raise EvidenceError(f"{expected_id} baseline summary differs from ABBA samples") + if not math.isclose(candidate["time_to_solution_seconds"], measured_candidate, rel_tol=1.0e-12): + raise EvidenceError(f"{expected_id} candidate summary differs from ABBA samples") + speedup = statistics.median(ratios) + if speedup < minimum_speedup: + raise EvidenceError( + f"{expected_id} speedup {speedup:.6g} is below required {minimum_speedup:.6g}" + ) + if ( + candidate["throughput_cell_updates_per_second"] + <= baseline["throughput_cell_updates_per_second"] + ): + raise EvidenceError(f"{expected_id} does not improve measured throughput") + if expected_id == "prepared_local_time": + if candidate["useful_work_cell_updates"] >= baseline["useful_work_cell_updates"]: + raise EvidenceError("prepared local time does not reduce useful-work updates") + if candidate["fallback_count"] != 0.0: + raise EvidenceError("prepared local time silently used a fallback") + else: + if candidate["imbalance_ratio"] >= baseline["imbalance_ratio"]: + raise EvidenceError("cost-aware load balance does not reduce observed imbalance") + if candidate["migration_bytes"] <= 0.0 or candidate["migration_seconds"] <= 0.0: + raise EvidenceError("load-balance evidence must include measured migration cost") + + +def validate(report: Any, *, expected_revision: str) -> dict[str, Any]: + root = _mapping(report, "report") + _exact_keys( + root, + { + "schema", + "status", + "provenance", + "protocol", + "device", + "streams", + "installed_runtime", + "scenarios", + }, + "report", + ) + if root["schema"] != SCHEMA: + raise EvidenceError(f"unexpected report schema {root['schema']!r}") + if root["status"] != "passed": + raise EvidenceError("hardware campaign did not pass") + provenance = _mapping(root["provenance"], "provenance") + _exact_keys( + provenance, + { + "revision", + "build_identity", + "installed_wheel_sha256", + "module_abi_sha256", + "runtime_evidence_sha256", + "mpi_ranks", + "topology_identity", + "timestamp_utc", + }, + "provenance", + ) + if provenance["revision"] != expected_revision: + raise EvidenceError("hardware evidence revision differs from the candidate revision") + installed_runtime = validate_installed_runtime( + root["installed_runtime"], expected_revision=expected_revision + ) + expected_runtime_digest = _canonical_sha256(installed_runtime) + if provenance["runtime_evidence_sha256"] != expected_runtime_digest: + raise EvidenceError("hardware evidence is not bound to its installed runtime matrix") + gpu_mpi = installed_runtime["modes"][-1] + expected_wheel_sha256 = gpu_mpi["installation"]["wheel_sha256"] + expected_module_abi_sha256 = hashlib.sha256( + gpu_mpi["artifact"]["module_abi_key"].encode("utf-8") + ).hexdigest() + if provenance["installed_wheel_sha256"] != expected_wheel_sha256: + raise EvidenceError("hardware evidence used another installed wheel") + if provenance["module_abi_sha256"] != expected_module_abi_sha256: + raise EvidenceError("hardware evidence used another installed module ABI") + for name in ("build_identity", "topology_identity", "timestamp_utc"): + if not isinstance(provenance[name], str) or not provenance[name]: + raise EvidenceError(f"provenance.{name} must be non-empty") + ranks = provenance["mpi_ranks"] + if isinstance(ranks, bool) or not isinstance(ranks, int) or ranks < 2: + raise EvidenceError("hardware evidence requires at least two MPI ranks") + protocol = _mapping(root["protocol"], "protocol") + expected_protocol = { + "ordering": "ABBA", + "clock": "steady_clock", + "device_fence": "before_and_after", + "mpi_barrier": "before_and_after", + "rank_aggregation": "max", + "warmups": 2, + } + if protocol != expected_protocol: + raise EvidenceError(f"protocol must be exactly {expected_protocol}") + _validate_device(root, ranks) + _validate_streams(root) + scenarios = root["scenarios"] + if not isinstance(scenarios, list) or len(scenarios) != len(SCENARIOS): + raise EvidenceError(f"scenarios must contain exactly {list(SCENARIOS)}") + for raw, expected_id in zip(scenarios, SCENARIOS, strict=True): + _validate_scenario(raw, expected_id) + return root + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, required=True) + parser.add_argument("--expected-revision", required=True) + args = parser.parse_args(argv) + try: + report = json.loads(args.input.read_text(encoding="utf-8")) + validate(report, expected_revision=args.expected_revision) + except (EvidenceError, OSError, json.JSONDecodeError) as error: + print(f"ADC-757 hardware evidence refused: {error}", file=sys.stderr) + return 2 + print("ADC-757 hardware evidence: PASSED") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/manifest.toml b/benchmarks/manifest.toml index 05746afde..e62e75faa 100644 --- a/benchmarks/manifest.toml +++ b/benchmarks/manifest.toml @@ -63,3 +63,37 @@ requires_real_device = true requires_distinct_device_per_rank = true job_script = "benchmarks/romeo/adc700_program_cutover.sbatch" submit_script = "benchmarks/romeo/submit_adc700_program_cutover.sh" + +[campaigns.adc757_heterogeneous_numerics] +routine_ci = false +source = "benchmarks/adc757" +report_schema = "pops.adc757.heterogeneous-numerics.v1" +verifier = "benchmarks/adc757/verify.py" +requires_real_device = true +requires_distinct_device_per_rank = true +minimum_mpi_ranks = 2 +minimum_streams = 2 +requires_stream_overlap = true +requires_restart_rollback_and_ledger_parity = true +requires_exact_installed_wheels = true +requires_pops_doctor = true +runtime_scenario = "adc757_amr_advection_runtime_v1" +runtime_modes = ["serial", "threaded", "gpu", "gpu_mpi"] +required_runtime_authorities = ["cell_local_time", "amr_rebalance_migration"] +scenarios = ["prepared_local_time", "cost_aware_load_balance"] +metrics = [ + "time_to_solution_seconds", + "throughput_cell_updates_per_second", + "memory_traffic_bytes", + "kernel_launches", + "task_count", + "communication_bytes", + "communication_seconds", + "fallback_count", + "useful_work_cell_updates", + "imbalance_ratio", + "migration_bytes", + "migration_seconds", +] +job_script = "benchmarks/romeo/adc757_heterogeneous_numerics.sbatch" +submit_script = "benchmarks/romeo/submit_adc757_heterogeneous_numerics.sh" diff --git a/benchmarks/romeo/adc757_heterogeneous_numerics.sbatch b/benchmarks/romeo/adc757_heterogeneous_numerics.sbatch new file mode 100755 index 000000000..9d71f950b --- /dev/null +++ b/benchmarks/romeo/adc757_heterogeneous_numerics.sbatch @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +#SBATCH --job-name=pops-adc757 +#SBATCH --account=r250127 +#SBATCH --constraint=armgpu +#SBATCH --partition=instant +#SBATCH --nodes=1 +#SBATCH --ntasks=2 +#SBATCH --gpus-per-node=2 +#SBATCH --gpus-per-task=1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=64G +#SBATCH --time=01:00:00 +#SBATCH --output=pops-adc757-%j.out +#SBATCH --error=pops-adc757-%j.err + +set -euo pipefail + +romeo_load_armgpu_env +module load cuda/12.6 +spack load openmpi +cuda + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${POPS_ADC757_REPO_ROOT:-$(cd -- "${SCRIPT_DIR}/../.." && pwd)}" +CANDIDATE_REF="${POPS_ADC757_CANDIDATE_REF:-HEAD}" +CANDIDATE_SHA="$(git -C "${REPO_ROOT}" rev-parse "${CANDIDATE_REF}^{commit}")" + +WORK_ROOT="${POPS_ADC757_WORK_ROOT:-/scratch_p/${USER}/${SLURM_JOB_ID}/pops-adc757}" +RESULTS_DIR="${POPS_ADC757_RESULTS_DIR:-${HOME}/pops-benchmark-results/adc757}" +KOKKOS_ROOT="${POPS_KOKKOS_ROOT:-${Kokkos_ROOT:-${HOME}/adc_gpu_p1/kinstall}}" +NVCC_WRAPPER="${POPS_NVCC_WRAPPER:-${KOKKOS_ROOT}/bin/nvcc_wrapper}" +RUNTIME_MATRIX_INPUT="${POPS_ADC757_RUNTIME_EVIDENCE:-}" +ABBA_BLOCKS="${POPS_ADC757_ABBA_BLOCKS:-5}" +EXTENT="${POPS_ADC757_EXTENT:-32768}" +INNER_ITERATIONS="${POPS_ADC757_INNER_ITERATIONS:-96}" +MIGRATION_VALUES_PER_TASK="${POPS_ADC757_MIGRATION_VALUES_PER_TASK:-4096}" +MINIMUM_SPEEDUP="${POPS_ADC757_MINIMUM_SPEEDUP:-1.01}" + +test -x "${NVCC_WRAPPER}" +test "${SLURM_NTASKS:?}" -ge 2 +test "${ABBA_BLOCKS}" -ge 5 +case "${WORK_ROOT}" in + /scratch_p/"${USER}"/*/pops-adc757) ;; + *) echo "refusing unsafe POPS_ADC757_WORK_ROOT: ${WORK_ROOT}" >&2; exit 3 ;; +esac + +cmake -E remove_directory "${WORK_ROOT}" +cmake -E make_directory \ + "${WORK_ROOT}/source" "${WORK_ROOT}/build" "${WORK_ROOT}/wheelhouse" "${RESULTS_DIR}" +git -C "${REPO_ROOT}" archive "${CANDIDATE_SHA}" | tar -xf - -C "${WORK_ROOT}/source" + +# Build and retain one exact distributed CUDA wheel through the official installation route. The +# benchmark executable is configured later with the authenticated headers from this installation; +# it must never compile a second source-tree copy of PoPS. +source "${WORK_ROOT}/source/scripts/conda_runtime.sh" +if ! pops_load_conda; then + echo "ADC-757 requires the official PoPS conda environment to build an exact wheel" >&2 + exit 3 +fi +conda activate "${POPS_ENV_NAME:-pops}" +export CC="${CC:-gcc}" +export CXX="${NVCC_WRAPPER}" +export Kokkos_ROOT="${KOKKOS_ROOT}" +export POPS_KOKKOS_ROOT="${KOKKOS_ROOT}" +bash "${WORK_ROOT}/source/scripts/build_python.sh" --mpi \ + --wheel-dir "${WORK_ROOT}/wheelhouse" + +WHEELS=("${WORK_ROOT}"/wheelhouse/pops-*.whl) +if [[ "${#WHEELS[@]}" -ne 1 ]]; then + echo "ADC-757 expected exactly one retained PoPS wheel" >&2 + exit 3 +fi +WHEEL="${WHEELS[0]}" +PYTHON="${CONDA_PREFIX}/bin/python" +WHEEL_PROOF="${WORK_ROOT}/installed-wheel-proof.json" +PYTHONPATH= PYTHONNOUSERSITE=1 \ + "${PYTHON}" "${WORK_ROOT}/source/scripts/prove_installed_wheel.py" \ + --wheel "${WHEEL}" > "${WHEEL_PROOF}" + +# A vector kernel can no longer authorize a report. The installed probe calls pops.doctor, verifies +# wheel bytes/header ABI, and requires a four-mode same-scenario runtime matrix with C/G receipts. +# Until ADC-757C/G are integrated and such a matrix is supplied, it writes a precise refusal and the +# job stops before the ABBA microbenchmark. +RUNTIME_EVIDENCE="${WORK_ROOT}/installed-runtime-evidence.json" +RUNTIME_PROBE_ARGS=( + --wheel-proof "${WHEEL_PROOF}" + --source-root "${WORK_ROOT}/source" + --expected-revision "${CANDIDATE_SHA}" + --output "${RUNTIME_EVIDENCE}" +) +if [[ -n "${RUNTIME_MATRIX_INPUT}" ]]; then + RUNTIME_PROBE_ARGS+=(--runtime-evidence "${RUNTIME_MATRIX_INPUT}") +fi +set +e +srun --kill-on-bad-exit=1 --ntasks=1 --gpus-per-task=1 \ + env PYTHONPATH= PYTHONNOUSERSITE=1 \ + "${PYTHON}" "${WORK_ROOT}/source/benchmarks/adc757/runtime_probe.py" \ + "${RUNTIME_PROBE_ARGS[@]}" +RUNTIME_PROBE_STATUS=$? +set -e +if [[ "${RUNTIME_PROBE_STATUS}" -ne 0 ]]; then + if [[ -f "${RUNTIME_EVIDENCE}" ]]; then + cp "${RUNTIME_EVIDENCE}" \ + "${RESULTS_DIR}/adc757-${SLURM_JOB_ID}-runtime-refusal.json" + fi + echo "ADC-757 report refused: installed runtime proof exited ${RUNTIME_PROBE_STATUS}" >&2 + exit "${RUNTIME_PROBE_STATUS}" +fi + +readarray -t INSTALLED_IDENTITIES < <( + PYTHONPATH= PYTHONNOUSERSITE=1 "${PYTHON}" - \ + "${WHEEL_PROOF}" "${RUNTIME_EVIDENCE}" <<'PY' +import hashlib +import json +from pathlib import Path +import sys + +from pops import _pops +from pops.codegen import toolchain +import importlib.metadata + +wheel_proof = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +runtime = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) +canonical = json.dumps(runtime, sort_keys=True, separators=(",", ":"), ensure_ascii=True) +print(wheel_proof["wheel_sha256"]) +print(hashlib.sha256(_pops.abi_key().encode("utf-8")).hexdigest()) +print(hashlib.sha256(canonical.encode("utf-8")).hexdigest()) +print(importlib.metadata.distribution("pops").locate_file("")) +print(toolchain.pops_include()) +PY +) +if [[ "${#INSTALLED_IDENTITIES[@]}" -ne 5 ]]; then + echo "ADC-757 could not resolve exact installed identities" >&2 + exit 3 +fi +WHEEL_SHA256="${INSTALLED_IDENTITIES[0]}" +MODULE_ABI_SHA256="${INSTALLED_IDENTITIES[1]}" +RUNTIME_EVIDENCE_SHA256="${INSTALLED_IDENTITIES[2]}" +POPS_INSTALL_PREFIX="${INSTALLED_IDENTITIES[3]}" +POPS_INCLUDE_ROOT="${INSTALLED_IDENTITIES[4]}" + +cmake -S "${WORK_ROOT}/source/benchmarks/adc757" -B "${WORK_ROOT}/build" \ + -DPOPS_ADC757_REVISION="${CANDIDATE_SHA}" \ + -DPOPS_ADC757_WHEEL_SHA256="${WHEEL_SHA256}" \ + -DPOPS_ADC757_MODULE_ABI_SHA256="${MODULE_ABI_SHA256}" \ + -DPOPS_ADC757_INCLUDE_ROOT="${POPS_INCLUDE_ROOT}" \ + -DCMAKE_PREFIX_PATH="${POPS_INSTALL_PREFIX};${KOKKOS_ROOT}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_COMPILER="${NVCC_WRAPPER}" \ + -DKokkos_ROOT="${KOKKOS_ROOT}" +cmake --build "${WORK_ROOT}/build" --target adc757_heterogeneous_numerics \ + --parallel "${SLURM_CPUS_PER_TASK}" + +EXECUTABLE="${WORK_ROOT}/build/bin/adc757_heterogeneous_numerics" +RAW="${WORK_ROOT}/measurements.jsonl" +INVENTORY="${WORK_ROOT}/device-inventory.txt" +REPORT="${WORK_ROOT}/report.json" +: > "${RAW}" + +run_one() { + local scenario="$1" + local route="$2" + local log="${WORK_ROOT}/${scenario}-${route}-${RUN_SERIAL}.log" + # cudaDeviceGetUuid authenticates the physical device selected by each rank. The executable + # gathers those UUIDs collectively and refuses aliased Slurm placement before measuring. + srun --kill-on-bad-exit=1 --ntasks="${SLURM_NTASKS}" --gpus-per-task=1 \ + "${EXECUTABLE}" \ + --scenario="${scenario}" \ + --route="${route}" \ + --extent="${EXTENT}" \ + --inner-iterations="${INNER_ITERATIONS}" \ + --migration-values-per-task="${MIGRATION_VALUES_PER_TASK}" \ + --runtime-evidence-sha256="${RUNTIME_EVIDENCE_SHA256}" | tee "${log}" + grep -E '^\{"schema":"pops\.adc757\.heterogeneous-numerics\.measurement\.v1"' \ + "${log}" >> "${RAW}" + RUN_SERIAL=$((RUN_SERIAL + 1)) +} + +RUN_SERIAL=0 +for scenario in prepared_local_time cost_aware_load_balance; do + for ((block = 0; block < ABBA_BLOCKS; ++block)); do + run_one "${scenario}" baseline + run_one "${scenario}" candidate + run_one "${scenario}" candidate + run_one "${scenario}" baseline + done +done + +python3 "${WORK_ROOT}/source/benchmarks/adc757/assemble.py" \ + --input "${RAW}" \ + --output "${REPORT}" \ + --device-inventory-output "${INVENTORY}" \ + --runtime-evidence "${RUNTIME_EVIDENCE}" \ + --expected-revision "${CANDIDATE_SHA}" \ + --minimum-speedup "${MINIMUM_SPEEDUP}" +python3 "${WORK_ROOT}/source/benchmarks/adc757/verify.py" \ + --input "${REPORT}" \ + --expected-revision "${CANDIDATE_SHA}" + +cp "${RAW}" "${RESULTS_DIR}/adc757-${SLURM_JOB_ID}-measurements.jsonl" +cp "${INVENTORY}" "${RESULTS_DIR}/adc757-${SLURM_JOB_ID}-devices.txt" +cp "${WHEEL_PROOF}" "${RESULTS_DIR}/adc757-${SLURM_JOB_ID}-installed-wheel.json" +cp "${RUNTIME_EVIDENCE}" "${RESULTS_DIR}/adc757-${SLURM_JOB_ID}-installed-runtime.json" +cp "${REPORT}" "${RESULTS_DIR}/adc757-${SLURM_JOB_ID}-report.json" +echo "ADC757_REPORT=${RESULTS_DIR}/adc757-${SLURM_JOB_ID}-report.json" diff --git a/benchmarks/romeo/submit_adc757_heterogeneous_numerics.sh b/benchmarks/romeo/submit_adc757_heterogeneous_numerics.sh new file mode 100755 index 000000000..8f2a654af --- /dev/null +++ b/benchmarks/romeo/submit_adc757_heterogeneous_numerics.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec sbatch "$@" "${SCRIPT_DIR}/adc757_heterogeneous_numerics.sbatch" diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index f8e298ef3..4a18542ef 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -145,6 +145,17 @@ global auxiliary slot, or provider outside its resolved pack. It returns `FluxDe applied exactly once by the spatial layer. A fallible evaluation maps explicitly to retry, reject or abort transaction actions. +Primitive face reconstruction is a fallible numerical operation, not an unchecked model callback. +Every conservative-to-primitive stencil sample is evaluated through +`PreparedVariableRecovery` and returned as a `ReconstructedFaceState` carrying both the candidate +and its `RecoveryReport`. Cartesian, cached-HLL, masked, polar and embedded-boundary kernels consume +that report before calling the numerical flux. A refused candidate therefore writes only finite +transactional scratch, joins the same device/MPI failure reduction as a fallible flux, and cannot be +published. The type-erased report preserves the selected and last-attempted method kinds in addition +to their chain indices; diagnostics can therefore name the actual closed-form, nonlinear, bracketed, +repair, or custom route without reconstructing policy from an erased plan. The pointwise route is +fixed-size, `POPS_HD`, allocation-free and callback-free. + **Constraints / remarks.** CFL condition: $\Delta t \le C\,\dfrac{\min(\Delta x,\Delta y)}{\max|\lambda|}$, where $\lambda$ is the local wave speed and $C \le 1$ at order 1; `max_wave_speed_mf` provides $\max|\lambda|$. A model without transport ($\max|\lambda| = 0$) does not constrain the step @@ -220,10 +231,27 @@ requires `m.wave_speeds`). `HLLCFlux` requires `HasHLLCStructure` (`pressure`, ` `hllc_star_state`) and `RoeFlux` requires `HasRoeDissipation` (`roe_dissipation`). Euler conforms through those same capabilities. A missing capability is rejected during route resolution; there is no component-count inference and no implicit HLL/Rusanov substitution. The -compatibility function `rusanov_flux` (in `spatial_operator.hpp`) delegates to `RusanovFlux{}` for serial -references. The flux is passed by template: `compute_face_fluxes` and -`assemble_rhs` are templated on the flux policy, chosen -independently of the limiter. The `SourceFreeModel` adapter (explicit IMEX half-step) forwards +four built-ins return the common device-copyable `FluxEvaluation`. Built-in rejection reasons use +the typed `RiemannFailureCause` vocabulary before device/MPI reduction. In particular, Roe rejects +a non-finite dissipation or final candidate flux, while HLLC attributes non-finite physical flux, +pressure, contact speed, star state, and final candidate flux separately. Neither policy publishes a +successful NaN result; the runtime rolls the owning step transaction back without selecting another +solver. Every production result also carries typed requested, used and last-attempted solver +identities plus the attempt count. The typed public +`riemann.Recovery(primary=riemann.Roe(), fallbacks=(riemann.HLL(), riemann.Rusanov()))` +descriptor lowers exactly to +`PreparedRiemannRecoveryPolicy` on Cartesian +Uniform and AMR routes. Other orders, duplicate candidates, candidate options, external descriptors, +and untyped values are refused during authoring; annular polar geometry is explicitly unavailable. +Only `kReject` advances to the next candidate, and the first recovery cause remains observable when +a fallback succeeds. The policy is an empty, trivially-copyable template value instantiated directly +in the face kernel: no per-face allocation, string dispatch, callback, exception or host round trip +is introduced. +The compatibility function `rusanov_flux` (in `spatial_operator.hpp`) delegates to +`RusanovFlux{}` for serial references. The flux is passed by template: +`compute_face_fluxes` and +`assemble_rhs` are templated on the flux policy, chosen independently +of the limiter. The `SourceFreeModel` adapter (explicit IMEX half-step) forwards `pressure`, `wave_speeds`, and the optional HLLC/Roe structural hooks only when the wrapped model exposes them (`requires` clauses), so the explicit half-step keeps the selected Riemann provider. A moment hierarchy (no fluid roles, no primitive `p`) can also @@ -231,8 +259,14 @@ drive a dense Roe-type dissipation via the DSL emitter `m.roe_from_jacobian()` ( `pops::roe_abs_apply` ([`include/pops/numerics/linalg/dense_eig.hpp`](../include/pops/numerics/linalg/dense_eig.hpp)) behind a real-spectrum gate. A real singular Jacobian uses the native zero-mode projector. A complex or non-converged -spectrum is rejected; the provider never substitutes another Riemann solver. Passing -`entropy_fix=delta` applies the Harten spectral function directly to the dense Jacobian. This provider +spectrum is rejected; the provider never substitutes another Riemann solver. Passing the typed +`entropy_fix=riemann.Harten(delta)` policy applies the Harten spectral function directly to the +dense Jacobian; `riemann.NoEntropyFix()` (the default for `roe_from_jacobian`) selects the matrix +absolute value. Role-generated Roe keeps its historical `riemann.Harten(0.1)` default and also +accepts `riemann.NoEntropyFix()` explicitly. Bare entropy scalars are rejected during authoring. +The detached artifact records `fluid_roles_v1`, `direct_action_v1`, or `flux_jacobian_v1` together +with the exact canonical entropy option. Runtime availability and inspection consume that evidence; +they never reconstruct a provider from `has_roe=True`. This provider evaluates the flux Jacobian at the arithmetic midpoint $(U_L+U_R)/2$. It is therefore a Roe-type linearization for a general nonlinear flux, not a claim that the resulting matrix satisfies the exact Roe secant identity $F_R-F_L=A(U_R-U_L)$. @@ -369,8 +403,8 @@ function weno5z(vm2, vm1, v0, vp1, vp2): # face entre v0 et vp1 **Code.** Pointwise `Limiter` policies in [`include/pops/numerics/fv/reconstruction.hpp`](../include/pops/numerics/fv/reconstruction.hpp): `NoSlope` -(`n_ghost = 1`, `operator()` returns `Real(0)`), `Minmod` and `VanLeer` (`n_ghost = 2`, `operator()(a,b)` -returns the limited slope, absolute value coded by hand to stay device-safe without ``), `Weno5` +(`n_ghost = 1`, piecewise-constant face value), `Minmod`, `VanLeer`, `MC` and `Superbee` +(`n_ghost = 2`, `limited_slope(a,b)` returns the limited slope with device-safe scalar arithmetic), `Weno5` (`n_ghost = 3`, a tag whose `operator()` is a no-op that just satisfies the `Limiter` concept). The order-5 reconstruction lives in the free function `weno5z(vm2, vm1, v0, vp1, vp2)` of the same header: it returns the value at the face between `v0` and `vp1`, and for the opposite face one passes it the @@ -378,12 +412,18 @@ reversed stencil. All are `POPS_HD` (device-callable, static polymorphism: the l parameter of `assemble_rhs` / `compute_face_fluxes`, inlined on device). The mesh stencil access and the routing by `n_ghost` are in `reconstruct` of `numerics/spatial_operator.hpp`; the policy itself loops over no grid. The reconstruction can act on the conserved or primitive variables -(`rho, u, p`) depending on the block. +(`rho, u, p`) depending on the block. Production kernels use the typed +`reconstruct_recovered`/`reconstruct_pp_recovered` entry points and consume their `RecoveryReport` +before any face flux; the value-only wrappers remain low-level compatibility helpers. **Constraints / remarks.** The reconstruction does not change the hyperbolic stability condition: the step stays bounded by the CFL of section 1, `dt <= C dx / max|lambda|`. Limits and pitfalls: - `Minmod` is strictly TVD but falls back to local order 1 at extrema (it erases smooth peaks); for the Diocotron growth modes one prefers `VanLeer`, less dissipative at extrema. +- `MC` uses $\operatorname{minmod}((a+b)/2,2a,2b)$ and is a less diffusive TVD compromise; + `Superbee` uses $\operatorname{maxmod}(\operatorname{minmod}(2a,b), + \operatorname{minmod}(a,2b))$ and is the most compressive builtin MUSCL limiter. Their + implementations avoid overflowing intermediate doubled slopes for finite inputs. - `weno5z` is smooth (no branch on the sign: the $\beta_k$ and $\tau_5$ are squares so always $\ge 0$, and only $|\beta_0-\beta_2|$ goes through a ternary), which makes it fully device-callable; the floor `eps = 1e-40` avoids division by zero on a constant stencil. @@ -391,10 +431,29 @@ step stays bounded by the CFL of section 1, `dt <= C dx / max|lambda|`. Limits a (the reconstructed states can leave the admissible domain on the conserved side). - The ghost cost drives the halo width to exchange: 1 (NoSlope), 2 (MUSCL), 3 (WENO5). +For a Python-authored model, finite primitive recovery can be strengthened with explicit physical +constraints after declaring the primitive layout: + +```python +# after model.primitive_state(rho, u, v, p, conservative=(...)) +model.recovery_admissibility(rho=rho > 0, p=p > 0) +``` + +Each keyword identifies the primitive component reported on failure; its value is a symbolic Boolean +expression over the primitive state. Code generation emits a device-callable +`recovery_admissible(Prim, failing_component)` method. `CompositeModel` forwards that optional +contract and `prepare_model_variable_recovery` installs it in the same ordered recovery plan as the +conversion method. A finite candidate that violates a predicate is therefore not published: the +chain proceeds to its next declared method, or finishes with `inadmissible_candidate` when no method +remains. Models that declare no policy retain the finite-only path and emit no extra method. + **Validation.** `test_weno_convergence` (the face reconstruction of a smooth function reaches order 5), `test_primitive_recon` (conserved <-> primitive conversions and their use in the reconstruction), `test_spatial_discretisation` (the reconstruction x numerical flux pair is a named type, exercised end -to end). +to end), and `test_weno_convergence` (MC/Superbee reference formulas, symmetry, homogeneity, TVD +bounds and finite extreme inputs in addition to WENO convergence), and +`test_variable_recovery_chain` (a model-declared physical predicate blocks publication +and preserves the typed failing component). --- @@ -597,6 +656,11 @@ central runtime policy (`abs_tol=1e-12`, `rel_tol=1e-10`, 25 iterations) into th controls. Singular pivots, exhausted budgets, NaN/Inf, inadmissible candidates, safeguard failures and unsupported Jacobian capabilities remain distinct outcomes. Collective priority is independent of status numbering, so a fatal cell or MPI-rank failure cannot be hidden by a recoverable rejection. +Once that priority is known, the first failing location is selected by exact staged integer +collectives (`min(j)`, then `min(i)`, then `min(component)` at that cell). Coordinates are never +packed into a floating-point mantissa, so negative and large global `Box2D` indices keep the same +diagnostic and MPI ordering on double- and single-precision builds. These extra collectives execute +only on the failure path. There is no warning-only or unchecked publication policy. Limits: `imex_euler_step` is first order in time (forward-backward Euler); the AP covers the relaxation limit, not the condensation of the potential-velocity-Lorentz couplings at high `omega_c`, which is the @@ -609,7 +673,9 @@ runtime does not infer that split. Validation: `test_imex_ap` (AP property on a stiff linear relaxation source), `test_ap_limit` (quantified AP limit, stiffness sweep over 8 decades at fixed `dt`), `test_imex_partial` (a 2-variable model, only one implicit), -`test_imex_transport` (the transport of an IMEX block is indeed advanced explicitly). +`test_imex_transport` (the transport of an IMEX block is indeed advanced explicitly), and +`test_newton_robustness` plus `test_mpi_field_plan_consensus` (exact first-failure selection across +large signed indices, including fatal-over-recoverable precedence between ranks). --- @@ -1540,8 +1606,9 @@ $S_g$ is the geometric curvature source ($-\rho v_\theta^2/r$ etc.), not capture divergence in a rotating local basis; it is carried per cell (null for a scalar ExB brick -> bit-identical to the historical polar ExB transport). The weight $r_{i+1/2}$ of an interior face is shared by the two neighboring cells, so the radial term telescopes; the azimuthal term telescopes -exactly (periodic). With `wall_radial`, the radial flux is forced to zero at the two physical boundary -faces -> mass $\sum n_{ij}\, r_i\, dr\, d\theta$ conserved to the machine whatever $v_r$. +exactly (periodic). When the immutable `PreparedBoundaryPlan` assigns `NoFlux` to the two radial +faces, their evaluated numerical flux is forced to zero -> mass +$\sum n_{ij}\, r_i\, dr\, d\theta$ conserved to the machine whatever $v_r$. **Formula / discretization (Poisson, FFT-in-theta + tridiag-in-r).** We solve $\tfrac{1}{r}\partial_r(r\,\partial_r\phi) + \tfrac{1}{r^2}\partial_\theta^2\phi = f$ directly @@ -1591,8 +1658,8 @@ the gauge by pinning $\hat\phi(0,0) = 0$ (row 0 replaced by the identity in Thom opt-in via the advanced `pops.mesh.PolarMesh`; `cfg.geometry == "polar"` on the [`src/runtime/system/system.cpp`](../src/runtime/system/system.cpp) side). Transport: [`include/pops/numerics/spatial/operators/polar_operator.hpp`](../include/pops/numerics/spatial/operators/polar_operator.hpp)`::assemble_rhs_polar` -(`recon_prim`, `wall_radial`), via the named functors `detail::PolarFaceFluxRKernel` (radial flux -weighted by `r_face`, optional wall at the boundary faces), `PolarFaceFluxThetaKernel`, +(`PreparedBoundaryPlan`, `recon_prim`), via the named functors `detail::PolarFaceFluxRKernel` (radial +flux weighted by `r_face`, with `NoFlux` derived from the prepared face laws), `PolarFaceFluxThetaKernel`, `PolarAssembleRhsKernel`; the physical source and the geometric source are routed by the concepts `PolarHasSource` / `PolarHasGeomSource` (`if constexpr`: zero codegen for a scalar brick, ExB path bit-identical). Instantiated via `runtime/block_builder_polar.hpp`, wired in diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67a6ba6c0..fc2783636 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -259,9 +259,11 @@ corner, which stays coherent on both sides of zero (negative ghosts). With a rat level therefore has a mesh $dx_f = dx_c / 2$ at unchanged physical domain. The multi-block co-locates N species on a shared hierarchy (same `BoxArray`, same -`DistributionMapping`, same $dx, dy$ per level); the multi-block supports `regrid_every > 0` (the union-tag regrid rebuilds the -hierarchy from all blocks' tags; `regrid_every == 0` keeps it frozen). Conservation is guaranteed per block via reflux and average_down, described -below. +`DistributionMapping`, same $dx, dy$ per level); the multi-block supports `regrid_every > 0`. +`AmrProgramContext` compares the accepted macro-step with that prepared interval, then calls the +immediate spatial `AmrRuntime::regrid()` primitive when due; `AmrRuntime` does not decide cadence. +The union-tag regrid rebuilds the hierarchy from all blocks' tags, while `regrid_every == 0` keeps it +frozen. Conservation is guaranteed per block via reflux and average_down, described below. ## AMR coarse-fine stencil (reflux) @@ -485,8 +487,11 @@ that same transaction: a failed transform restores its exact accepted bytes, whi transform advances one tagging cycle and publishes the transformed image. The bounded route requires one AMR layout and unchanged MPI cardinality. Serial and exact-`MPI_COMM_WORLD` shared-interface flux groups participate in the same topology rematerialization, all-rank identity consensus, -conservation check, rollback and retry. Rank-changing dynamic interface rematerialization, elliptic -providers and bootstrap staggered caches remain refused. The phase-local history consensus +conservation check, rollback and retry. One rank-local post-transform failure is closed +collectively; rollback restores the complete accepted image before a retry may publish one common +receipt and resume the rematerialized interface. Active-depth changes, unsupported non-finest +replacements at depth greater than two, rank-changing dynamic interface rematerialization, +elliptic providers and bootstrap staggered caches remain refused. The phase-local history consensus fingerprints materialize each dense ring slot collectively; they prove exact all-rank agreement on each hierarchy, not bitwise equality across a topology-changing interpolation. Conservation is the separate native before/after invariant on every accepted solution component. This is a cold-restart @@ -547,6 +552,15 @@ de l'artefact. Un run qui échoue lève une exception ; il ne retourne jamais un Strang and Lie composition are Program macros (`pops.lib.time.strang` / `lie`). They lower explicit sub-flows into the same IR rather than selecting a native `System` stepper branch. +`ProgramContext` and `AmrProgramContext` consume the same `ProgramExecutionServices` authority for +topology-independent generated operations. In particular, persistent RHS/state/scalar scratch is +one shared resource service keyed by IR value, sub-slot and active level. Providers expose only the +authenticated resource identity (topology epoch, process-local materialization generation and +level); the shared service owns validation, invalidation, exact-layout allocation, zero-on-reuse and +profiling for both Uniform and AMR execution. Prepared operator capabilities are likewise retained +as complete evaluation snapshots: a probe re-authenticates the provider clock and topology against +the exact active snapshot, so a provider transition cannot leave a stale nonzero revision usable. + ### Adaptive runtime execution On the adaptive hierarchy, `AmrSystem::step` @@ -799,6 +813,13 @@ model-qualified `FaceTrace` values plus `FaceContext` and returns a typed densit `SpatialOperator` alone applies face and cell measures. Provider packs are selected from exact `(owner, space kind, space name, component)` identities. Missing, unavailable or contract-mismatched providers fail during selection; homonymous components from different owners never alias. +Generated physical models carry those qualified rows as `flux_provider_requirements`. The native +binder validates their count, qualification, availability, unique in-range storage slots and then +loads only those declared slots into the model-qualified device pack. The physical law reads that +pack directly through the bounded `flux_provider()` protocol: `PhysicalFluxView` never +reconstructs the global `Aux` source/implicit carrier. Hand-written C++ fixtures that do not declare +the generated ABI may populate a full-width test pack, but they execute through the same direct +physical-flux protocol. ## Limitations diff --git a/docs/CODE_DOCUMENTATION_CONVENTION.md b/docs/CODE_DOCUMENTATION_CONVENTION.md index 8274e7205..af5680d64 100644 --- a/docs/CODE_DOCUMENTATION_CONVENTION.md +++ b/docs/CODE_DOCUMENTATION_CONVENTION.md @@ -118,7 +118,7 @@ Prefer: |---|---|---| | Folder/file | Architectural role, boundaries | `runtime/System` orchestrates, does not contain the physics formulas. | | Class | Usage, contract, invariants, constraints | `AmrSystem` orchestrates a common AMR hierarchy. | -| Public method | User/API contract, `@param`, `@return`, `@throws` if useful | `add_block` validates resolved block metadata. | +| Public method | User/API contract, `@param`, `@return`, `@throws` if useful | `Case.block` validates resolved block metadata. | | Complex block | Why the order of operations matters | Poisson then aux then RHS. | | Line | Rare, only bug/trick | `local_size()==0` MPI guard. | diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 3790bd07a..784bf1867 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -2,7 +2,8 @@ `PoPS` follows [Semantic Versioning 2.0.0](https://semver.org). Package SemVer and the independently evolving API, semantic IR, normalization, component registry, native ABI, and checkpoint schema -revisions are recorded by `schemas/release_contract.v1.json` and generated for Python/C++. +revisions and the exact full/semantic component-catalog digests are recorded by +`schemas/release_contract.v2.json` and generated for Python/C++. ## Single source of the version number @@ -69,7 +70,7 @@ only by an offline migration tool that emits a complete current artifact. ## Supported release matrix The normative matrix is the generated `SUPPORTED_MATRIX` projection of -`schemas/release_contract.v1.json`. It currently promises Python 3.12, C++20, Kokkos 4.4.01 Serial +`schemas/release_contract.v2.json`. It currently promises Python 3.12, C++20, Kokkos 4.4.01 Serial and OpenMP source builds, a Serial OpenMPI source lane, and a macOS arm64 CPython 3.12 Serial wheel. CUDA/HIP, MPI and Windows wheels are explicitly not promised. A release may narrow or extend this matrix only by changing the versioned contract and proving every declared lane. diff --git a/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md b/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md index e8bb1baf1..a1319e232 100644 --- a/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md +++ b/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md @@ -424,11 +424,23 @@ vérifier `residual_norm <= max(relative_tolerance * reference_residual_norm, ab uniquement un échec de transport ABI et ne fabrique jamais de statut scientifique. La représentation matière est typée (`full`, couverture binaire, fraction cut-cell, ids matériau ou -leur combinaison), jamais simulée par un tableau de `1`. La route actuellement prouvée de bout en bout -est plus étroite que cette ABI : `Uniform(CartesianGrid)`, cell-centered, plein matériau, float64, -host et communicateur série. AMR, embedded boundary, multimatériau, GPU, MPI sans consensus global, -conditions de bord dépendantes d'un état/champ/temps et outer solve non linéaire sont refusés à -`resolve`; les accepter dans un manifest ne suffit pas à rendre l'adapter capable. +leur combinaison), jamais simulée par un tableau de `1`. Deux routes sont prouvées : le `System` +uniforme cell-centered utilise un batch plein matériau, et `AmrSystem` matérialise tous les niveaux +en un unique batch composite. Chaque patch AMR porte son `level`; une couverture binaire masque sur +le niveau parent les cellules couvertes par le niveau enfant. Le couple authentifié est enregistré +comme un `AmrFieldSolverProvider`, appelé une fois collectivement, puis détruit et rematérialisé avec +le nouveau layout après regrid. Après publication et jauge, le runtime restreint les valeurs fines +sur les cellules grossières couvertes, puis matérialise les halos same-level, physiques et +coarse/fine avant tout gradient centré. Les preuves de déclaration, contrat préparé, digest et provenance +sont consensuelles sur le communicateur. La route reste ratio-2, float64/host : MPI exige que les deux +manifests déclarent leur variant CPU+MPI, que le contexte installe exactement +`MPI_COMM_WORLD`/`MPI_DOUBLE` et que le niveau grossier soit distribué. Embedded/cut-cell, +multimatériau, GPU, conditions de bord dépendantes d'un état/champ/temps, réaction et outer solve non +linéaire/JVP restent refusés ; les accepter dans un manifest ne suffit pas à rendre l'adapter capable. +La preuve exécutable MPI actuelle est bornée à deux rangs : chacun possède réellement des patches L0 +et L1, le couple est rematérialisé après un regrid qui change le layout, un échec scientifique +collectif restaure puis réessaie l'état accepté, et une publication divergente sur un seul rang est +refusée par consensus exact. Les tailles de communicateur supérieures à deux restent à qualifier. Cette route sélectionne, pour chacun des deux composants, exactement un variant cible `{dimension: 2, scalar: "float64", device: "cpu"}`. Un variant uniquement 3D, ou plusieurs variants @@ -606,22 +618,24 @@ Les builtins de `pops.lib.amr` et les composants externes implémentent le même provider. Un composant externe est sélectionné sans callback Python : ```python -from pops.amr import ClusteringProvider, TaggerProvider +from pops.amr import ClusteringProvider, RefluxProvider, TaggerProvider layout = AMR( ..., tagger=TaggerProvider(component=my_tagger), clustering=ClusteringProvider(component=my_clustering), + reflux=RefluxProvider(component=my_reflux), ) resolved = pops.resolve( pops.validate(case), layout=layout, - components=(my_tagger, my_clustering), + components=(my_tagger, my_clustering, my_reflux), ) ``` -Les deux valeurs doivent référencer un exact `pops.external.ExternalComponent` portant -respectivement l'interface générée `Tagger` ou `Clustering`. Le même objet exact doit être fourni à +Les trois valeurs doivent référencer un exact `pops.external.ExternalComponent` portant +respectivement l'interface générée `Tagger`, `Clustering` ou `Reflux`. Le même objet exact doit +être fourni à `resolve(components=...)`; son identité de manifest, son interface et sa version traversent `resolve -> compile -> bind`. Le manifest doit déclarer une classification déterministe `bitwise` ou `reproducible`, car chaque rang doit produire la même hiérarchie. Un `Tagger` déclare en plus une @@ -705,9 +719,13 @@ doivent couvrir exactement la hiérarchie. Le provider natif livré matérialise le coeur maillage/stockage en 2D et ses kernels de transfert, correction conservative et sous-cyclage AMR exigent un ratio de transition égal à 2. La correction -coarse/fine reste l'unique ledger de flux détenu par PoPS : aucune interface externe `Reflux` -n'existe, car déléguer ce dépôt créerait une seconde autorité conservative. Une autre dimension ou un autre -ratio est refusé pendant la résolution ou le bind avec les capacités observées. Le coeur de +coarse/fine reste l'unique ledger de flux détenu par PoPS. L'interface native `Reflux` ne peut +déléguer qu'un kernel local et non collectif : PoPS lui fournit les flux coarse/fine déjà intégrés +dans le temps et ramenés sur la même face coarse ; le kernel écrit la correction locale +`side * (fine - coarse) / dx`. PoPS conserve exclusivement la topologie d'interface, le ledger, la +réduction MPI, la transaction et l'application à l'état. Un provider `Reflux` ne devient donc jamais +une seconde autorité conservative. Une autre dimension ou un autre ratio est refusé pendant la +résolution ou le bind avec les capacités observées. Le coeur de planification ne normalise jamais la demande vers ce sous-ensemble. Défensivement, `AmrProgramContext` revalide aussi chaque transition à sa construction et refuse un ratio différent de 2 avant le premier pas : cette limite appartient au provider natif reflux/average-down installé, @@ -1317,9 +1335,10 @@ provider déclare aussi `validate_snapshot()` et doit produire une préparation `discard()` et `rollback()` ; ce protocole est vérifié avant qu'un effet accepté puisse être publié. Les formats livrés sont des descripteurs (`HDF5`, `NPZ`, `ParaView`) abaissés vers des writers réels. -La gate finale rouvre indépendamment chaque HDF5 et ParaView émis et vérifie leur contenu structurel ; -l'existence du fichier seule n'est pas une preuve. La route NPZ est exercée par l'exemple IMEX-AMR et -ses tests de format, sans être présentée comme une réouverture supplémentaire de la gate groupée. +La gate finale rouvre indépendamment chaque HDF5 et ParaView scientifique émis et vérifie leur contenu +structurel ; l'existence du fichier seule n'est pas une preuve. La route NPZ scientifique est exercée +et rouverte uniquement par l'exemple IMEX-AMR. Les archives NPZ de checkpoint appartiennent à la +preuve `strict_restart` et ne peuvent jamais satisfaire cette preuve de format scientifique. La cible d'un `ScientificOutput` est toujours un chemin logique sans suffixe ; le provider possède seul l'extension. `schedule=every(100, clock=program.clock)` publie donc un artefact distinct après chaque centième pas accepté, visible pendant la poursuite du run. Une petite capability de catalogue, @@ -1405,14 +1424,30 @@ paramètres, interfaces, requirements, capabilities, effets, layouts, clocks, d restart et points d'entrée. Le même catalogue génère les IDs et tables C/POD versionnées des interfaces natives (flux numérique, -ghost boundary, closure de champ, tagging, clustering, transfert, solveur de champ, writer et -topologie de champ). Le reflux conservatif reste une autorité interne pilotée par le flux ledger ; -aucune table externe `Reflux` n'est annoncée. Chaque famille possède sa propre version d'interface, indépendante de la version +ghost boundary, closure de champ, tagging, clustering, transfert, kernel local de reflux, solveur de +champ, writer et topologie de champ). Le reflux conservatif complet reste une autorité interne +pilotée par le flux ledger ; la table externe `Reflux` ne couvre que la transformation locale, +non collective, de flux intégrés en correction non appliquée. Chaque famille possède sa propre version d'interface, indépendante de la version du protocole enveloppe. Le loader authentifie identité sémantique, manifest, digest du catalogue, taille/header de table et opérations requises avant de conserver le handle de bibliothèque. Les tables sont résolues une fois à l'installation ; aucun `dlsym`, nom de classe ou dispatch Python n'entre dans une boucle de cellules. +Le contrat `Reflux` v1 possède maintenant un adaptateur préparé interne vers +`PreparedAmrProgramRefluxTransition`. Pour chaque patch enfant local, l'adaptateur reçoit quatre +paires de flux déjà intégrés et écrit quatre corrections dans des buffers persistants empoisonnés +avant l'appel. PoPS vérifie que chaque valeur a été écrite et reste finie, atteint un consensus +d'échec entre rangs, puis applique seul périodicité, masque de couverture, réduction MPI et +publication transactionnelle. La présence et le contrat exact du provider sont également comparés +entre rangs avant toute exécution. + +La sélection `AMR(..., reflux=RefluxProvider(component))` traverse désormais la même résolution +normalisée, identité de provider, artifact et transaction d'installation que `Tagger` et +`Clustering`. Sans sélection explicite, `FluxRegisterReflux` décrit le kernel builtin par le même +protocole et apparaît dans le même rapport de providers. La qualification initiale de l'adaptateur +reste limitée à la cible 2D, `float64`, CPU avec stockage hôte. Le chemin n'est pas encore prouvé par +exécution MPI avec un composant externe, mesure de conservation ni backend GPU. + Les champs sémantiques inconnus, capacités sans preuve, collisions d'identité et entry points manquants sont refusés. Un vieux manifest n'est pas « réparé » silencieusement. @@ -1491,10 +1526,13 @@ scientifiques choisissent obligatoirement un `ParallelMode` typé : d'un unique writer rang 0, `COLLECTIVE` pour les hyperslabs HDF5 MPIO exacts, ou `PER_RANK` pour des artefacts locaux qualifiés par rang et un reçu agrégé. Le mode, le format, la sélection, la cible et l'identité de chaque pièce native (`global_box_index`, `owner_rank`, `replicated`) sont authentifiés -entre rangs avant toute écriture. La route `COLLECTIVE` appelle le backend C++ HDF5 parallèle sur -`MPI_COMM_WORLD`; `h5py` reste uniquement un lecteur/écrivain série optionnel et n'est jamais un -transport MPI. Une dépendance HDF5 parallèle native absente, un mode incompatible ou un backend -Kokkos GPU/device handle non supporté est refusé avant le +entre rangs avant toute écriture. La capture native `ROOT` reçoit uniquement une lane consommateur +dupliquée pour le run et la libère collectivement à sa fermeture ; les façades +`System`/`AmrSystem` n'acceptent plus le singleton monde pour cette route. La route `COLLECTIVE` +appelle le backend C++ HDF5 parallèle avec la lane MPI dupliquée possédée par la session observateur ; +le writer ne redécouvre ni n'emprunte `MPI_COMM_WORLD`. `h5py` reste uniquement un +lecteur/écrivain série optionnel et n'est jamais un transport MPI. Une dépendance HDF5 parallèle +native absente, un mode incompatible ou un backend Kokkos GPU/device handle non supporté est refusé avant le constructeur de `System`/`AmrSystem`; aucune route série implicite ne remplace une demande MPI. Les maillages non structurés, mobiles/déformables ou changeant de topologie, de nouvelles familles de @@ -1516,10 +1554,12 @@ Quatre scripts sont des tests d'acceptation, pas des esquisses : `AMRExecution.subcycled()`, regrid/reflux, HDF5/NPZ/ParaView, restart strict et continuation bit-identique ; 4. `examples/final/EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py` : état 15 moments, layout Uniform, - `Program` IMEX explicite avec garde de réalisabilité dans sa transaction, champ de Poisson, - HDF5/ParaView et continuation bit-identique, sans branche de scénario dans le compilateur. Le - preset `pops.lib.time.IMEX` reste un constructeur d'un `Program` ordinaire ; il ne remplace pas - cette écriture explicite lorsqu'une garde scientifique spécifique doit être composée. + fermeture utilisateur `@closure(4)` abaissée dans le graphe de flux générique, `Program` IMEX + explicite avec garde de réalisabilité et ensemble complet des stores provisoires dans sa + transaction, champ de Poisson, conservation du nombre de particules, HDF5/ParaView et + continuation bit-identique, sans branche de scénario dans le compilateur. Le preset + `pops.lib.time.IMEX` reste un constructeur d'un `Program` ordinaire ; il ne remplace pas cette + écriture explicite lorsqu'une garde scientifique spécifique doit être composée. `scripts/final_release_contract.py` fixe cet ensemble exact : aucun cinquième script `.py` n'est admis dans `examples/final/`. Chaque script doit : @@ -1536,28 +1576,51 @@ dans `examples/final/`. Chaque script doit : ## 14. Gate de conformance finale +Le job de release exécute d'abord +`scripts/run_final_gate.py --wheel --evidence `. Ce gate installe +l'artefact exact avant que +`scripts/prove_public_api_parity.py --wheel --installed --evidence ` +ne résolve la distribution installée avec `importlib.metadata`, sans importer `pops` dans le +processus du gate. Le chemin résolu doit être extérieur au checkout. La preuve compare octet par +octet tous les fichiers Python et de typage (`*.py`, `*.pyi`, `py.typed`) du checkout, du wheel +retenu et du package installé, puis importe séparément les trois arbres dans des interpréteurs +isolés. Les trois snapshots doivent exposer la même racine publique, les mêmes signatures et +annotations, un `Case` explicite, des handles qualifiés distincts et +authoring/validation/inspection sans chargement de `_pops`. Un ancien nom public, un fichier de +typage absent, un chemin provenant du checkout ou une divergence source/wheel/installé bloque la +publication. La preuve authentifie aussi le `Name`, la `Version` et le digest du `METADATA` de la +distribution installée contre ceux du wheel. Enfin `release_preflight.py` reçoit cette evidence via +`--public-api-evidence` et vérifie son producteur, le SHA-256 du wheel et le chemin du package contre +le même runtime installé que l'evidence finale ; une evidence de parité issue d'un autre wheel ou +d'une autre installation ne peut donc pas être réutilisée. + Une release ne peut être déclarée conforme que par `scripts/run_final_gate.py --evidence `. La commande exige un checkout propre, refuse d'écraser une evidence existante et produit une evidence JSON liée au commit, à la version du package, au digest du release contract et au SHA-256 de l'extension native installée. L'evidence est générée depuis les retours de commandes et ne contient pas de booléens fournis à la main. -La séquence groupée couvre exactement les onze lignes authentifiées suivantes : +La séquence groupée couvre exactement les douze lignes authentifiées suivantes : 1. `official_build` : `scripts/setup_env.sh`, `scripts/build_python.sh`, puis configure/build du preset CMake `serial` avec les headers `POPS_INCLUDE` du checkout validé ; -2. `doctor` : `pops.runtime.doctor.doctor()` sur le package installé, sans échec ; -3. `codesign` : `scripts/codesign_pops_extensions.py` sur les extensions installées ; -4. `native_conformance` : CTest complet avec JUnit non vide, sans skip, xfail, failure ni error ; -5. `python_conformance` : suite Python complète, puis lane obligatoire - `not mpi and not hdf5` avec JUnit all-pass et sans skip caché ; -6. `examples` : les quatre scripts exacts depuis le package installé et leurs quatre marqueurs de preuve ; -7. `artifact_reopen` : parsing indépendant de chaque HDF5/NPZ/ParaView, puis réouverture de chaque - HDF5 par `h5py` et de chaque archive/array NPZ par NumPy avec `allow_pickle=False` ; -8. `strict_restart` : checkpoint réel et digest complet de son arbre pour chaque exemple ; -9. `documentation` : `docs/check_docs.py` ; -10. `generated_products` : release contract et component catalog régénérés avec `--check` ; -11. `diff` : `git diff --check`, `git diff --cached --check` et checkout encore propre. +2. `installed_wheel` : installation du wheel retenu puis preuve byte-identical de son extension native, + de ses métadonnées et de son arbre installé ; +3. `codesign` : `scripts/codesign_pops_extensions.py` sur les extensions installées, sans modifier les + octets natifs retenus ; +4. `doctor` : `pops.runtime.doctor.doctor()` sur le package installé, sans échec ; +5. `native_conformance` : CTest complet avec JUnit non vide, sans skip, xfail, failure ni error ; +6. `python_conformance` : ledger Pytest fermé de M4 plus les huit preuves finales d'exemples, exécuté + une fois contre le wheel avec JUnit all-pass, sans skip caché. La suite source complète reste la + responsabilité du job parallèle `full-source-matrix` et n'est pas rejouée en série dans ce job ; +7. `examples` : les quatre scripts exacts depuis le package installé et leurs quatre marqueurs de preuve ; +8. `artifact_reopen` : parsing indépendant des HDF5 et ParaView scientifiques de chaque exemple, plus + du NPZ scientifique IMEX-AMR, puis réouverture HDF5 par `h5py` et NPZ par NumPy avec + `allow_pickle=False` ; +9. `strict_restart` : checkpoint réel et digest complet de son arbre pour chaque exemple ; +10. `documentation` : `docs/check_docs.py` ; +11. `generated_products` : release contract et component catalog régénérés avec `--check` ; +12. `diff` : `git diff --check`, `git diff --cached --check` et checkout encore propre. `scripts/release_preflight.py --release --tag --installed --evidence ` refuse une evidence incomplète, issue d'un autre commit, d'un autre digest, d'un autre script de gate ou d'une autre @@ -1595,7 +1658,8 @@ extension installée. Une exigence de la lane obligatoire ne peut pas être couv - `docs/design/consumer_graph_transaction_contract.md` : effets acceptés et rollback ; - `docs/design/temporal-execution-contract.md` : clocks, sous-cycles et restart temporel v2 ; - `docs/design/external-component-packages.md` : extension C++ externe ; -- `schemas/release_contract.v1.json` : versions de schémas, ABI et matrice supportée ; +- `schemas/release_contract.v2.json` : versions de schémas, ABI, digests exacts du catalogue de + composants et matrice supportée ; - `schemas/component_catalog.v2.json` : composants builtin et routes natives ; - `scripts/final_release_contract.py` : spécification et ensemble exact des quatre exemples ; - `scripts/run_final_gate.py` : producteur unique de l'evidence groupée ; diff --git a/docs/design/exact-output-consumers.md b/docs/design/exact-output-consumers.md index 8b81f518b..d053040cb 100644 --- a/docs/design/exact-output-consumers.md +++ b/docs/design/exact-output-consumers.md @@ -53,6 +53,28 @@ ScientificOutput( ) ``` +When the native runtime owns an AMR reflux correction and/or an authored projection, the ledger can +delegate those exact terms instead of requiring zero placeholders. `component` is the exact +conservative index shared by the explicit Program sums and native evidence (it defaults to zero for +a scalar state); the optional typed role is checked against that index at bind: + +```python +from pops.physics.roles import Density + +mass = BalanceLedger( + "mass", + role=Density(), + component=0, + automatic_terms=("projection", "reflux"), +) +program.record_balance( + mass, + storage_change=storage_increment, + outward_boundary_flux=boundary_flux_increment, + sources=source_increment, +) +``` + Le fournisseur possède l'extension. Une cible comme `solution/tracer.vtu` est refusée dès l'authoring, avant le bind ; elle empêcherait le changement de format et entrerait en collision au deuxième échantillon. Chaque pas accepté dû publie immédiatement un fichier distinct sous le chemin @@ -82,13 +104,16 @@ count, target suffix, or writer availability: - `SERIAL` requires the proved serial `ExecutionContext` (rank 0, size 1) and one complete snapshot. - `ROOT` requires a distributed context. Every rank participates in the authenticated native - gather, but only rank 0 prepares, verifies and atomically publishes the single-file writer. - Preparation failures and the final receipt are broadcast to every participant. + gather over a run-scoped duplicated consumer lane, but only rank 0 prepares, verifies and + atomically publishes the single-file writer. The native `System`/`AmrSystem` output bridge + accepts only that owned lane, never the process-world singleton. Preparation failures and the + final receipt are broadcast to every participant. - `COLLECTIVE` requires a distributed context, an authenticated collective resource plan and the - native C++ parallel-HDF5 provider. Each rank writes only its exact non-overlapping native - hyperslabs with exactly one MPIO collective transfer per dataset and rank (including a select-none - transfer for a rank with no patch). A replicated AMR coarse patch is assigned to rank 0 for this - mode so it cannot overlap. + native C++ parallel-HDF5 provider. The observer runtime owns a duplicated MPI lane for the complete + writer session; neither the Python writer nor the native HDF5 adapter borrows or rediscovers the + process world. Each rank writes only its exact non-overlapping native hyperslabs with exactly one + MPIO collective transfer per dataset and rank (including a select-none transfer for a rank with no + patch). A replicated AMR coarse patch is assigned to rank 0 for this mode so it cannot overlap. - `PER_RANK` requires a distributed context and preserves each rank's exact local pieces, including explicitly replicated coarse pieces. Targets are rank-qualified before any file is opened. The transaction succeeds only after it aggregates one deterministic receipt per contiguous rank. @@ -192,10 +217,11 @@ therefore write NPZ, HDF5 or the complete VTU/PVTU/PVD/state ParaView bundle. `q retained detached snapshots; a full queue deliberately applies backpressure. The selected format owns the topology. `SERIAL` uses the sole rank. `ROOT` performs the complete -snapshot gather on the main execution path, then writes from the rank-zero worker without worker -MPI. `PER_RANK` and `COLLECTIVE` run one worker per rank over a run-scoped communicator duplicated -collectively before any worker starts. That private lane has a distinct MPI context from -`MPI_COMM_WORLD`, so numerical and output collective orderings cannot alias. PoPS requires +snapshot gather on the main execution path over one run-scoped duplicated consumer lane, then +writes from the rank-zero worker without MPI. `PER_RANK` and `COLLECTIVE` run one worker per rank +over a run-scoped communicator duplicated collectively before any worker starts. Those private +lanes have distinct MPI contexts from `MPI_COMM_WORLD`, so numerical and output collective +orderings cannot alias. PoPS requires `MPI_THREAD_MULTIPLE`, authenticates the lane on every worker call and fixes distributed `max_attempts` to one: retrying after entry into an MPI publication would not be safe. Supported mode combinations remain those of the format itself; in particular, ParaView has no `COLLECTIVE` mode and @@ -384,9 +410,10 @@ re-emission; a rank-local `KeyboardInterrupt`/`SystemExit` cannot split collecti - HDF5 uses native datasets and `read_hdf5()` verification. Serial/root fields must be complete. Collective mode requires the compiled C++ parallel-HDF5 route before preparation; every rank writes its declared non-overlapping hyperslabs through the exact authenticated communicator and - the manifest authenticates all pieces. A synchronous consumer uses the execution communicator; - an asynchronous consumer uses its private duplicated worker lane. Python never emulates this - mode with a gather-to-root writer: the compiled provider owns the MPIO dataset transfers. + the manifest authenticates all pieces. The HDF5 session uses its private duplicated observer lane; + neither synchronous nor asynchronous publication borrows the process world. Python never + emulates this mode with a gather-to-root writer: the compiled provider owns the MPIO dataset + transfers. Partition validation scales with piece count rather than global cell count, and shared geometry is written once by rank zero. Unlike the default relayed PVTU topology, the single collective HDF5 target is opened by every rank through parallel HDF5/MPI-IO and must therefore be genuinely @@ -425,6 +452,102 @@ quantity an invariant. Diagnostic-only outputs remain valid: their owner-qualifi terms, layout metadata and provenance are preserved even when no field array is selected. Geometry origins and spacings use the conventional `(x, y)` and `(dx, dy)` order. +An executable open-domain balance uses one shared typed identity rather than a Python callback: + +```python +from pops.diagnostics import Balance, BalanceLedger + +mass = BalanceLedger("mass") +program.record_balance( + mass, + storage_change=storage_increment, + outward_boundary_flux=boundary_flux_increment, + sources=source_increment, + reflux=reflux_increment, + projection=projection_increment, +) + +ScientificOutput( + ..., + diagnostics=(Balance(mass, block=fluid),), +) +``` + +`AsyncScientificOutput(..., diagnostics=(Balance(mass, block=fluid),))` uses the same exact +schedule and transaction. Its reductions are completed on the simulation thread before detachment; +the post-commit worker receives only immutable arrays and scalar payloads, never the native mailbox +or communicator facade. + +Each non-automatic argument to `record_balance` is a signed, time-integrated native Program sum/dot +reduction, or scalar arithmetic composed only from such reductions and exact literals. When any +term is delegated to a native producer, every explicit term must instead be composed from +component-qualified `sum` reductions for the ledger's exact `component`; an all-state dot product +cannot be reconciled with one component's reflux/projection evidence. +The reported residual is `storage_change + outward_boundary_flux - sources - reflux - projection`. +The native attempt mailbox accumulates repeated cadence/substep invocations, rejects missing or +non-finite terms, and is cleared before the next attempt. The consumer reads it only while the +outer accepted-step transaction still retains the pre-step image. Python therefore packages the +five returned scalars and residual but never traverses arrays, invents a zero term, or reuses a +previous step. Selected automatic terms are resolved by exact runtime block, active hierarchy level +and conservative component. A missing coordinate, a non-finite value, or simultaneous Program and +native authority for one term fails the accepted transaction. A rejected attempt or failed consumer +publication restores both mailboxes with the rest of the native transaction. + +The `pops.balance-term` namespace is reserved. Ordinary `Program.record_scalar(...)` authoring and +the Python runtime diagnostic binding both reject it; generated `record_balance` code reaches a +separate native sink that validates the route and canonical term before touching the mailbox. + +The resolved `ConsumerGraph` now compiles one immutable `BalanceDueContract` into the Program +artifact. For `every(n, clock=program.clock)`, the native Program queries the next outer accepted +macro-step before any balance reduction. Off-cadence sum/dot and scalar-arithmetic chains are +short-circuited, and the five terminal records are omitted; no Kokkos kernel, MPI collective or +Python callback is entered for that balance route. Multiple consumers of the same route are joined +by an OR of their exact accepted-step periods. `Always` and `when(True)` are period one, +`when(False)` contributes no occurrence, and a route with no consumer is compiled off. + +The compiler traces the complete reduction/scalar chain rather than scheduling only the terminal +records. If a value is also consumed by an ordinary Program diagnostic or another non-balance +operation, that shared producer remains unconditional so cadence fusion cannot change unrelated +semantics. A `Balance` consumer with no complete matching `Program.record_balance` producer for all +non-automatic terms fails before native code generation. Program stride/substeps use one +attempt-local outer accepted-step +target, so every substep of one due public step sees the same decision and accumulates into the same +attempt mailbox. The cadence is authored once as part of the Program identity, for example +`program.cadence(substeps=2, stride=3)`, then authenticated and installed before runtime freeze on +both Uniform and AMR targets. A stride-held public step executes no Program work and therefore +publishes the exact additive-identity balance (all five terms are zero); a due Program that omits +even one term still fails closed. Accepted-step periods larger than the native signed-32-bit ceiling +can never fire in a representable run and are compiled off instead of being narrowed into C++. +Selective checkpoint reconstruction may re-execute the Program to rebuild omitted history slots, +but that work is not a public accepted step. Uniform and AMR replay therefore enter an explicit +native replay guard: every Balance due query returns false, no term reaches the accepted-attempt +mailbox, and the guard is restored on both success and exception. The replay still executes all +non-Balance scientific operations needed to reconstruct the history exactly. + +This first sparse cutover is exact only for accepted-step `every(n)` schedules. Physical-time +`every_dt`, `on_end`, and extension domains/triggers remain conservatively active for every Program +invocation; their consumer still publishes only when its own runtime schedule is due, but upstream +balance reductions are not yet skipped. This fallback can add work but cannot suppress required +evidence. A zero-step run has no accepted native occurrence: its coincident start/end moment cannot +publish an accepted-step consumer, including `Balance`. + +The selected public route now consumes signed AMR reflux corrections and before/after projection +deltas from the separate qualified attempt mailbox. Uniform Cartesian projection uses the +authenticated cell measure and embedded-boundary mask; AMR projection excludes covered coarse cells +and performs one component-vector collective per participating level. A reflux selection requires +an adaptive hierarchy and expects one contribution for every active parent/fine interface; +projection expects one for every selected active level. Generated code publishes the OR of the exact +due route decisions before the first Program operator; the marker is monotone for the attempt, +disabled during replay, and reset at attempt entry. Consequently off-cadence steps do not pay for +automatic operator reductions. + +The capability remains deliberately bounded. Polar projection is rejected because no exact +per-cell polar volume provider exists on this path. Automatic physical-boundary flux and source +evidence are not yet producers and therefore remain explicit `Program.record_balance` arguments. +The native selector never substitutes a missing automatic value with zero (except the exact reflux +identity for a hierarchy with no coarse/fine interface), and the legacy all-explicit ledger route +retains its original identity and behavior. + Checkpoint remains a separate restart effect. These consumers do not define a checkpoint schema or reader and do not call the scientific-output manifest a restart identity. The checkpoint provider remains the sole owner of sealing, hierarchy/history persistence and strict identity-checked diff --git a/docs/design/external-component-packages.md b/docs/design/external-component-packages.md index caed1db16..78b49f4a6 100644 --- a/docs/design/external-component-packages.md +++ b/docs/design/external-component-packages.md @@ -35,7 +35,9 @@ interprets package JSON. The JSON package schema is strict and versioned. It contains the complete `ComponentManifest` data, explicit exports, payload digests, protocol ABI and a package digest. Paths are canonical relative POSIX paths; absolute paths, traversal and resolved escapes are rejected. Payload bytes are read and -retained at load time, so compilation does not trust a later mutable source path. +retained at load time, so compilation does not trust a later mutable source path. The retained +manifest, package identity and every source/header/IR digest are re-authenticated when authoring, +registering and immediately before compilation; the Python value's type alone is never authority. Source registration compares the complete component-manifest digest and source-package digest. Compiled registration compares the component, exact platform identity, artifact identity and binary @@ -57,8 +59,9 @@ are reused only when their bytes authenticate to the same binary identity. table layouts, plus the version of the common request/value ABI. The complete declaration feeds the catalog digest. The generator emits `pops.interfaces`, the Python route data and `generated_component_abi.hpp` together; `--check` makes any hand-edited drift fail CI. The current -protocol includes separate tables for numerical flux, ghost boundary, field-boundary closure, -tagging, clustering, transfer, reflux, field solve, writer and field topology. Adding an +protocol includes separate tables for numerical flux, ghost boundary, post-Riemann boundary-flux +transformation, field-boundary closure, tagging, clustering, transfer, reflux, field solve, writer +and field topology. Adding an implementation requires no central scientific switch. The installed CPU route proves 2D, `float64`, host execution. It supports source/header payloads and @@ -88,4 +91,10 @@ artifacts on the declared failure path. Other devices, scalar types and dimensions remain unavailable until a target variant and every interface operation prove them. The wheel ships the exact signed PoPS header tree under -`pops/include`, so AOT compilation does not depend on a source checkout. +`pops/include`, so AOT compilation does not depend on a source checkout. The release gate proves +this independently of the ordinary source conformance lane: it clears `POPS_INCLUDE`, imports the +retained installed wheel with an empty `PYTHONPATH`, requires `pops_include()` to resolve exactly to +that wheel's `pops/include`, and rejects a stub or mocked native route before compiling, installing, +loading and invoking the external numerical-flux component. The one exact pytest node produces an +all-pass JUnit report; release preflight reauthenticates its node ID, command, wheel-header authority +and report digest, and refuses skips, xfails, duplicate execution or a checkout header override. diff --git a/docs/design/final-advection-imex-amr.md b/docs/design/final-advection-imex-amr.md index 5eb070960..021e01201 100644 --- a/docs/design/final-advection-imex-amr.md +++ b/docs/design/final-advection-imex-amr.md @@ -28,8 +28,12 @@ property of this graph and tableau, not a repeated `order=2` option. Every fallible public solve returns an unreadable `SolveOutcome`. The example consumes every field solve with `RejectAttempt()`. A failed solve therefore raises the typed native rejection signal before a field, -state, diagnostic or output can read a partial result. Local affine elimination remains a value -operation because it has no iterative outcome to classify. +state, diagnostic or output can read a partial result. The executable acceptance also compiles a +separate negative case whose explicitly widened parameter domain makes the second IMEX diagonal +system exactly singular. It compares state, solved fields, hierarchy topology, the canonical opaque +Program accepted-state image, Program cache/history/clock/ledger registries and consumer cursors +before and after the rejected attempt, then requires that its output directory contain no file. The +normal physical case retains the strictly positive relaxation-rate domain. `Model.field_operator(...)` declares the physical equation and its RHS providers. The sole callable time-Program authority is the `FieldHandle` returned by `Case.field(operator, discretization)`: both @@ -43,6 +47,7 @@ The AMR Program driver owns the accepted-state boundary. A hierarchy attempt sta - level state and clocks; - coarse/fine flux ledgers and reflux contributions; - history rings and their flux publications; +- persistent AMR tagging hysteresis state; - regrid-dependent synchronization state; - field materializations and consumer schedule cursors. @@ -60,15 +65,21 @@ The adaptive layout owns: subcycled execution; this is the installed provider's executable composite-field envelope; - strict above/below refinement and coarsening predicates; - a discrete gradient predicate resolved against the selected FV stencil; -- explicit hysteresis/equality/conflict semantics; +- two-cycle persistent hysteresis plus explicit equality/conflict semantics; - conservative state prolongation, restriction, coarse/fine fill and time interpolation; - elliptic recomputation after regrid instead of interpolating a stale solved field. +Resolution adds each hierarchy, regrid, tagging predicate, hysteresis/conflict policy, transfer +entry, bootstrap authority and subcycling relation to the global `LoweringCoverageReport`. The +non-zero hysteresis row names its Program accepted-state persistence route as well as the native +tagger. Every row therefore names a concrete runtime target; the report is a machine-readable +lowering gate rather than an `inspect()` narrative inferred after compilation. + The acceptance target intentionally requests a regrid on every accepted macro-step. The first snapshot may still expose zero completed regrids: cadence is a due condition, not proof that a non-empty tag set rebuilt the hierarchy. `simulation.amr.explain_regrid()` publishes the native `regrid_count` and `topology_epoch`; after the continuation step the example requires both values to -have increased, and requires the uninterrupted and restarted instances to report identical values. +remain monotone, and requires the uninterrupted and restarted instances to report identical values. `regrid_count` advances only after the native regrid completes, while `topology_epoch` identifies the installed hierarchy topology. A scheduled or no-op regrid is therefore never accepted as completed runtime evidence. @@ -105,10 +116,13 @@ python examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py --output-dir /tm The command reopens the emitted HDF5 and ParaView files, retains a real accepted-state checkpoint and restarts a fresh bound simulation from it. It compares time, macro-step, every AMR level of every qualified conservative state and solved-field route, patch topology, Program/consumer identities and -consumer cursors bit-for-bit. The snapshot also carries the live completed-regrid count and topology -epoch, so checkpoint restore, uninterrupted/restarted continuation and manual/factory parity must -preserve exactly the same AMR generation evidence. It then advances the uninterrupted and restarted -instances once more, requires a real counter/epoch increase, verifies the accepted multi-level flux +consumer cursors bit-for-bit. It also compares the complete opaque Program accepted-state bytes, +which include the persistent tagging history without duplicating its native codec in Python. The +snapshot carries the live completed-regrid count and topology epoch, so checkpoint restore, +uninterrupted/restarted continuation and manual/factory parity must preserve exactly the same AMR +generation evidence. It then advances the uninterrupted and restarted +instances once more, requires monotone counter/epoch evidence, verifies the accepted multi-level flux ledger plus reflux-then-average-down trace, and repeats the complete comparison before exercising the -preset parity run. A printed success therefore follows real I/O, a completed regrid, restart, -continuation and manual/factory checks; it is not a demonstration placeholder. +preset parity run. A printed success therefore follows a real rejected-attempt rollback, real I/O, +an executed regrid cadence window, restart, continuation and manual/factory checks; it is not a +demonstration placeholder. diff --git a/docs/design/hyqmom15-final-contract.md b/docs/design/hyqmom15-final-contract.md index 4a3f8ea26..1e13997b3 100644 --- a/docs/design/hyqmom15-final-contract.md +++ b/docs/design/hyqmom15-final-contract.md @@ -16,12 +16,16 @@ gauge and multigrid solver remain separate `FieldDiscretization` choices on the ## Generic extension boundaries -- `LocalClosure(order, name, evaluator)` is the closure extension interface. The evaluator executes - once on symbolic standardized moments during authoring and must return exactly the order `N + 1` - keys. It is absent from native execution. -- `RealizabilityProjection` configures the smooth floors used by moment algebra. It does not pretend - to be a time-step acceptance guard. A future realizability rejection policy must implement the - ordinary typed `AcceptanceGuard` protocol and participate in the Program transaction explicitly. +- `LocalClosure(order, name, evaluator)` is the closure extension interface. The final script writes + the six fifth-order HyQMOM relations under `@closure(4)` and passes that value to + `HyQMOM15.vlasov_lorentz(closure=...)`. The evaluator executes once on symbolic standardized + moments during authoring and must return exactly the order `N + 1` keys. Its arithmetic is folded + into the ordinary flux graph, so there is no Python callback or mutable closure state in native + execution; the installed Program hash authenticates the resulting graph across restart. +- `RealizabilityProjection` configures the smooth floors and the complete 15-moment projection. + `guard_hyqmom15_candidate(...)` authors ordinary typed acceptance guards with + `ProjectAndRecheck(on_failure=RejectAttempt())` inside the `Program` transaction. Rejection and + rollback therefore use the shared runtime path rather than a HyQMOM-specific branch. - `Model.field_spaces()` derives solved storage from the generic field-output protocol. A scalar `FieldOutput` contributes one component; a Cartesian `GradientOutput` contributes two. This rule lets any provided or user model add a potential-plus-gradient solve without a model-specific @@ -42,6 +46,12 @@ explicitly so its realizability guard is visibly inside the commit transaction. route, but it does not hide this model-specific scientific guard. The local solve is specialized from the resolved state manifest and therefore prepares exact 15 by 15 stack storage for the shared pivoted local provider, without an explicit inverse, eight-component fallback or family dispatch. +The executable also requires the installed transaction plan to own every typed provisional store: +states, fields, topology, flux ledgers, caches, solver warm starts, histories, clocks, schedules, +consumers, diagnostics and external effects. Its forced non-realizable attempt compares the +accepted state, solved fields, histories, Program identity and ConsumerGraph cursors before and +after rejection and refuses any published artifact. This Uniform case has no non-empty AMR reflux +ledger; non-empty multilevel ledger persistence remains the responsibility of the AMR final example. The example executes only: @@ -53,4 +63,7 @@ One accepted step publishes authenticated HDF5, ParaView and scheduled checkpoin script reopens both scientific formats, creates a manual checkpoint, restores it into a fresh bind, compares the full 15-component state, solved field, clock, program identity and consumer cursors, then advances the uninterrupted and restarted instances one more step and requires exact equality. -This is the final behavior, not a transition or compatibility example. +Every retained state must remain realizable and conserve the integral of `M00` over the unit square +within a relative tolerance of `1e-10`; the machine-readable report exposes the measured particle +number and maximum relative error. This is the final behavior, not a transition or compatibility +example. diff --git a/docs/design/m3-conformance-gate.md b/docs/design/m3-conformance-gate.md index ea307c0a4..d24f3a0cb 100644 --- a/docs/design/m3-conformance-gate.md +++ b/docs/design/m3-conformance-gate.md @@ -14,6 +14,8 @@ The full gate covers: - per-space transfer registries and recursive deterministic bootstrap; - exact level clocks, flux ledgers, reflux, rollback, and real MPI execution; - strict accepted-state restart and topology/history/ledger rollback; +- strict two-level qualified-field warm-start restart, including rollback after a deliberately late + post-restore validation failure; - two-rank AMR restart after an accepted regrid and continuation across the next regrid, for replicated and distributed coarse layouts; - checkpoint/restart of every state and mapping counter in a real two-layout @@ -56,8 +58,9 @@ coarse/fine patches, checkpoints a non-empty accepted interface audit, injects o rank divergence, proves exact rollback of owners/histories/ledgers/Program bytes, retries, then requires a new topology-qualified interface audit, exact all-rank audit/receipt consensus and conservative continuation. -The source validator requires that exact Python entrypoint to remain in the manifest's -`mpi_entrypoints` category with `nproc = 2`; removing or reclassifying it invalidates +The source validator requires the rank-change pytest path to remain in the manifest's +`mpi_orchestrators` category and the RegridOnRestart process to remain in the manifest's +`mpi_entrypoints` category with `nproc = 2`; removing or reclassifying either invalidates `--check-only`. All Python checks run with native and MPI requirements forced on; a missing capability cannot turn this proof into an optional skip. Pytest also emits a mandatory JUnit report with strict xfail @@ -67,6 +70,11 @@ The multi-layout checkpoint proof currently uses two independent `Uniform` layouts. It proves restoration of every layout state and mapping counter, but it is not a substitute for the separate AMR hierarchy/regrid restart proofs. +The qualified-field checkpoint proof uses a real two-level CompositeFAC provider. It records every +level warm start, injects a failure after the strict payload has been applied and authenticated, +requires the restart transaction to recover the fresh runtime's exact field image, then retries and +requires byte-exact restoration of the accepted coarse and fine potentials. + Use: ```bash diff --git a/docs/design/m4-conformance-gate.md b/docs/design/m4-conformance-gate.md new file mode 100644 index 000000000..32b9d13de --- /dev/null +++ b/docs/design/m4-conformance-gate.md @@ -0,0 +1,162 @@ +# M4 native runtime and scientific I/O conformance gate + +The evidence ledger is **SOURCE-CLOSED AND REQUIRED BY CI**. The ledger in +`tests/gates/m4_runtime_io.toml` records exact executable evidence for +ADC-679 through ADC-687. It contains exactly 55 executable checks and +`deferred = []`. Milestone closure is accepted only for a commit whose required MPI job +successfully executes the complete installed gate; source audit alone is not +the acceptance evidence. + +The source audit already authenticates real proofs for: + +- external flux, boundary, tagger, transfer, solver, and writer components + that are compiled, loaded, and executed; +- canonical component manifests, generated registries, exact interface + tables, and platform launch checks; +- source, manifest, and installed-binary tamper refusals, provider absence, + native parameter capacity overflow, and a genuine wrong-ABI DSO refusal; +- an unknown device capability refused by the runtime launch validator before + the candidate kernel is invoked; +- Program-only Uniform/AMR temporal facades, retired native source-stage + headers and schedulers, typed component dispatch, and fail-closed unbound + native interfaces; +- real Uniform and AMR writer transactions, a real multi-layout transfer, + a real two-writer collision with complete ConsumerGraph compensation, and a + positive multi-layout checkpoint/restart; +- one complete RuntimeInstance contract proof across compiled Uniform, AMR, + and multi-layout execution, including RunReport and Program/inspection + parity; +- a prepared native FieldSolver whose invalid first result is refused through + RuntimeInstance with exact accepted-state rollback and a successful retry; +- a two-rank external AMR FieldTopology/FieldSolver solve with distributed L0/L1, + layout-changing rematerialization, exact consensus, rollback/retry, and rank-local divergence + refusal; +- accepted scientific publication, diagnostics including qualified native + projection/reflux term selection, two-rank collective HDF5, + and a two-rank PVD/PVTU/rank-VTU hierarchy reopened by native VTK readers. + +The required Ubuntu 24.04 MPI lane installs Open MPI, parallel HDF5, NumPy, +h5py, pytest, and the native VTK Python readers. It builds the MPI-enabled +extension plus every exact CTest target selected by the ledger, then runs the +complete gate. The global required-check aggregator rejects a skipped, failed, +cancelled, or timed-out MPI lane whenever the M4 runner, ledger, source fence, +or CI workflow changes. + +## Exact output evidence + +There are four serial proofs that are real and remain selected: + +1. the final IMEX/AMR example publishes and reopens its serial scientific + formats through the public PoPS readers; +2. NPZ is independently reopened with NumPy and its arrays and physical clock + are checked; +3. HDF5 is independently reopened with h5py and its dataset and physical clock + are checked; +4. a serial VTU is independently reopened with VTK and its mesh, public field + name, AMR level array, and `TimeValue` are checked. + +An additional HDF5 refusal mutates a dataset with h5py and proves that the +authenticated PoPS reader rejects it. These tests contain no optional import +or skip. That makes their dependencies mandatory wherever the executable gate +runs; the required MPI lane provisions and imports those readers before +launching the matrix. + +The selected two-rank ParaView entrypoint starts from the standard `.pvd` +catalogue, preserves its exact temporal ordering, and requires the native VTK +parallel reader to assemble every referenced `.pvtu`. It also reopens every +rank-local `.vtu` directly with VTK and checks its geometry, public arrays, +component name, and `TimeValue`. VTK imports are unconditional in the required +MPI lane: `POPS_REQUIRE_MPI_TESTS=1` turns an absent reader into a test failure. +The selected `gate_execution` proof authenticates that exact CI route, and the +same required job executes the entrypoint rather than auditing only its source. + +The strict-checkpoint refusal is also provider-backed. A correctly sealed AMR +checkpoint with an inconsistent dynamic accepted-ledger claim passes the real +`RestartV3` file reopen and static preflight, then fails only when the native +AMR provider validates the restored Program image. The test proves that the +active restart transaction restores fields, hierarchy, histories, clocks, +counters, run identity, and consumer cursors, and that the same provider can +successfully retry the unmodified checkpoint. + +The ConsumerGraph refusal is likewise provider-backed. Two separately +qualified native Writer components are compiled and staged in one transaction. +After the first Writer publishes, a pre-existing user-owned target makes the +second Writer fail at the runtime's atomic publication link. The transaction +must compensate the first artifact, preserve the colliding file byte-for-byte, +remove every private staging path, retain the exact accepted numerical state +and consumer cursors, and then publish both Writers on a clean retry. The +selected proof contains no handwritten publisher or prepared-publication fake. + +The RuntimeInstance refusal no longer relies on `FailFirstStep`. A qualified +native FieldTopology/FieldSolver pair is packaged, compiled, resolved, bound, +and prepared through the production component ABI. An authenticated external +fault marker makes its solve report convergence while returning non-finite +values, so the production field validation fails inside the native Program +step. RuntimeInstance must restore +the conservative state, field potential, accepted clock, macro-step, temporal +authority, consumer cursors, reports, and accepted provider evidence exactly. +The immutable prepared component stays installed and may reuse its private +topology cache, but that provisional cache is not published as accepted +materialization. After the external fault is removed, the same prepared +component returns a finite result and the unchanged RuntimeInstance accepts the +retry. The selected test defines no step wrapper and never replaces a native +engine or step target. + +The Uniform refusal proof uses the MPI-enabled module with a one-rank +`MPI_COMM_WORLD`. A separate required entrypoint now launches the AMR adapter with +`mpiexec -n 2`: both levels are distributed across both ranks, a moving refinement region forces +component rematerialization, and every provider report is identical before publication. It then +proves a typed collective FailRun rollback and retry, followed by fail-closed refusal of a +rank-local non-finite candidate whose report differs across ranks. No candidate field, conservative +state, clock, topology, ownership, or provider evidence is published by either failure. + +The positive RuntimeInstance proof is also a compiled route. It builds and +executes one Uniform artifact, one AMR artifact, and one two-layout artifact +with a native conservative Transfer. Every execution returns the exact public +`RuntimeInstance` and `RunReport` types with aligned artifact, bind, execution, +run, clock, step, and transaction evidence. The multi-layout executor +authenticates each installed child Program, creates one domain-separated hash +for the ordered Program set, and projects local block/parameter/cache metadata +into deterministic layout-qualified report rows. The block bijection and +parameter occupancy come from the installed native `program_block_map()` and +`program_param_count()` accessors; the report does not infer an identity map +from missing bindings. Runtime inspection consumes that same complete +`ProgramRuntimeReport`; the selected test proves direct and inspection parity +without a wrapper, fake engine, replaced step target, or monkeypatch. + +## Gate modes + +The source-only architecture CI checks that the ledger is closed: + +```bash +python scripts/run_m4_gate.py --check-only +``` + +`--check-only` verifies the exact nodeids, CTest selectors, manifest ownership, +empty deferred-gap ledger, and source-level anti-skip rules without launching +a compiler, test, MPI process, or native reader. `--audit-only` performs the +same structural audit and reports `AUDITED CLOSED`. + +The installed MPI lane asks the same closed manifest for its exact native build +targets: + +```bash +python scripts/run_m4_gate.py --list-ctest-targets +``` + +It then invokes the complete executable gate, with no audit-only or +Python-only reduction: + +```bash +/usr/bin/python3 scripts/run_m4_gate.py \ + --build-dir build-mpi \ + --mpi-exec mpiexec +``` + +The full command requires the MPI-enabled extension, every selected CTest +target, NumPy, h5py, and VTK. Every selected pytest and CTest execution emits a +JUnit report and fails on any skipped or xfailed proof. A future limitation +must be restored as an explicit `[[deferred]]` row; the validator rejects +malformed or duplicate gaps, wildcard selectors, missing manifest ownership, +optional pytest imports, skip/xfail markers, mock fixtures/imports, and +disabled CTests. diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index aafa83e42..b44e42ef5 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -82,8 +82,12 @@ Supported native routes include: and opposite residual scattering. Endpoints must be co-located on one layout and their explicit default-flux RHS evaluations must be simultaneous and contiguous in one Program point. `MPI_COMM_WORLD` layouts may distribute the two face decompositions independently: native C++ - collectives assemble both traces, require a finite bit-identical shared flux on every rank, - then scatter only into locally owned residual cells. + collectives reconstruct both traces, require a finite bit-identical shared flux on every rank, + then scatter only into locally owned residual cells. `MultiFab`/`DistributionMapping` ownership is + still indexed in the process-world rank space, so interface installation performs one explicit + admission comparison against that storage rank space. It then retains the world-congruent + communicator carried by `ExecutionContext`; trace, failure, flux and registry collectives never + reacquire the process world in the numerical hot path. Each endpoint trace plan retains its projection Handle, authenticated reconstruction-provider identity, operation and provider-derived stencil depth in the collective identity `pops.multiblock.interface-plan.v2`. The current @@ -109,13 +113,29 @@ Supported native routes include: before that level becomes the parent of the next transition; only those exact routes can authorize proper-nesting support across an omitted physical-boundary face. This route does not mirror one endpoint's AMR tags through the interface mapping. - Cross-layout interfaces without an explicit Mapping/Transfer provider, shared implicit JVP, - dynamic active-depth changes, non-finest dynamic replacements at depth greater than two, - historical shared-interface rates, and rank-changing dynamic refined rematerialization remain + One narrow shared implicit JVP route is production-executable on host/serial. Resolve authenticates + exactly two participant blocks connected by one interface on a frozen two-level hierarchy, two + state-only `rhs_jacvec` nodes in one packed matrix-free apply, and both base residuals in the same + top-level atomic RHS round. An unrelated third block may carry the two-component packed Krylov + vector, but cannot participate in another interface. Code generation consumes that exact resolve + evidence before emitting the paired call. The native + `level_rhs_jacvec_pair` primitive perturbs both endpoint states before one shared-flux evaluation, + so its finite difference includes both cross-interface derivatives. A generated Program now + compiles, binds and runs GMRES through more than one paired interface evaluation per level while + preserving the uncommitted packed carrier state. The public capability is therefore `partial`, + not unavailable. + Field-coupled boundaries, dynamic hierarchy mutation, additional participating interfaces and mixed + apply operators fail closed. Bind requires the exact materialized prefix `(L0, L1)` and rejects + MPI, non-host devices and non-host memory before native interface installation. + Cross-layout interfaces without an explicit Mapping/Transfer provider, dynamic active-depth + changes, non-finest dynamic replacements at depth greater than two, and historical + shared-interface rates remain unavailable. Frozen and depth-preserving dynamic + refined interfaces use the same exact `MPI_COMM_WORLD` trace and replacement-registry consensus + as the flat route. Dynamic rematerialization stages a detached collective candidate; a + rank-local failure restores the accepted layout, topology epoch, evaluator audit count and + executable registry before retry. Every rank evaluates the canonical shared flux and scatters + only to its locally owned endpoint cells. Rank-changing dynamic refined rematerialization remains unavailable. - Frozen refined interface publication uses the same exact `MPI_COMM_WORLD` trace consensus as the - flat route; every rank evaluates the canonical shared flux and scatters only to its locally owned - endpoint cells. - AMR through the native production route with hierarchy depth controlled by resolved resource policy. Transitions are exactly 2D, isotropic `ratio == (2, 2)`, share one isotropic buffer and one lookahead across the hierarchy, and currently select the exact native policy routes @@ -124,24 +144,155 @@ Supported native routes include: interpolation are cell-centered on the supplied route. Derived fields use `elliptic_solve` and caches use `patch_topology`; unsupported provider contracts fail before artifact creation. - Finite-volume spatial discretisation on the 2D core. -- Native Riemann routes: Rusanov, HLL, HLLC, Roe, subject to model capability requirements. +- One prepared, model-aware 2D transport-boundary plan shared by Uniform and AMR native/compiled + routes. The capability matrix marks this route `partial` and names its exact built-ins: + periodicity, extrapolation, constant or `RuntimeParam` fixed state, conservative device-side + analytic fixed state over typed `(x,y,t,params)`, fixed-state primitive inflow converted once + through the exact compiled block-model `to_conservative` provider, typed-role slip wall, and a + typed `NoFlux` face. `NoFlux` uses the plan's prepared extrapolation for reconstruction ghosts, + then zeroes the already evaluated face flux before divergence and AMR reflux; it is not a masked, + polar, or embedded-boundary side channel. + Analytic programs are immutable postfix tables evaluated in native device kernels at the exact + `BoundaryEvaluationPoint`; no Python callback or hot-loop allocation is retained. The analytic + finite-value contract is strictly non-mutating: one device preflight and one communicator + reduction complete before any same-level, periodic, MPI or physical halo write. The commit kernel + then evaluates the program again; this deliberate two-pass route avoids a per-cell scratch field + but retains one blocking collective per analytic boundary fill. + The analytic route remains `partial`: primitive per-point conversion and discrete state/field/input reads are + rejected, as is an analytic ghost depth larger than the normal domain extent. Analytic faces with + axis-permuted periodic coordinates also fail closed until a prepared coordinate map exists. The + conversion route is explicitly `partial`: conservative-to-primitive recovery and arbitrary + representation components remain unavailable, and conversion does not invent a boundary + admissibility projection. Characteristic no-inflow is now an explicit narrow `partial` route: + `Inflow(state=U, value=U_ref, + characteristic=pops.boundary.model_characteristic_no_inflow(U))` requires a conservative + constant/`RuntimeParam` fixed reference and the exact generated `m.roe_from_jacobian()` provider. + Its Kokkos kernel evaluates + the complete model flux Jacobian (1..16 components), orients it with the physical-face normal, + applies the strictly incoming spectral projector, and leaves the scale-relative sonic subspace + neutral. A collective real-spectrum preflight precedes publication; any failure restores the + complete ghost transaction and never selects scalar, Rusanov, or Euler-specific logic. This + qualification is currently 2D Cartesian host serial; primitive/analytic reference states, + state/field-dependent auxiliary eigenstructure, sonic-error policy, MPI/GPU qualification, 3D, + polar and embedded/cut-cell geometry remain unavailable. The native selector now authenticates + these limits with one `dimension x geometry x operation` spatial-provider matrix: a 2D + staircase/cut-cell residual cannot be mistaken for a metric-aware characteristic or boundary + linearization provider, and the polar residual cannot be selected as Cartesian. Post-Riemann + transformation is instead an explicit `partial` route: a typed + `BoundaryFlux` component receives the already evaluated outward-normal flux and executes between + the Riemann solve and divergence/reflux through the same prepared Uniform/AMR plan. The runtime + converts lower and upper faces to outward orientation before the call and converts the result + back to canonical positive-axis face storage afterwards. This route is currently 2D Cartesian + host-batch execution; it has no device-native or embedded/cut-cell metric ABI, and the ordinary + Uniform route materializes face fields when selected. + These requests fail during resolution or lowering; none silently degrades to component-wise + ghost filling. A native rank-1/2/4 regrid fixture removes and recreates the fine hierarchy, then + proves that uncovered internal fine ghosts retain the conservative coarse-fine transfer and are + never treated as physical faces by the rematerialized prepared boundary session. The explicit + public route is + `Inflow(state=U, value=primitive_values, representation=Primitive(), + converter=pops.boundary.model_primitive_to_conservative(U))`; the converter is derived from the + authenticated block state and cannot name an unrelated callback or kernel. + `primitive_values` follows the model's declared primitive-variable order. +- Native Riemann routes: Rusanov, HLL, HLLC, Roe, subject only to exact model capability + requirements. HLLC and Roe keep one native route each; the compiled model separately authenticates + the model-side provider (`fluid_roles_v1`, `direct_action_v1`, or `flux_jacobian_v1`) and the + typed Roe entropy policy (`riemann.Harten(delta)`, `riemann.NoEntropyFix()`, or provider-owned). + Missing, unknown, or flag/provider-mismatched evidence fails before native installation, and + compiled inspection reports every distinct provider/options record instead of collapsing it to a + Boolean. Cartesian, AMR and annular-polar dispatch use the same provider identity; the + native isothermal provider supplies HLLC/Roe on the polar route while scalar ExB refuses them. + `riemann:typed_failure_outcome` is deliberately `partial`: every built-in returns the common + device-copyable `FluxEvaluation` with typed status, stability bound, reason code, requested/used/ + last solver identity and attempt metadata. A single-solver route remains explicit, while a + typed public `riemann.Recovery(primary=Roe(), fallbacks=(HLL(), Rusanov()))` descriptor lowers to + the sole statically instantiated C++ `PreparedRiemannRecoveryPolicy` in the ordinary Cartesian Uniform/AMR face hot loop. Other + orders, duplicate or configured candidates, external descriptors, and untyped values are refused + before compile; annular polar geometry is explicitly unavailable. Only a typed candidate rejection + advances; retry and fatal outcomes remain terminal. The route remains `partial`: block/team and MPI + fallback counters, GPU qualification, restart publication metadata, backend matrices and + performance budgets are not yet delivered. +- Prepared variable recovery is explicitly `partial`. One block-prepared closed-form method returns + a device-copyable `RecoveryOutcome`/`RecoveryReport`. Type erasure retains both the selected and + last-attempted method kinds, so a successful fallback or a refusal cannot be reported as an opaque + chain index. System conservative-to-primitive and transactional analytic initial-state + materialization plus Cartesian, polar, masked, and embedded-boundary face reconstruction consume + publication permission before copying a candidate or evaluating a flux. Primitive-to-conservative + setup conversion similarly publishes only a finite candidate accepted by that prepared inverse + authority. Accepted AMR regrid prolongation and restriction candidates also pass that + block-prepared inverse authority collectively before replacing live hierarchy state. AMR + bootstrap commits, rematerialized history slots, and physical boundary traces use the same + publication gate and restore their complete transaction on refusal. Generated Program terminal + commits also validate every Uniform or AMR live-state candidate before the first multi-block copy, + including endpoints assembled from model-local and coupled sources. This route adds no implicit + repair or fallback. The host Uniform `get_primitive_state` materializer now owns one per-block, + per-local-cell warm-start slot qualified by exact conservative input plus topology and accepted + state generations. It stages each slot through `RecoveryPublicationTransaction`, publishes the + primitive array only after the complete batch succeeds, and explicitly invalidates every slot when + a batch is refused. The separate `recovery:complete_consumer_cutover` capability remains + `unavailable`: face-reconstruction kernels and AMR do not yet own persistent recovery warm starts, + AMR regrid migration and checkpoint/restart do not persist such slots, and manual in-place Program + writes, backend parity, and performance evidence do not yet share that authority. - Native reconstruction routes: first-order, MUSCL, WENO5/WENO5-Z. - Elliptic GeometricMG on Uniform/AMR and FFT on uniform periodic constant-coefficient grids. - Matrix-free Krylov descriptors: CG, BiCGStab, GMRES, Richardson. - ProgramContext install on System, and AMR program install when compiled for `target="amr_system"`. +- A native C++ `amr:cell_local_temporal_transport` route partially proves scientific consumption of + the prepared cell-local executor. `Program.cell_local_time(...)` and generated + `AmrProgramContext` code wire the exact bounded route. On host/serial, one 2D block, one level, one + rank-owned box and one common rung, it calls the exact compiled AMR transport closure, advances the + real conservative state with forward Euler, and publishes the four time-integrated face fluxes per + cell only at the synchronization barrier. Its contract authenticates the model-owned spatial + parameters and selected limiter/Riemann route. Same-topology restart restores state and integer + clocks while invalidating the non-persisted last-interval diagnostic ledger until the next accepted + step. It currently accepts only built-in periodic/Foextrap transport boundaries; missing + identities, prepared physical-boundary plans, MPI/GPU execution, topology drift, multiple boxes or + levels and mixed rungs fail closed. It does not claim heterogeneous local times, coarse/fine + conservation, source integration, regrid/rank-change rematerialization, diagnostic-ledger + persistence or performance qualification. +- Generated local implicit-source Programs on synchronous two-level 2D AMR. `pops.lib.time.IMEX` + lowers its local residual to the sole prepared `LocalNewton` service on every active level and + consumes the returned `SolveOutcome`; it does not invoke a spatial-runtime time integrator. The + executable route covers dynamic regridding, covered and uncovered coarse cells, active fine cells, + finite no-root and non-finite failures, exact all-level/clock/topology rollback, and a rank-local + failure reduced consistently over two MPI ranks. The capability remains `partial`: subcycled local + solves, GPU qualification, field/global implicit coupling and performance evidence are not inferred + from this pointwise synchronous route and require their own prepared execution proof. - Prepared state-boundary residual/JVP pairs on Program matrix-free solves. The exact base `BoundaryEvaluationPoint` is transported into the apply closure, the core RHS is finite-differenced, and the authenticated state-only boundary JVP is added once with persistent - conditional scratch. A field-dependent boundary closure under `field_coupled=True` is refused - until a qualified tangent-field solve exists. Core field-coupled `rhs_jacvec` currently has an - exact provider route only on AMR level 0. -- Level-local AMR named-field solves materialize linear dynamic-boundary state dependencies once per - active level. The context carries that exact level and the matching state layout/distribution; - coarse storage is never silently reused by a fine solver. Composite-FAC dynamic boundaries, - iterate-dependent multilevel boundaries, and field-to-field dependencies remain unavailable. + conditional scratch. Field-coupled `rhs_jacvec` re-solves its exact prepared provider from the + perturbed state on level zero and every refined level; if a transport boundary reads that solved + field, its complete residual is finite-differenced before the perturbed provider is restored. + Ordinary single-state field solves use that same owner-qualified provider ABI on Uniform and AMR: + the generated call carries the exact `BoundaryEvaluationPoint`, provider slot, active level and + stage state, with no AMR coarse-report reuse overload. + Dynamic physical field boundaries may read level-qualified conservative states, already-solved + fields and the exact stage/local time under both `LevelByLevelSolve` and + `CompositeHierarchySolve`; the composite FAC provider requires one exact dependency carrier per + materialized level before entering a solve. The generated resolve/source contract covers the + field-dependent transport-boundary JVP route. A native L0/L1 level-local oracle now places that + dependency on a physical face of a fully refined domain and checks the complete core-plus-boundary + `rhs_jacvec(field_coupled=True)` against an independent centered finite difference; it also proves + physical-face locality, provider sensitivity and restoration after every perturbation. The core + field-coupled JVP has a two-rank level-local oracle over genuinely distributed L0/L1 state and + provider storage. Its composite-policy MPI oracle exercises the ownership topology supported by + the builtin FAC provider: one complete replicated L0 copy per rank and a genuinely distributed + L1. Both check centered-difference parity, frozen-provider sensitivity and collective restoration + of the complete provider hierarchy plus the active-level residual carrier. A second two-rank L0/L1 + oracle drives the level-local solved field through an x-low physical-face residual split across + both ranks, proving that its JVP contribution is non-trivial, face-local, provider-sensitive and + collectively restored. + Partially refined FAC patches carrying a dynamic physical boundary must remain strictly interior; + a patch touching a non-periodic domain face fails closed. A selected solve with a field dependency + also fails closed until its complete dependency closure can share one transaction. Simultaneous + multi-block stage solves use one exact hierarchy-qualified multi-state request carrying the same + `BoundaryEvaluationPoint`, provider slot and active level; every provisional conservative state is + restored before the provider candidate can be consumed. - Runtime scientific output v1: typed `SERIAL`, `ROOT`, `COLLECTIVE` and `PER_RANK` publication on the exact modes advertised by NPZ, ParaView and HDF5, with native Uniform/AMR piece ownership. -- Runtime accepted-state checkpoint v5 for Uniform and v6 for AMR. The single-file MPI route captures +- Runtime accepted-state checkpoint v5 for Uniform and v7 for AMR. The single-file MPI route captures collectively only after every rank agrees on the exact gather-plan identity, agrees again on the sealed payload identity, and publishes once on rank 0 with atomic no-clobber semantics. The provider authority is resolved into the compiled plan, including the builtin v5 manual route. Restart reads @@ -157,10 +308,12 @@ Supported native routes include: artifact creation until their adapter contract owns the same persistent-state route. MPI capture validates the rank-independent accepted-state image on every producer before sealing or publication; disagreement fails collectively and cannot leave a partial checkpoint. +- The prepared limiter registry exposes native `Minmod`, `VanLeer`, `MC`, and `Superbee` MUSCL + policies. Each is a stateless `POPS_HD` compile-time provider with formal order 2 and exactly two + ghost layers; Uniform, AMR, MPI and supported device targets consume the same route identity. Explicit unsupported rows include: -- `limiter:mc` and `limiter:superbee`: catalogued descriptors with no native C++ symbol. - `elliptic:fft_amr`: FFT requires a single uniform periodic mesh; AMR uses GeometricMG. - `checkpoint:parallel_hdf5`: parallel HDF5 is a scientific-output route, not a restartable checkpoint encoding; `RuntimeInstance.checkpoint()` and the typed `Checkpoint` consumer use uniform v5 or AMR @@ -182,10 +335,12 @@ Explicit unsupported rows include: a global transform receipt derives a distinct run identity. Persistent tagging state is restored and rolled back with the accepted image, then advanced exactly once by a successful transform. Serial and exact-`MPI_COMM_WORLD` shared-interface groups are rematerialized at unchanged - cardinality in the same transaction and execute conservatively after rollback or commit. Uniform, - multi-layout, elliptic-field, rank-changing dynamic shared-interface, and - bootstrap-staggered/cache cases remain explicit refusals; `bit_identical=True` is incompatible - with the policy. Exact phase-local dense-history consensus fingerprints are gathered only on this + cardinality in the same transaction and execute conservatively after rollback or commit. The MPI + acceptance proof covers one refined transition, a rank-local post-transform fault, exact + all-rank rollback, retry with one receipt identity and post-restart interface execution. Uniform, + multi-layout, elliptic-field, active-depth-change, unsupported non-finest replacements at depth + greater than two, rank-changing dynamic shared-interface, and bootstrap-staggered/cache cases + remain explicit refusals; `bit_identical=True` is incompatible with the policy. Exact phase-local dense-history consensus fingerprints are gathered only on this cold restart path; they prove all-rank agreement per hierarchy rather than bitwise continuity across interpolation. Accepted solution components retain the separate native composite conservation invariant. Fingerprint memory and collective-communication cost scales with all @@ -196,20 +351,29 @@ Explicit unsupported rows include: GPU Kokkos execution space before constructing `System`/`AmrSystem`; build-time availability is not launch authorization. The native providers do accept an explicit, authenticated `MPI_COMM_WORLD` context; custom communicators remain unavailable. -- `amr:field_coupled_rhs_jacvec`: AMR level greater than zero is explicitly unavailable because the - provider ABI does not transport a level-qualified tangent field. The reported error identifies - the level-0 field-coupled route as the available route; a multi-level request must fail rather - than silently reuse the coarse provider. The shared execution service additionally requires the - JVP evaluation point to match the active Program resource level before either the perturbed solve - or frozen-state restoration can dispatch. Closing the remaining gap requires a typed per-level - tangent-field publication and transactional primal restoration, plus CompositeFAC coupling where - selected; the existing primal `fields` table cannot represent that derivative. - `amr:composite_dynamic_boundary`: a fully refined hierarchy uses the exact finest-level uniform field solver and receives that level's logical time, state dependencies, distributions, and nonlinear/JVP context. A partially refined FAC hierarchy refuses the same request because its interface correction does not yet own the required homogeneous/JVP boundary operator per level; it never reuses the inhomogeneous primal closure as a correction boundary. - +- `amr:external_field_solver_v2`: an authenticated `InstallPlan` now installs the exact + `FieldTopology@2` + `FieldSolver@2` pair as one `AmrFieldSolverProvider`. One prepared request + carries every hierarchy level, qualified by `metadata.level`, and binary material masks exclude + fine-covered coarse cells. The serial integration oracle requires both a masked coarse cell and an + active fine cell, then advances through repeated field solves and a layout-changing regrid. The + provider performs one collective solve, validates every active candidate value before + `SolveOutcome` publication, restricts solved fine values into covered coarse cells, materializes + same-level/physical/coarse-fine potential halos before centered gradients, and + destroys/rematerializes both component states when regridding invalidates the prepared solver. + The current transfer proof is ratio-2. Host serial is available. The executable + `test_external_amr_field_solver_mpi.py` oracle proves `mpiexec -n 2` with local patches on both + ranks at L0 and L1, a layout-changing regrid/rematerialization, exact provider evidence, typed + collective rollback/retry, and refusal of a rank-local non-finite candidate before publication. + MPI is available only when both component manifests declare the host/MPI variant, the installed execution + authority is exact `MPI_COMM_WORLD`/`MPI_DOUBLE`, and the coarse level is distributed (the v2 ABI + has no replicated-coarse ownership marker); rank counts above two remain unqualified. + GPU/device memory, embedded or cut-cell topology, + dynamic/dependent boundaries, reaction coefficients and nonlinear/JVP solves remain fail-closed. ADC-601 also records audited native subsystem limitations as `partial` rows. These rows are not hard failures, but they make compatibility and performance constraints visible to reports and future validators: @@ -219,7 +383,11 @@ future validators: - `elliptic:mg_fac_defaults`: MG/FAC defaults and debug diagnostics still need a shared `SolverDefaults`/logger route. - `mesh:2d_storage_arithmetic`: the native mesh/storage/arithmetic core is `Box2D`/`Fab2D` - 2D-only, and `validate_dimension()` rejects `Dim != 2` requests. + 2D-only, and `validate_dimension()` rejects `Dim != 2` requests. Separately, the prepared local + periodic Cartesian finite-volume provider executes compile-time `Dim=1..3` contiguous patches + through the same metric, reconstruction, typed Riemann and conservative-divergence pipeline. + Its 1D/3D qualification does not claim 1D/3D `MultiFab`, AMR hierarchy, physical boundaries or + runtime binding. - `amr:refinement_ratio`: native AMR hierarchy, patch ranges, spatial transfers and reflux geometry are `ratio=2` only, and `validate_amr_refinement_ratio()` rejects other spatial ratios. Temporal parent/child ratios are explicit `ProgramGraph` data; `AmrRuntime` never infers or executes @@ -227,6 +395,16 @@ future validators: - `amr:transition_envelope`: transitions are 2D/isotropic and buffer/lookahead are hierarchy-global. - `amr:hierarchy_policy_routes`: only the reported shared hierarchy, clustering, patch-generation, and load-balance routes are installed. +- `amr:accepted_owner_migration`: a prepared `RebalanceDecision` can redistribute one active fine + level at a clean accepted Program boundary. The consumer revalidates the exact decision against + its prepared authority, source level, live topology epoch, materialization generation, boxes and + current owners, requires all-rank byte consensus, migrates every block/aux/history carrier, + rematerializes topology-bound providers, redistributes compact lagged-flux authority through the + checkpoint rematerializer, invalidates audit reports qualified by the replaced topology epoch and + republishes accepted Program state atomically. Stale, divergent, malformed and non-beneficial + decisions do not mutate state; failures restore the complete accepted runtime/Program image. + Level-zero migration, custom communicators, materialized staggered bootstrap fields and cell-local + stage/flux-ledger rematerialization remain unavailable. - `amr:transfer_contracts`: centering, representation, storage, operation, order and ghost depth must match an exact native transfer/materialization provider contract. - `parallel:mpi_world_communicator`: the native `RuntimeInstance` providers consume the exact @@ -237,7 +415,11 @@ future validators: has ended; an embedding application retains its lifecycle. Python carries only the opaque native resource identity. - `parallel:custom_communicator`: caller-provided custom MPI communicators remain representable but - unavailable because the native engines expose no communicator-injection ABI. + unavailable at the public bind surface because field storage does not yet carry a + communicator-relative rank space. The native interface scheduler and layout-transfer consumers + can execute on an authenticated `MPI_IDENT`/`MPI_CONGRUENT` lane, but admission must still compare + that lane with the process-world-indexed field ownership. Subgroups and reordered communicators + are refused before kernel launch. - `precision:single_or_mixed`: `pops::Real` is `double`; single or mixed precision is unavailable. - `runtime:kokkos_lifecycle`: `runtime_environment_report()` exposes whether PoPS will lazily initialize Kokkos, has initialized it, or is attached to an externally initialized runtime. diff --git a/docs/design/platform-manifest-contract.md b/docs/design/platform-manifest-contract.md index a2cba9910..adcedbbde 100644 --- a/docs/design/platform-manifest-contract.md +++ b/docs/design/platform-manifest-contract.md @@ -50,6 +50,48 @@ loaded, so plugins share the already-owned Kokkos/MPI runtimes. The external com records `MPI_COMM_WORLD` plus the MPI ABI proof and is checked against the explicit execution context at installation. +## Remaining native `System` communicator injection boundary + +The uniform native provider validates an `ExecutionContext` before launch, but it currently +constructs `System(SystemConfig)` before passing that authority into C++. `SystemDomain` therefore +builds its `DistributionMapping` from process-global rank queries, while `SystemFieldSolver`, +`ProgramContext`, `SystemProgramDriver`, field publication, and global field gathers still use +argument-free world collectives. This is an incomplete authority flow, not a missing collective +primitive: exact contract consensus already accepts a `CommunicatorView`, exact `SolveReport` +consensus accepts an `ExecutionLane`, and `SolveOutcome` already exposes `collective_lane`. + +Creating another private `MPI_COMM_WORLD` lane inside one of those consumers would not close the +contract. It would still capture process-global state, could order collectives differently from the +field owner, and would not prove that the lane rank space matches the process-world-indexed +`DistributionMapping`. + +The minimum native ABI cut is: + +1. Decode and validate the owned `PreparedExecutionContextV1` before constructing `System`; the + Python runtime provider must pass it to the native constructor/factory instead of attaching only + a Python `_execution_context` attribute after construction. +2. Store that authority for the complete `System::Impl` lifetime. Construct `SystemDomain` from its + explicit communicator rank/size, admit only `MPI_IDENT` or `MPI_CONGRUENT` with the current + process-world field rank space, and reject a subgroup or reordered communicator before allocating + fields. +3. Materialize one deterministically named, owning field-execution lane from that authenticated + communicator during construction. Pass it to `SystemFieldSolver`, its nested elliptic provider + registry, `ProgramContext`, `SystemProgramDriver`, field publication, and global gathers; none of + those consumers may create or rediscover a world lane in a solve/publication hot path. +4. Replace every argument-free reduction, rank query, ordered-byte consensus, and `SolveReport` + consensus in that graph with its lane-scoped overload. Return + `SolveOutcome::collective_lane` using the same lifetime-stable lane so accept/reject consensus and + publication hooks cannot escape onto a different communicator. +5. Keep the existing custom-communicator refusal until field storage owns a communicator-relative + rank space. A low-level test-only constructor may select an explicit serial/world authority, but + the final Python runtime path must not retain `System(SystemConfig)` as an implicit-world route. + +The closure proof must include an `MPI_Comm_dup` world-congruent launch, rank-local construction and +solve failures, divergent `SolveReport`/consumption actions, and refusal of wrong-rank-space +communicators before mutation. A source architecture fence must additionally show that the complete +uniform field-solve/publication graph contains no argument-free collective, `ExecutionLane::world`, +`world_communicator_view`, or raw `MPI_COMM_WORLD` capture. + `compile_native` has an explicit PE/COFF command and `_pops.lib` contract. By contrast, `compile_problem` and `compile_component` are currently fail-closed on Windows because their final authenticated PE/COFF symbol-inspection/publication pipeline does not yet exist. They never run a diff --git a/docs/design/pybind-binding-audit.md b/docs/design/pybind-binding-audit.md index d9ad932c0..95ad9077f 100644 --- a/docs/design/pybind-binding-audit.md +++ b/docs/design/pybind-binding-audit.md @@ -1,7 +1,9 @@ # Pybind and native component boundary This note defines the final binding boundary. Python authoring never selects a native algorithm with -a string and never calls `System.add_block`. A typed descriptor contributes a versioned +a string, and the private Python runtime wrappers do not expose `System.add_block` or +`AmrSystem.add_block`; `pops.bind` uses their single type-dispatched `add_equation` seam. A typed +descriptor contributes a versioned `ComponentManifest`; resolution authenticates its small interfaces and produces an immutable route identity. Pybind materializes the already-resolved plan and does not reinterpret scientific intent. diff --git a/docs/design/runtime_instance_planning_contract.md b/docs/design/runtime_instance_planning_contract.md index 34aadbf35..3cf2e9e97 100644 --- a/docs/design/runtime_instance_planning_contract.md +++ b/docs/design/runtime_instance_planning_contract.md @@ -77,10 +77,18 @@ component reduction; adaptive integrals use the native volume-weighted composite exact selected levels. Installation and execution must authenticate the bundle's plan, bind, component and layout identities without rebuilding or weakening them. Every native provider authenticates the exact bundle before reading backend state or constructing -an execution engine; a missing or mismatched bundle therefore fails before execution. The complete -bundle is retained in the array-free `RuntimeInstance.inspect()` report under `instance.runtime_plan` -so derived halos, transfers, collectives, fences, buffers and determinism assumptions remain -reviewable rather than becoming hidden installation state. +an execution engine, then checks the plan's determinism guarantee against current native +rank/device/backend facts and its authenticated reduction order before native preflight; a missing +bundle, mismatched authority or changed execution fact therefore fails before execution. The +complete bundle is retained in the array-free `RuntimeInstance.inspect()` report under +`instance.runtime_plan` so derived halos, transfers, collectives, fences, buffers and determinism +assumptions remain reviewable rather than becoming hidden installation state. +Single-layout providers additionally require the exact ordered block/layout call projection, +layout-qualified halos, and the absence of unconsumed Transfer or mapping-provider routes before +constructing their sole native engine. +The multi-layout Uniform provider likewise authenticates ordered block/layout calls and the exact +mapping-provider set backing its materialized Transfers before constructing child engines. It +refuses non-empty runtime halo plans until an explicit per-layout halo scheduler exists. For an accepted step, successful native finalization is an irreversible `native_finalized` boundary. The instance commits the engine state, accepted cursor set and consumer receipts across diff --git a/docs/design/temporal-execution-contract.md b/docs/design/temporal-execution-contract.md index 6e25499c8..1074c6bb6 100644 --- a/docs/design/temporal-execution-contract.md +++ b/docs/design/temporal-execution-contract.md @@ -61,6 +61,82 @@ program schedule with the installed program before native state mutation and req checkpointed step strategy for the next attempt. Schema v1 and other historical payloads require an offline migration; runtime restart contains no compatibility branch. +## Cell-local temporal-partition restart foundation + +The AMR Program accepted image now has an explicit temporal-partition section. Its cell-local form +stores a prepared-provider identity, hierarchy topology epoch, integer synchronization tick and tick +denominator, plus canonically ordered `(level, cell, rung, accepted_tick)` records. Floating-point +cell clocks and rank-local addresses are not checkpoint authority. Every persisted cell must be at +the same rung-aligned synchronization tick; a provisional attempt cannot be serialized. + +`BatchedCellTemporalPartition` supplies the execution-provider-independent transaction semantics: +one attempt target, ordered same-rung batches, synchronization barriers, commit, rollback, strict +restore and an accepted-state manifest. The AMR restart path decodes and validates this state before +replacing accepted bytes. A malformed provider identity or topology/level mismatch leaves the +previous image untouched. +The public Program report consumes the same native image and exposes provider identity, accepted +tick, denominator, cell count and per-rung counts. + +For this bounded slice, a cell-local checkpoint restarts only with the recorded MPI cardinality and +`RestoreRecordedHierarchy`. Rank-change and `RegridOnRestart` are rejected during Python preflight, +before the native restart transaction, because rematerializing canonical cell ids onto a new owner +or topology is not implemented yet. + +`PreparedBatchedCellTemporalExecutor` is the first executable ADC-756 rung slice. Preparation +authenticates the exact provider identity recorded by the accepted image, groups canonical records +into compact device-accessible arrays and reserves the host clock transaction. During an attempt it +orders events by exact integer end tick and rung, then launches one Kokkos batch for every active +rung event rather than one task or kernel per cell. The provider sees the exact rational begin/end +time of each cell. The prepared hot loop does not allocate PoPS storage. + +The executor accepts only a typed provider exposing one combined device operation: +`evaluate_local_stage_and_record_space_time_flux`. There are no independent Boolean declarations +for a local stage or ledger. A provider that needs a coherent neighbouring-cell image additionally +owns the optional `begin_rung_batch`/`complete_rung_batch` lifecycle; these hooks can materialize and +rotate attempt-local storage but cannot publish it. An accepted result therefore means that the +provider evaluated the stage and wrote its attempt-local integrated-flux record before that cell +clock advanced. All provider records and cell clocks commit together only at the synchronization +barrier. A malformed outcome, rejection, provider-preparation refusal or kernel failure rolls back +the complete attempt and leaves the accepted checkpoint unchanged. + +`PreparedSameLevelTransportEulerStageFluxProvider` is the first scientific consumer of this +executor. It reuses the selected AMR block's real compiled transport closure to materialize +`-div(F)` and the exact x/y face-flux fields, advances the conservative candidate with forward +Euler, and accumulates four time-integrated face records per valid cell. Both state and ledger stay +in fixed attempt-local storage; the barrier commit is their sole accepted publication. The exact +provider contract includes the block state identity, model-owned transport identity and parameters, +limiter/Riemann route, spatial options, hierarchy/materialization identity, clock, tick scale, +layout and distribution. A type-erased spatial closure without that builder-owned contract is +refused rather than authenticated from a caller label. + +This first scientific route is deliberately bounded to a host/serial 2D hierarchy with exactly one +block, one level, one rank-owned box, one common cell rung, frozen attempt auxiliary fields, +built-in periodic/Foextrap transport boundaries and transport-only forward Euler. A prepared +physical-boundary plan is refused until its exact executable contract can join the provider +identity. The route also has no MPI, GPU, heterogeneous-rung interpolation, coarse/fine ledger, +source-stage integration, regrid/rank-change rematerialization, diagnostic-ledger checkpoint +persistence or performance proof. + +`Program.cell_local_time(tick_denominator=..., rung=...)` now selects this bounded route explicitly. +Generated AMR code accepts only the exact single-state Forward-Euler transport graph, prepares the +provider at an accepted boundary and installs `AmrProgramContext::advance_same_level_cell_temporal`. +The context routes checkpoint, attempt, commit and rollback through that sole executor; the ordinary +hierarchy-global driver still refuses a cell-local image and never substitutes a global `dt`. +Same-topology restart restores the numerical image and integer clocks. Because the accepted-state +schema does not persist the last interval's diagnostic face ledger, restart invalidates that +publication until the next accepted interval instead of exposing stale fluxes. + +The remaining production extensions are explicit dependencies, not capabilities inferred from this +slice: canonical rank/box ownership and halo-stage snapshots for MPI; distributed face-ledger +reconciliation and collective failure draining; device-resident provider storage and publication for +GPU; temporal neighbour interpolation and subface synchronization for heterogeneous rungs; +coarse/fine space-time ledgers, reflux and local refinement ratios for multilevel AMR; exact provider +rematerialization after regrid or rank migration; prepared source, field and physical-boundary stage +contracts; an accepted-state schema extension if the last diagnostic ledger must survive restart; +and backend/allocation/performance qualification. ADC-707/ADC-708 continue to own the prepared +patch/task graph. No end-to-end heterogeneous or multilevel locally subcycled AMR conservation claim +is made by this bounded route. + Offline envelope inspection authenticates only the integrity of a canonical checkpoint; it is not a migration. The frozen release-v2 Uniform checkpoint predates the envelope and omits lifecycle identities, temporal state, consumer cursors, and field-provider state. The explicit diff --git a/docs/docmap.toml b/docs/docmap.toml index 812be7ecc..efde8adf4 100644 --- a/docs/docmap.toml +++ b/docs/docmap.toml @@ -77,13 +77,16 @@ testable = false [docs."docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md"] owner = "Romain" mode = "warning" -reviewed = "53db73d068939b24ffdc9dbf2369df86a630374f" +reviewed = "a82c3aef84fb14e69c836383683584a5085abf0c" depends_on = [ "python/pops/__init__.py", "python/pops/_api.py", "python/pops/physics/board.py", "python/pops/problem/problem.py", "python/pops/time/_program/api.py", + "scripts/prove_public_api_parity.py", + "scripts/release_preflight.py", + ".github/workflows/release.yml", "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py", "examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py", "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py", @@ -91,6 +94,7 @@ depends_on = [ ] tested_by = [ "tests/python/architecture/test_final_public_api.py", + "tests/python/architecture/test_public_api_parity_proof.py", "tests/python/architecture/test_release_contract.py", ] testable = true diff --git a/docs/tuto/scalar_advection/README.md b/docs/tuto/scalar_advection/README.md index 19951b227..863d6dd3b 100644 --- a/docs/tuto/scalar_advection/README.md +++ b/docs/tuto/scalar_advection/README.md @@ -187,14 +187,16 @@ Les substitutions suivantes utilisent toutes des briques natives : reconstruction.FirstOrder() reconstruction.MUSCL(limiters.Minmod()) reconstruction.MUSCL(limiters.VanLeer()) +reconstruction.MUSCL(limiters.MC()) +reconstruction.MUSCL(limiters.Superbee()) reconstruction.WENO5() # implementation native WENO5-Z ``` -Le document source cite aussi les limiteurs MC et Superbee. Leurs fonctions usuelles sont +Les limiteurs MC et Superbee utilisent respectivement $\phi_{MC}(r)=\max(0,\min(2r,(1+r)/2,2))$ et -$\phi_{SB}(r)=\max(0,\min(2r,1),\min(r,2))$. PoPS 1.0.0 ne fournit pas encore de descriptor -natif pour ces deux limiteurs. Ils peuvent etre compares sur le papier, mais ne sont pas -selectionnables dans ce tutoriel. +$\phi_{SB}(r)=\max(0,\min(2r,1),\min(r,2))$. Ils passent par le meme registre prepare que Minmod +et VanLeer, demandent exactement deux couches de cellules fantomes et sont selectionnables sans +branche specifique Uniform, AMR, MPI ou backend. ## Tutoriel 1 : briques preimplementees @@ -801,4 +803,14 @@ ici utilise SSPRK2. [L'exemple final d'advection scalaire](../../../examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py) compose toutes ces briques avec trois niveaux, diagnostics, controles d'identite et preuves de -restart exhaustives. +restart exhaustives. Son gate natif rouvre le dernier VTU accepte, ne conserve que les cellules +feuilles AMR, calcule les normes ponderees par le volume face a la solution exacte transportee et +impose une erreur L2 relative inferieure ou egale a `0.10`. Il authentifie aussi les contributions +de flux de chaque niveau et l'ordre `reflux`, puis `average_down`, pour chaque relation parent/enfant. +Une hierarchie raffinee preexistante ne suffit pas : le gate lit les compteurs publics +`regrid_count` et `topology_epoch`, exige un remplacement topologique termine avant le checkpoint et +pendant chaque continuation, puis verifie leur restauration exacte. Il refuse aussi la publication si +`simulation.amr.explain_checkpoint()` signale une violation du contrat de restart strict. +L'etude `15_openmp_convergence.py` reste la preuve separee de raffinement conjoint MUSCL/SSPRK2 : +elle exige la decroissance de L1, L2 et Linf sur les grilles 32², 64², 128² et 256² et publie les +ordres observes plutot que de supposer un ordre effectif constant pres des extrema limites. diff --git a/examples/final/EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py b/examples/final/EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py index caaec9bf2..0a952c87e 100644 --- a/examples/final/EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py +++ b/examples/final/EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py @@ -31,7 +31,7 @@ from pops.lib.models.moments import HyQMOM15 from pops.math import laplacian from pops.mesh import CartesianGrid, PeriodicAxes -from pops.moments import RealizabilityProjection +from pops.moments import RealizabilityProjection, closure from pops.numerics import DiscretizationPlan, reconstruction, riemann, variables from pops.numerics.reconstruction import limiters from pops.numerics.spatial import FiniteVolume @@ -42,6 +42,7 @@ from pops.solvers import DenseLU from pops.solvers.elliptic import GeometricMG from pops.time import ( + ALL_PROVISIONAL_STORES, AdaptiveCFL, Dense, LocalLinear, @@ -54,6 +55,45 @@ DEFAULT_CELLS = 8 DEFAULT_T_END = 1.0e-5 +PARTICLE_NUMBER_RELATIVE_TOLERANCE = 1.0e-10 + + +@closure(4) +def user_hyqmom15_closure(standardized: Any) -> dict[str, Any]: + """Close the six fifth-order moments through the public local algebra contract.""" + + s03 = standardized["S03"] + s04 = standardized["S04"] + s11 = standardized["S11"] + s12 = standardized["S12"] + s13 = standardized["S13"] + s21 = standardized["S21"] + s22 = standardized["S22"] + s30 = standardized["S30"] + s31 = standardized["S31"] + s40 = standardized["S40"] + return { + "S50": 0.5 * s30 * (5.0 * s40 - 3.0 * s30 * s30 - 1.0), + "S41": ( + -0.25 * s30 * (8.0 * s40 - 9.0 * s30 * s30 - 4.0) * s11 + + 0.25 * (10.0 * s40 - 15.0 * s30 * s30 - 6.0) * s21 + + 2.0 * s30 * s31 + ), + "S32": ( + 0.5 * (2.0 * s40 - 3.0 * s30 * s30) * s12 + + 0.5 * (3.0 * s22 - 1.0) * s30 + ), + "S23": ( + 0.5 * (2.0 * s04 - 3.0 * s03 * s03) * s21 + + 0.5 * (3.0 * s22 - 1.0) * s03 + ), + "S14": ( + -0.25 * s03 * (8.0 * s04 - 9.0 * s03 * s03 - 4.0) * s11 + + 0.25 * (10.0 * s04 - 15.0 * s03 * s03 - 6.0) * s12 + + 2.0 * s03 * s13 + ), + "S05": 0.5 * s03 * (5.0 * s04 - 3.0 * s03 * s03 - 1.0), + } def _native_output_mode() -> ParallelMode: @@ -93,6 +133,7 @@ class HyQMOM15Authoring: """All exact declarations retained across the public lifecycle.""" model: Any + closure: Any case: Any state: Any state_instance: Any @@ -116,10 +157,20 @@ class RuntimeSnapshot: fields: dict[str, np.ndarray] histories: dict[str, tuple[np.ndarray, ...]] program_hash: str + transaction_stores: tuple[str, ...] consumer_graph_identity: str consumer_cursors: dict[str, Any] +@dataclass(frozen=True, slots=True) +class PhysicalDiagnostics: + """Retained-state checks required by the HyQMOM15 specification.""" + + realizable: bool + particle_number: float + particle_number_relative_error: float + + @dataclass(frozen=True, slots=True) class ExecutionEvidence: """Scientific artifacts and exact states produced by one final execution.""" @@ -137,6 +188,8 @@ class ExecutionEvidence: restored: RuntimeSnapshot continuous: RuntimeSnapshot restarted: RuntimeSnapshot + reference_particle_number: float + physical_diagnostics: dict[str, PhysicalDiagnostics] def _guarded_imex_program( @@ -220,6 +273,7 @@ def build_authoring( "unit_square", lower=(0.0, 0.0), upper=(1.0, 1.0), ).frame(Cartesian2D()) model = HyQMOM15.vlasov_lorentz( + closure=user_hyqmom15_closure, q_over_m=ConstParam("q_over_m", -1.0), omega_c=ConstParam("omega_c", 0.5), projection=realizability, @@ -295,6 +349,7 @@ def build_authoring( ))) return HyQMOM15Authoring( model=model, + closure=user_hyqmom15_closure, case=case, state=state, state_instance=state_instance, @@ -329,6 +384,55 @@ def build_initial_state(*, cells: int = DEFAULT_CELLS) -> dict[str, np.ndarray]: return {"plasma": state} +def _particle_number(state: Any) -> float: + """Integrate ``M00`` over the unit square represented by cell averages.""" + + values = np.asarray(state, dtype=np.float64) + if values.ndim != 3 or values.shape[0] != len(HyQMOM15.components): + raise ValueError( + "HyQMOM15 diagnostics require a (15, ny, nx) cell-average state" + ) + density = values[HyQMOM15.components.index("M00")] + if density.size == 0: + raise ValueError("HyQMOM15 diagnostics require at least one cell") + return float(np.sum(density, dtype=np.float64) / density.size) + + +def _require_physical_diagnostics( + state: Any, + *, + projection: RealizabilityProjection, + reference_particle_number: float, + where: str, +) -> PhysicalDiagnostics: + """Require finite, realizable moments and conservative particle number.""" + + values = np.asarray(state, dtype=np.float64) + if not np.isfinite(values).all(): + raise RuntimeError("%s contains a non-finite moment" % where) + if ( + not np.isfinite(reference_particle_number) + or reference_particle_number <= 0.0 + ): + raise ValueError("reference particle number must be finite and positive") + realizable = bool(projection.is_hyqmom15_realizable(values)) + if not realizable: + raise RuntimeError("%s is not HyQMOM15-realizable" % where) + particle_number = _particle_number(values) + scale = max(abs(reference_particle_number), np.finfo(np.float64).tiny) + relative_error = abs(particle_number - reference_particle_number) / scale + if relative_error > PARTICLE_NUMBER_RELATIVE_TOLERANCE: + raise RuntimeError( + "%s changed particle number by %.6e (limit %.6e)" + % (where, relative_error, PARTICLE_NUMBER_RELATIVE_TOLERANCE) + ) + return PhysicalDiagnostics( + realizable=realizable, + particle_number=particle_number, + particle_number_relative_error=relative_error, + ) + + def compile_final_case( *, cells: int = DEFAULT_CELLS, inject_nonrealizable: bool = False, ) -> tuple[HyQMOM15Authoring, Any, Any]: @@ -355,6 +459,16 @@ def compile_final_case( def _snapshot(simulation: Any) -> RuntimeSnapshot: + program_report = simulation.program_report() + if not program_report.installed: + raise RuntimeError("HyQMOM15 runtime has no installed Program report") + transaction_stores = tuple(program_report.step_transaction.get("stores", ())) + expected_stores = tuple(store.value for store in ALL_PROVISIONAL_STORES) + if transaction_stores != expected_stores: + raise RuntimeError( + "HyQMOM15 transaction does not own every provisional store: %r" + % (transaction_stores,) + ) fields = { slot: np.asarray(simulation.field_potential_global(slot), dtype=np.float64).copy() for slot in simulation.field_provider_slots() @@ -373,6 +487,7 @@ def _snapshot(simulation: Any) -> RuntimeSnapshot: fields=fields, histories=histories, program_hash=str(simulation.installed_program_hash()), + transaction_stores=transaction_stores, consumer_graph_identity=simulation.consumer_graph.identity.token, consumer_cursors=simulation.consumer_cursors.to_data(), ) @@ -380,7 +495,8 @@ def _snapshot(simulation: Any) -> RuntimeSnapshot: def _require_same_snapshot(left: RuntimeSnapshot, right: RuntimeSnapshot, *, where: str) -> bool: for name in ( - "time", "macro_step", "program_hash", "consumer_graph_identity", "consumer_cursors", + "time", "macro_step", "program_hash", "transaction_stores", + "consumer_graph_identity", "consumer_cursors", ): if getattr(left, name) != getattr(right, name): raise RuntimeError("%s changed %s across restart" % (where, name)) @@ -461,8 +577,9 @@ def run_and_restart( root.mkdir(parents=True, exist_ok=True) rejected_before, rejected_after, rejection_reason = \ _run_rejected_nonrealizable_attempt(root, cells=cells) - _target, _resolved, artifact = compile_final_case(cells=cells) + target, _resolved, artifact = compile_final_case(cells=cells) initial = build_initial_state(cells=cells) + reference_particle_number = _particle_number(initial["plasma"]) simulation = _bind_artifact(artifact, initial_state=initial) accepted_root = root / "accepted" run_report = pops.run( @@ -499,6 +616,23 @@ def run_and_restart( resumed, t_end=final_time, max_steps=1, output_dir=root / "restarted") continuous, restarted = _snapshot(simulation), _snapshot(resumed) _require_same_snapshot(continuous, restarted, where="bit-identical continuation") + snapshots = { + "rejected_before": rejected_before, + "rejected_after": rejected_after, + "accepted": accepted, + "restored": restored, + "continuous": continuous, + "restarted": restarted, + } + physical_diagnostics = { + name: _require_physical_diagnostics( + snapshot.state, + projection=target.realizability, + reference_particle_number=reference_particle_number, + where=name.replace("_", " "), + ) + for name, snapshot in snapshots.items() + } return ExecutionEvidence( hdf5_path=hdf5_path, @@ -514,6 +648,8 @@ def run_and_restart( restored=restored, continuous=continuous, restarted=restarted, + reference_particle_number=reference_particle_number, + physical_diagnostics=physical_diagnostics, ) @@ -533,11 +669,20 @@ def main(argv: list[str] | None = None) -> None: print("checkpoint: %s" % evidence.manual_checkpoint_path) print("non-realizable rollback: %s" % rollback) print("bit-identical restart: True") + diagnostics = evidence.physical_diagnostics + restarted_diagnostics = diagnostics["restarted"] print("report: " + json.dumps({ "finite": bool(np.isfinite(evidence.restarted.state).all()), + "realizable": all(value.realizable for value in diagnostics.values()), "n_moments": int(evidence.restarted.state.shape[0]), + "particle_number": restarted_diagnostics.particle_number, + "particle_number_reference": evidence.reference_particle_number, + "particle_number_relative_error": max( + value.particle_number_relative_error for value in diagnostics.values()), + "particle_number_relative_tolerance": PARTICLE_NUMBER_RELATIVE_TOLERANCE, "runtime_steps": evidence.restarted.macro_step, "runtime_time": evidence.restarted.time, + "rollback_stores": list(evidence.restarted.transaction_stores), "rejection_reason": evidence.rejection_reason, "nonrealizable_rollback": rollback, "scheduled_checkpoint": str(evidence.scheduled_checkpoint_path), diff --git a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py index 9a65f119b..c6b2c44c3 100644 --- a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py +++ b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py @@ -98,6 +98,7 @@ def _bind_artifact(artifact: Any, **inputs: Any) -> Any: implicit_c=(Fraction(0), Fraction(1)), name="cn-heun-imex", ) +HYSTERESIS_MIN_CYCLES = 2 @dataclass(frozen=True, slots=True) @@ -148,6 +149,8 @@ class IMEXRuntimeSnapshot: regrid_count: int topology_epoch: int program_hash: str + program_accepted_state: bytes + program_transaction_state: str consumer_graph_identity: str consumer_cursors: dict[str, Any] @@ -160,6 +163,15 @@ class IMEXAMRProgramEvidence: synchronization_phases: tuple[str, ...] +@dataclass(frozen=True, slots=True) +class IMEXRejectedAttemptEvidence: + """Exact proof that one consumed SolveOutcome rolled back without publication.""" + + error: str + before: IMEXRuntimeSnapshot + after: IMEXRuntimeSnapshot + + @dataclass(frozen=True, slots=True) class IMEXExecutionEvidence: """Artifacts plus exact pre/post-restart and continuation snapshots.""" @@ -287,7 +299,10 @@ def _preset_imex_program(core: IMEXAMRAuthoring, *, solve_action: Any) -> Progra def build_authoring( - *, use_preset: bool = False, field_solver: Any | None = None, + *, + use_preset: bool = False, + field_solver: Any | None = None, + relaxation_domain: Any | None = None, ) -> IMEXAMRAuthoring: domain = Rectangle( "unit_square", @@ -310,7 +325,11 @@ def build_authoring( # the incoming subspace; a static boundary table must not silently pretend to support them. velocity_x = model.param(RuntimeParam("a_x", default=1.0, domain=Positive())) velocity_y = model.param(RuntimeParam("a_y", default=0.25, domain=Positive())) - relaxation_rate = model.param(RuntimeParam("lambda", default=50.0, domain=Positive())) + relaxation_rate = model.param(RuntimeParam( + "lambda", + default=50.0, + domain=Positive() if relaxation_domain is None else relaxation_domain, + )) inlet_value = model.param(RuntimeParam("u_in", default=0.0, domain=Interval(-10.0, 10.0))) a_x = model.value(velocity_x) a_y = model.value(velocity_y) @@ -483,9 +502,13 @@ def build_layout(core: IMEXAMRAuthoring) -> Any: Coarsen(value < core.case.value(core.coarsen_value)), Buffer(cells=2), ), - # Equality is explicit. A non-zero temporal dwell requires a checkpointed per-cell tagging - # state provider; this example does not pretend that an in-memory counter is restart-safe. - hysteresis=Hysteresis(min_cycles=0, equality=EqualityPolicy.HOLD), + # Keep one full tagging cycle between opposite decisions. The native Program accepted-state + # image owns this sparse, topology-independent history, so rejection and strict restart + # restore the same hysteresis authority instead of resetting an in-memory Python counter. + hysteresis=Hysteresis( + min_cycles=HYSTERESIS_MIN_CYCLES, + equality=EqualityPolicy.HOLD, + ), conflict_policy=ConflictPolicy.REFINE_WINS, ) transfer = AMRTransfer() @@ -538,10 +561,15 @@ def build_consumers(core: IMEXAMRAuthoring, *, output_mode: Any = None) -> Any: def build_final_case( *, use_preset: bool = False, field_solver: Any | None = None, + relaxation_domain: Any | None = None, initial_background: float = 0.05, initial_amplitude: float = 0.95, output_mode: Any = None, ) -> FinalIMEXAMRCase: - core = build_authoring(use_preset=use_preset, field_solver=field_solver) + core = build_authoring( + use_preset=use_preset, + field_solver=field_solver, + relaxation_domain=relaxation_domain, + ) core.numerics.boundaries.add(build_boundaries(core)) core.case.numerics(core.numerics, block=core.tracer) core.case.initials.add(build_initial( @@ -551,12 +579,17 @@ def build_final_case( return FinalIMEXAMRCase(core, build_layout(core)) -def build_bind_params(core: IMEXAMRAuthoring, *, inlet_value: float = 0.0) -> dict[Any, float]: +def build_bind_params( + core: IMEXAMRAuthoring, + *, + inlet_value: float = 0.0, + relaxation_rate: float = 50.0, +) -> dict[Any, float]: resolve = core.case.resolve return { resolve(core.velocity_x): 1.0, resolve(core.velocity_y): 0.25, - resolve(core.relaxation_rate): 50.0, + resolve(core.relaxation_rate): relaxation_rate, resolve(core.inlet_value): inlet_value, resolve(core.refine_value): 0.70, resolve(core.coarsen_value): 0.25, @@ -566,16 +599,35 @@ def build_bind_params(core: IMEXAMRAuthoring, *, inlet_value: float = 0.0) -> di def compile_final_case( *, use_preset: bool = False, + relaxation_domain: Any | None = None, ) -> tuple[FinalIMEXAMRCase, Any, Any]: """Compile one exact manual or preset-authored target through the public lifecycle.""" target = build_final_case( - use_preset=use_preset, output_mode=_native_output_mode() + use_preset=use_preset, + relaxation_domain=relaxation_domain, + output_mode=_native_output_mode(), ) resolved = pops.resolve(pops.validate(target.authoring.case), layout=target.layout) return target, resolved, pops.compile(resolved) +def _program_transaction_state(simulation: Any) -> str: + """Canonicalize every rollback-sensitive Program registry without field arrays.""" + + report = simulation.program_report().to_dict() + return json.dumps({ + "cache": report["cache"], + "clocks": report["clocks"], + "diagnostics": report["diagnostics"], + "flux_ledger": report["flux_ledger"], + "histories": report["histories"], + "level_relations": report["level_relations"], + "synchronization": report["synchronization"], + "temporal": report["temporal"], + }, sort_keys=True, separators=(",", ":")) + + def _snapshot(simulation: Any) -> IMEXRuntimeSnapshot: """Capture state, solved fields, hierarchy, clocks, identities and consumer cursors.""" @@ -594,6 +646,9 @@ def _snapshot(simulation: Any) -> IMEXRuntimeSnapshot: } if any(count <= 0 for count in field_level_counts.values()): raise RuntimeError("IMEX acceptance installed an empty diagnostic-field hierarchy") + program_accepted_state = bytes(simulation.program_accepted_state()) + if not program_accepted_state: + raise RuntimeError("IMEX acceptance installed no canonical Program accepted-state image") regrid = simulation.amr.explain_regrid() return IMEXRuntimeSnapshot( time=float(simulation.time()), @@ -623,6 +678,8 @@ def _snapshot(simulation: Any) -> IMEXRuntimeSnapshot: regrid_count=int(regrid.regrid_count), topology_epoch=int(regrid.topology_epoch), program_hash=str(simulation.installed_program_hash()), + program_accepted_state=program_accepted_state, + program_transaction_state=_program_transaction_state(simulation), consumer_graph_identity=simulation.consumer_graph.identity.token, consumer_cursors=simulation.consumer_cursors.to_data(), ) @@ -710,6 +767,14 @@ def _require_same_snapshot( "regrid_count": (left.regrid_count, right.regrid_count), "topology_epoch": (left.topology_epoch, right.topology_epoch), "program_hash": (left.program_hash, right.program_hash), + "program_accepted_state": ( + left.program_accepted_state, + right.program_accepted_state, + ), + "program_transaction_state": ( + left.program_transaction_state, + right.program_transaction_state, + ), "consumer_graph_identity": ( left.consumer_graph_identity, right.consumer_graph_identity, @@ -792,6 +857,57 @@ def _reopen_scientific_outputs(root: Path) -> tuple[Path, Path, str, str]: ) +def run_rejected_attempt_rollback(output_dir: Any) -> IMEXRejectedAttemptEvidence: + """Force one singular IMEX solve and prove complete rollback before publication.""" + + root = Path(output_dir) + if root.exists() and any(root.iterdir()): + raise ValueError("rejected-attempt proof requires an empty output directory") + target, _resolved, artifact = compile_final_case( + use_preset=False, + relaxation_domain=Interval(-1.0e6, 1.0e6), + ) + # Keep the negative fixture exactly singular even when the compiler contracts ``1 - a * L`` + # into one FMA. A binary power-of-two duration makes both ``a`` and ``L = 1 / a`` exact; + # the ordinary production run retains its independently authored end time above. + first_dt = 2.0 ** -14 + diagonal = float(IMEX_CN_HEUN.implicit_A[1][1]) + singular_rate = -1.0 / (first_dt * diagonal) + simulation = _bind_artifact( + artifact, + params=build_bind_params( + target.authoring, + relaxation_rate=singular_rate, + ), + ) + before = _snapshot(simulation) + try: + pops.run( + simulation, + t_end=first_dt, + max_steps=1, + output_dir=root, + ) + except RuntimeError as error: + message = str(error) + if not message.startswith("step attempt rejected during "): + raise RuntimeError( + "negative IMEX proof failed for an unexpected reason: %s" % message + ) from error + else: + raise RuntimeError("singular IMEX solve unexpectedly accepted its macro-step") + + after = _snapshot(simulation) + _require_same_snapshot(before, after, where="rejected IMEX attempt") + leaked = tuple(path for path in root.rglob("*") if path.is_file()) + if leaked: + raise RuntimeError( + "rejected IMEX attempt published files: %s" + % ", ".join(str(path) for path in leaked) + ) + return IMEXRejectedAttemptEvidence(message, before, after) + + def run_manual_and_restart(output_dir: Any) -> IMEXExecutionEvidence: """Run the manual Program, reopen output, restart fresh, then continue bit-identically.""" @@ -916,6 +1032,7 @@ def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) output_dir = args.output_dir.resolve() + rejected = run_rejected_attempt_rollback(output_dir / "rejected") evidence = run_manual_and_restart(output_dir / "manual") preset = run_preset_parity(output_dir / "preset", evidence.accepted) restart_equal = _snapshots_bit_identical(evidence.accepted, evidence.restored) @@ -940,6 +1057,9 @@ def main(argv: list[str] | None = None) -> None: print("bit-identical restart: %s" % restart_equal) print("bit-identical continuation: %s" % continuation_equal) print("manual/pops.lib.time.IMEX parity: %s" % preset_equal) + print("rejected-attempt rollback: %s" % _snapshots_bit_identical( + rejected.before, rejected.after, + )) print( "regrid count: %d -> %d (topology epoch %d -> %d)" % ( @@ -957,7 +1077,13 @@ def main(argv: list[str] | None = None) -> None: "flux_ledger_levels": list(evidence.program_evidence.flux_ledger_levels), "levels": evidence.level_count, "manual_preset_bit_identical": preset_equal, + "rejected_attempt_error": rejected.error, + "rejected_attempt_rollback": _snapshots_bit_identical( + rejected.before, rejected.after, + ), "program_hash": preset.program_hash, + "program_accepted_state_bytes": len(preset.program_accepted_state), + "tagging_hysteresis_min_cycles": HYSTERESIS_MIN_CYCLES, "regrid_count": evidence.accepted.regrid_count, "regrid_count_after_continuation": evidence.restarted.regrid_count, "runtime_steps": evidence.accepted.macro_step, diff --git a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py index 18ae4ac08..89d25a077 100644 --- a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py +++ b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py @@ -11,6 +11,7 @@ from collections.abc import Callable from dataclasses import dataclass from fractions import Fraction +import json from pathlib import Path from typing import Any @@ -30,6 +31,16 @@ OUTPUT_ROOT = Path("outputs/scalar_advection") +VELOCITY_X = 1.0 +VELOCITY_Y = 0.25 +INFLOW_X = 0.0 +INFLOW_Y = 0.0 +GAUSSIAN_BACKGROUND = 0.05 +GAUSSIAN_AMPLITUDE = 0.95 +GAUSSIAN_INVERSE_WIDTH = 120.0 +GAUSSIAN_CENTER_X = 0.30 +GAUSSIAN_CENTER_Y = 0.35 +RELATIVE_L2_TOLERANCE = 0.10 ProgramBuilder = Callable[[Any, Any], pops.Program] @@ -114,11 +125,35 @@ class ScalarRuntimeSnapshot: macro_step: int states: tuple[np.ndarray, ...] patch_boxes: tuple[tuple[int, ...], ...] + regrid_count: int + topology_epoch: int program_hash: str + program_transaction_state: str consumer_graph_identity: str consumer_cursors: dict[str, Any] +@dataclass(frozen=True, slots=True) +class ScalarErrorNorms: + """Cell-volume-weighted error against the exact characteristic solution.""" + + time: float + active_cells: int + l1: float + l2: float + linf: float + relative_l2: float + + +@dataclass(frozen=True, slots=True) +class ScalarAMRProgramEvidence: + """Accepted conservative coupling recorded by the native Program report.""" + + flux_ledger_levels: tuple[int, ...] + synchronization_relations: tuple[tuple[int, int], ...] + synchronization_phases: tuple[str, ...] + + @dataclass(frozen=True, slots=True) class ScalarExecutionEvidence: """Artifacts and snapshots proving manual execution, strict restart and continuation.""" @@ -128,6 +163,8 @@ class ScalarExecutionEvidence: checkpoint_path: Path hdf5_identity: str paraview_identity: str + error_norms: ScalarErrorNorms + program_evidence: ScalarAMRProgramEvidence accepted: ScalarRuntimeSnapshot restored: ScalarRuntimeSnapshot continuous: ScalarRuntimeSnapshot @@ -212,16 +249,16 @@ def build_authoring( (u,) = state velocity_x_param = model.param( - RuntimeParam("a_x", default=1.0, domain=Positive()) + RuntimeParam("a_x", default=VELOCITY_X, domain=Positive()) ) velocity_y_param = model.param( - RuntimeParam("a_y", default=0.25, domain=Positive()) + RuntimeParam("a_y", default=VELOCITY_Y, domain=Positive()) ) inlet_x_param = model.param( - RuntimeParam("u_in_x", default=0.0, domain=Interval(-10.0, 10.0)) + RuntimeParam("u_in_x", default=INFLOW_X, domain=Interval(-10.0, 10.0)) ) inlet_y_param = model.param( - RuntimeParam("u_in_y", default=0.0, domain=Interval(-10.0, 10.0)) + RuntimeParam("u_in_y", default=INFLOW_Y, domain=Interval(-10.0, 10.0)) ) # Handles remain stable identities. Only explicit value reads enter symbolic algebra. @@ -278,7 +315,9 @@ def build_authoring( # Run controls do not select physics, a spatial method, a time method or a CFL strategy. run_controls = { - "t_end": 1.0, + # At t=0.2 the translated Gaussian is still inside the domain, so the accepted scientific + # artifact can be checked against the non-trivial characteristic solution. + "t_end": 0.20, "max_steps": 100_000, "output_dir": Path(output_root), } @@ -408,10 +447,13 @@ def build_initial_condition(core: ScalarAdvectionAuthoring) -> Any: gaussian = Gaussian( frame=core.frame, - center={core.frame.x: 0.30, core.frame.y: 0.35}, - background=0.05, - amplitude=0.95, - inverse_width=120.0, + center={ + core.frame.x: GAUSSIAN_CENTER_X, + core.frame.y: GAUSSIAN_CENTER_Y, + }, + background=GAUSSIAN_BACKGROUND, + amplitude=GAUSSIAN_AMPLITUDE, + inverse_width=GAUSSIAN_INVERSE_WIDTH, ) return InitialCondition( state=core.tracer_state, @@ -495,10 +537,10 @@ def build_bind_params(core: ScalarAdvectionAuthoring) -> dict[Any, float]: resolve = core.case.resolve return { - resolve(core.velocity_x_param): 1.0, - resolve(core.velocity_y_param): 0.25, - resolve(core.inlet_x_param): 0.0, - resolve(core.inlet_y_param): 0.0, + resolve(core.velocity_x_param): VELOCITY_X, + resolve(core.velocity_y_param): VELOCITY_Y, + resolve(core.inlet_x_param): INFLOW_X, + resolve(core.inlet_y_param): INFLOW_Y, resolve(core.refine_threshold): 0.10, resolve(core.coarsen_threshold): 0.04, } @@ -521,6 +563,22 @@ def compile_final_case( return target, pops.compile(resolved) +def _program_transaction_state(simulation: Any) -> str: + """Canonicalize every restart-sensitive Program registry without native objects.""" + + report = simulation.program_report().to_dict() + return json.dumps({ + "cache": report["cache"], + "clocks": report["clocks"], + "diagnostics": report["diagnostics"], + "flux_ledger": report["flux_ledger"], + "histories": report["histories"], + "level_relations": report["level_relations"], + "synchronization": report["synchronization"], + "temporal": report["temporal"], + }, sort_keys=True, separators=(",", ":")) + + def _snapshot(simulation: Any) -> ScalarRuntimeSnapshot: """Capture every state item required for strict AMR continuation parity.""" @@ -530,6 +588,7 @@ def _snapshot(simulation: Any) -> ScalarRuntimeSnapshot: level_count = int(simulation.n_levels()) if level_count <= 0: raise RuntimeError("scalar acceptance installed no AMR hierarchy levels") + regrid = simulation.amr.explain_regrid() return ScalarRuntimeSnapshot( time=float(simulation.time()), macro_step=int(simulation.macro_step()), @@ -544,7 +603,10 @@ def _snapshot(simulation: Any) -> ScalarRuntimeSnapshot: tuple(int(value) for value in row) for row in simulation.patch_boxes() ), + regrid_count=int(regrid.regrid_count), + topology_epoch=int(regrid.topology_epoch), program_hash=str(simulation.installed_program_hash()), + program_transaction_state=_program_transaction_state(simulation), consumer_graph_identity=simulation.consumer_graph.identity.token, consumer_cursors=simulation.consumer_cursors.to_data(), ) @@ -562,7 +624,13 @@ def _require_same_snapshot( "time": (left.time, right.time), "macro_step": (left.macro_step, right.macro_step), "patch_boxes": (left.patch_boxes, right.patch_boxes), + "regrid_count": (left.regrid_count, right.regrid_count), + "topology_epoch": (left.topology_epoch, right.topology_epoch), "program_hash": (left.program_hash, right.program_hash), + "program_transaction_state": ( + left.program_transaction_state, + right.program_transaction_state, + ), "consumer_graph_identity": ( left.consumer_graph_identity, right.consumer_graph_identity, @@ -592,9 +660,149 @@ def _require_refined_hierarchy(snapshot: ScalarRuntimeSnapshot, *, where: str) - "%s did not execute the requested refined AMR hierarchy: expected=%r, actual=%r" % (where, expected_levels, actual_levels) ) + if snapshot.regrid_count <= 0 or snapshot.topology_epoch <= 0: + raise RuntimeError( + "%s exposes refined patches but no completed dynamic topology replacement: " + "regrid_count=%d, topology_epoch=%d" + % (where, snapshot.regrid_count, snapshot.topology_epoch) + ) + + +def _require_regrid_progress( + before: ScalarRuntimeSnapshot, + after: ScalarRuntimeSnapshot, + *, + where: str, +) -> None: + """Require continuation to cross a completed topology-changing regrid window.""" + + if after.macro_step <= before.macro_step: + raise RuntimeError( + "%s did not advance the accepted macro-step (%d -> %d)" + % (where, before.macro_step, after.macro_step) + ) + if after.regrid_count <= before.regrid_count: + raise RuntimeError( + "%s did not complete a dynamic regrid (%d -> %d)" + % (where, before.regrid_count, after.regrid_count) + ) + if after.topology_epoch <= before.topology_epoch: + raise RuntimeError( + "%s did not replace the accepted topology (%d -> %d)" + % (where, before.topology_epoch, after.topology_epoch) + ) + + +def _analytic_solution( + x: np.ndarray, + y: np.ndarray, + *, + time: float, +) -> np.ndarray: + """Backtrace positive characteristics through the two zero-inflow boundaries.""" + + departure_x = x - VELOCITY_X * time + departure_y = y - VELOCITY_Y * time + inside = ( + (departure_x >= 0.0) + & (departure_x <= 1.0) + & (departure_y >= 0.0) + & (departure_y <= 1.0) + ) + exact = np.zeros_like(x, dtype=np.float64) + exact[inside] = ( + GAUSSIAN_BACKGROUND + + GAUSSIAN_AMPLITUDE + * np.exp( + -GAUSSIAN_INVERSE_WIDTH + * ( + (departure_x[inside] - GAUSSIAN_CENTER_X) ** 2 + + (departure_y[inside] - GAUSSIAN_CENTER_Y) ** 2 + ) + ) + ) + return exact + +def _scalar_error_norms(paraview: Any) -> ScalarErrorNorms: + """Measure the accepted leaf-cell solution stored in one reopened VTU artifact.""" -def _reopen_scientific_outputs(root: Path) -> tuple[Path, Path, str, str]: + field_records = tuple(paraview.manifest["datasets"]["fields"].values()) + field_names = { + str(record["name"]) + for record in field_records + if record["association"] == "cell" + } + if len(field_names) != 1: + raise RuntimeError( + "scalar acceptance expected one cell-field family, got %r" + % (tuple(sorted(field_names)),) + ) + (field_name,) = tuple(field_names) + values = np.asarray(paraview.arrays[field_name], dtype=np.float64) + if values.ndim == 2 and values.shape[1] == 1: + values = values[:, 0] + if values.ndim != 1: + raise RuntimeError("scalar VTU field must contain one component per cell") + + points = np.asarray(paraview.arrays["Points"], dtype=np.float64) + offsets = np.asarray(paraview.arrays["offsets"], dtype=np.int64) + connectivity = np.asarray(paraview.arrays["connectivity"], dtype=np.int64) + cell_sizes = np.diff(np.concatenate((np.asarray((0,), dtype=np.int64), offsets))) + if ( + offsets.size != values.size + or cell_sizes.size == 0 + or not np.all(cell_sizes == cell_sizes[0]) + ): + raise RuntimeError("scalar VTU topology is not one fixed-size cell family") + cell_points = connectivity.reshape((offsets.size, int(cell_sizes[0]))) + centers = np.mean(points[cell_points, :2], axis=1) + + coverage = np.asarray(paraview.arrays["pops_coverage"], dtype=np.uint8) + ghost_types = np.asarray(paraview.arrays["vtkGhostType"], dtype=np.uint8) + volumes = np.asarray(paraview.arrays["pops_cell_volume"], dtype=np.float64) + if not ( + coverage.shape == ghost_types.shape == volumes.shape == values.shape + ): + raise RuntimeError("scalar VTU geometry and field arrays have inconsistent extents") + # Ignore covered coarse cells and replicated MPI cells. Bit 0 is VTK_DUPLICATECELL. + active = (coverage == 0) & ((ghost_types & np.uint8(1)) == 0) + if not np.any(active) or np.any(volumes[active] <= 0.0): + raise RuntimeError("scalar VTU contains no positive-volume active leaf cells") + + time_values = np.asarray(paraview.arrays["TimeValue"], dtype=np.float64) + if time_values.shape != (1,) or not np.isfinite(time_values[0]): + raise RuntimeError("scalar VTU must contain one finite physical TimeValue") + time = float(time_values[0]) + exact = _analytic_solution(centers[:, 0], centers[:, 1], time=time) + error = values - exact + weights = volumes[active] + active_error = error[active] + exact_l2 = float(np.sqrt(np.sum(exact[active] ** 2 * weights))) + if not np.isfinite(active_error).all() or exact_l2 <= 0.0: + raise RuntimeError("scalar analytic comparison is non-finite or has zero reference norm") + l1 = float(np.sum(np.abs(active_error) * weights)) + l2 = float(np.sqrt(np.sum(active_error**2 * weights))) + linf = float(np.max(np.abs(active_error))) + result = ScalarErrorNorms( + time=time, + active_cells=int(np.count_nonzero(active)), + l1=l1, + l2=l2, + linf=linf, + relative_l2=l2 / exact_l2, + ) + if result.relative_l2 > RELATIVE_L2_TOLERANCE: + raise RuntimeError( + "scalar relative L2 error %.6e exceeds documented tolerance %.6e at t=%.6e" + % (result.relative_l2, RELATIVE_L2_TOLERANCE, result.time) + ) + return result + + +def _reopen_scientific_outputs( + root: Path, +) -> tuple[Path, Path, str, str, ScalarErrorNorms]: """Reopen one independently persisted HDF5 and ParaView artifact.""" from pops.output import read_hdf5, read_paraview @@ -606,11 +814,73 @@ def _reopen_scientific_outputs(root: Path) -> tuple[Path, Path, str, str]: hdf5_path, paraview_path = hdf5_paths[-1], paraview_paths[-1] hdf5 = read_hdf5(hdf5_path) paraview = read_paraview(paraview_path) + if not hdf5.arrays or not paraview.arrays: + raise RuntimeError("published scalar artifacts reopened without arrays") + if not all( + np.isfinite(value).all() + for artifact in (hdf5, paraview) + for value in artifact.arrays.values() + ): + raise RuntimeError("published scalar output contains a non-finite value") return ( hdf5_path, paraview_path, hdf5.output_identity.token, paraview.output_identity.token, + _scalar_error_norms(paraview), + ) + + +def _require_multilevel_program_evidence( + report: Any, + *, + expected_levels: tuple[int, ...], +) -> ScalarAMRProgramEvidence: + """Authenticate flux contributions and reflux-before-average-down coupling.""" + + if not report.installed: + raise RuntimeError("scalar acceptance has no installed native Program report") + levels = tuple(sorted({int(row["level"]) for row in report.flux_ledger})) + if levels != expected_levels: + raise RuntimeError( + "scalar flux ledger levels differ from the installed hierarchy: %r != %r" + % (levels, expected_levels) + ) + + phase_groups: dict[tuple[int, ...], list[str]] = {} + for row in report.synchronization: + clock_phase = row["clock_phase"] + key = ( + int(row["parent_level"]), + int(row["child_level"]), + int(row["block"]), + int(row["macro_step"]), + int(clock_phase["numerator"]), + int(clock_phase["denominator"]), + ) + phase_groups.setdefault(key, []).append(str(row["phase"])) + expected_phases = ("reflux", "average_down") + if not phase_groups: + raise RuntimeError("scalar acceptance published no AMR synchronization phases") + for key, phases in phase_groups.items(): + if tuple(phases) != expected_phases: + raise RuntimeError( + "scalar AMR synchronization %r must be reflux then average_down, got %r" + % (key, tuple(phases)) + ) + relations = tuple(sorted({(key[0], key[1]) for key in phase_groups})) + expected_relations = tuple( + (parent, parent + 1) for parent in range(len(expected_levels) - 1) + ) + if relations != expected_relations: + raise RuntimeError( + "scalar synchronization relations differ from the installed hierarchy: %r != %r" + % (relations, expected_relations) + ) + return ScalarAMRProgramEvidence( + flux_ledger_levels=levels, + synchronization_relations=relations, + synchronization_phases=expected_phases, ) @@ -630,8 +900,14 @@ def run_manual_and_restart(output_dir: Any) -> ScalarExecutionEvidence: if run_report.accepted_steps <= 0: raise RuntimeError("the explicit scalar Program executed no accepted macro-step") - hdf5_path, paraview_path, hdf5_identity, paraview_identity = \ + hdf5_path, paraview_path, hdf5_identity, paraview_identity, error_norms = \ _reopen_scientific_outputs(accepted_root) + checkpoint_contract = simulation.amr.explain_checkpoint() + if not checkpoint_contract.restartable or checkpoint_contract.violations: + raise RuntimeError( + "scalar AMR runtime does not expose a strict restart contract: %r" + % (tuple(checkpoint_contract.violations),) + ) checkpoint_path = Path(simulation.checkpoint(root / "accepted_restart")) accepted = _snapshot(simulation) _require_refined_hierarchy(accepted, where="accepted scalar run") @@ -661,12 +937,34 @@ def run_manual_and_restart(output_dir: Any) -> ScalarExecutionEvidence: ) continuous, restarted = _snapshot(simulation), _snapshot(resumed) _require_same_snapshot(continuous, restarted, where="bit-identical continuation") + _require_regrid_progress(accepted, continuous, where="uninterrupted continuation") + _require_regrid_progress(restored, restarted, where="restarted continuation") + expected_levels = tuple(range(len(continuous.states))) + continuous_report = simulation.program_report() + restarted_report = resumed.program_report() + continuous_program = _require_multilevel_program_evidence( + continuous_report, + expected_levels=expected_levels, + ) + restarted_program = _require_multilevel_program_evidence( + restarted_report, + expected_levels=expected_levels, + ) + if continuous_program != restarted_program: + raise RuntimeError("restart changed scalar AMR ledger/synchronization evidence") + if ( + continuous_report.flux_ledger != restarted_report.flux_ledger + or continuous_report.synchronization != restarted_report.synchronization + ): + raise RuntimeError("restart changed scalar AMR ledger/synchronization entries") return ScalarExecutionEvidence( hdf5_path=hdf5_path, paraview_path=paraview_path, checkpoint_path=checkpoint_path, hdf5_identity=hdf5_identity, paraview_identity=paraview_identity, + error_norms=error_norms, + program_evidence=continuous_program, accepted=accepted, restored=restored, continuous=continuous, @@ -735,6 +1033,33 @@ def main() -> None: print("PoPS final scalar-advection acceptance:") print(" HDF5: %s" % evidence.hdf5_identity) print(" ParaView: %s" % evidence.paraview_identity) + print( + " analytic error at t=%.6f: L1=%.6e L2=%.6e Linf=%.6e relative-L2=%.6e" + % ( + evidence.error_norms.time, + evidence.error_norms.l1, + evidence.error_norms.l2, + evidence.error_norms.linf, + evidence.error_norms.relative_l2, + ) + ) + print( + " AMR synchronization: levels=%r relations=%r phases=%r" + % ( + evidence.program_evidence.flux_ledger_levels, + evidence.program_evidence.synchronization_relations, + evidence.program_evidence.synchronization_phases, + ) + ) + print( + " AMR regrid: count=%d -> %d topology-epoch=%d -> %d" + % ( + evidence.accepted.regrid_count, + evidence.restarted.regrid_count, + evidence.accepted.topology_epoch, + evidence.restarted.topology_epoch, + ) + ) print(" checkpoint: %s" % evidence.checkpoint_path) print(" bit-identical restart: step %d" % evidence.restarted.macro_step) print(" explicit/pops.lib.time.SSPRK2 parity: %s" % preset.program_hash) diff --git a/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py b/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py index 89e64f59c..b9022f2b6 100644 --- a/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py +++ b/examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py @@ -15,6 +15,7 @@ import numpy as np import pops +from pops.diagnostics import Integral from pops.fields import ( CellCenteredSecondOrder, ConstantNullspace, @@ -115,6 +116,9 @@ class RuntimeSnapshot: states: dict[str, np.ndarray] fields: dict[str, np.ndarray] histories: dict[str, tuple[np.ndarray, ...]] + bind_identity: str + layout_plan_identity: str + layout_identities: tuple[str, ...] program_hash: str consumer_graph_identity: str consumer_cursors: dict[str, Any] @@ -129,6 +133,7 @@ class ExecutionEvidence: checkpoint_path: Path hdf5_identity: str paraview_identity: str + missing_mapping_refusal: str accepted: RuntimeSnapshot restored: RuntimeSnapshot continuous: RuntimeSnapshot @@ -348,6 +353,36 @@ def build_authoring(*, output_mode: Any = None) -> MultiphysicsAuthoring: if output_mode is None: output_mode = ParallelMode.SERIAL + end_schedule = on_end(clock=program.clock) + # The field RHS is -ne + ni, so these owner-qualified density integrals publish + # the two signed charge contributions with the same exact coefficients. + # Momentum is likewise selected by typed physical role, never by component name. + end_diagnostics = ( + Integral( + block=electron_block, + role=Density(), + cadence=end_schedule, + coefficient=-1.0, + ), + Integral( + block=ion_block, + role=Density(), + cadence=end_schedule, + coefficient=1.0, + ), + Integral( + block=electron_block, + role=Momentum(axis=x_axis), + cadence=end_schedule, + ), + Integral( + block=electron_block, + role=Momentum(axis=y_axis), + cadence=end_schedule, + ), + Integral(block=ion_block, role=Momentum(axis=x_axis), cadence=end_schedule), + Integral(block=ion_block, role=Momentum(axis=y_axis), cadence=end_schedule), + ) case.consumers(ConsumerGraph.from_consumers(( ScientificOutput( format=ParaView(mode=output_mode), @@ -357,8 +392,9 @@ def build_authoring(*, output_mode: Any = None) -> MultiphysicsAuthoring: ), ScientificOutput( format=HDF5(mode=output_mode), - schedule=on_end(clock=program.clock), + schedule=end_schedule, fields=(electron_state, ion_state), + diagnostics=end_diagnostics, target="state/two_fluid", ), Checkpoint( @@ -419,6 +455,79 @@ def build_final_case( return FinalMultiphysicsCase(authoring, plan, layout, provider) +def require_missing_mapping_provider_refusal( + *, cells: int = DEFAULT_CELLS, publication_root: Any = None, +) -> str: + """Prove that a cross-layout ion-to-field read cannot resolve without its provider.""" + + if isinstance(cells, bool) or not isinstance(cells, int) or cells < 4: + raise ValueError("cells must be an integer >= 4") + root = None if publication_root is None else Path(publication_root) + if root is not None and root.exists() and any( + path.is_file() for path in root.rglob("*") + ): + raise ValueError("the missing-mapping refusal root must not contain prior artifacts") + from pops.layouts import Uniform + from pops.mesh import ( + CartesianGrid, + LayoutMappingOperation, + LayoutPlanBuilder, + LayoutRepresentation, + LayoutSynchronization, + PeriodicAxes, + ) + + authoring = build_authoring() + pops.validate(authoring.case) + subjects = authoring.case.layout_subjects() + blocks = {block.local_id: block for block in subjects.blocks} + states = {state.block_ref.local_id: state for state in subjects.states} + frame = authoring.model.frame + + def descriptor() -> Any: + return Uniform(CartesianGrid( + frame=frame, + cells=(cells, cells), + periodic=PeriodicAxes(frame.axes), + )) + + builder = LayoutPlanBuilder(authoring.case.owner_path.canonical()) + electron_layout = builder.layout("electrons", descriptor()) + ion_layout = builder.layout("ions", descriptor()) + builder.assign_block(blocks["electrons"], electron_layout) + builder.assign_state(states["electrons"], electron_layout) + builder.assign_block(blocks["ions"], ion_layout) + builder.assign_state(states["ions"], ion_layout) + (field_subject,) = subjects.fields + builder.assign_field(field_subject, electron_layout) + builder.require_mapping( + ion_layout, + electron_layout, + source=states["ions"], + target=field_subject, + operation=LayoutMappingOperation.CONSERVATIVE_CELL_AVERAGE_V1, + synchronization=LayoutSynchronization.BEFORE_STEP_V1, + source_representation=LayoutRepresentation.CELL_AVERAGE_V1, + target_representation=LayoutRepresentation.CELL_AVERAGE_V1, + ) + try: + builder.resolve(**subjects.to_dict()) + except ValueError as error: + reason = str(error) + if "missing mapping provider" not in reason: + raise RuntimeError( + "the invalid multiphysics layout failed outside provider resolution" + ) from error + if root is not None and root.exists() and any( + path.is_file() for path in root.rglob("*") + ): + raise RuntimeError( + "missing mapping/provider refusal published an artifact" + ) from error + return reason + raise RuntimeError("a cross-layout multiphysics plan resolved without a mapping provider") + + def build_initial_state(*, cells: int = DEFAULT_CELLS) -> dict[str, np.ndarray]: """Create positive, neutral two-fluid data without selecting any resolved semantics.""" @@ -478,12 +587,20 @@ def _snapshot(simulation: Any) -> RuntimeSnapshot: ) for name in simulation.history_names() } + bound = simulation.bound_snapshot.to_dict() + layout_plan = bound["layout"] return RuntimeSnapshot( time=float(simulation.time()), macro_step=int(simulation.macro_step()), states=states, fields=fields, histories=histories, + bind_identity=simulation.bind_identity.token, + layout_plan_identity=str(layout_plan["qualified_id"]), + layout_identities=tuple( + str(layout["handle"]["qualified_id"]) + for layout in layout_plan["layouts"] + ), program_hash=str(simulation.installed_program_hash()), consumer_graph_identity=simulation.consumer_graph.identity.token, consumer_cursors=simulation.consumer_cursors.to_data(), @@ -496,6 +613,10 @@ def _require_same_snapshot(left: RuntimeSnapshot, right: RuntimeSnapshot, *, whe scalar_pairs = { "time": (left.time, right.time), "macro_step": (left.macro_step, right.macro_step), + "bind_identity": (left.bind_identity, right.bind_identity), + "layout_plan_identity": ( + left.layout_plan_identity, right.layout_plan_identity), + "layout_identities": (left.layout_identities, right.layout_identities), "program_hash": (left.program_hash, right.program_hash), "consumer_graph_identity": ( left.consumer_graph_identity, right.consumer_graph_identity), @@ -528,6 +649,10 @@ def run_and_restart( from pops.output import HDF5, ParaView root = Path(output_dir) + refusal_root = root / "refused_missing_mapping" + missing_mapping_refusal = require_missing_mapping_provider_refusal( + cells=cells, publication_root=refusal_root, + ) root.mkdir(parents=True, exist_ok=True) _target, artifact = compile_final_case(cells=cells) simulation = _bind_artifact( @@ -583,6 +708,7 @@ def run_and_restart( checkpoint_path=checkpoint_path, hdf5_identity=hdf5.output_identity.token, paraview_identity=paraview.output_identity.token, + missing_mapping_refusal=missing_mapping_refusal, accepted=accepted, restored=restored, continuous=continuous, @@ -605,6 +731,8 @@ def main() -> None: print(" HDF5: %s" % evidence.hdf5_identity) print(" ParaView: %s" % evidence.paraview_identity) print(" checkpoint: %s" % evidence.checkpoint_path) + print(" layout: %s" % evidence.restarted.layout_plan_identity) + print(" missing mapping refusal: %s" % evidence.missing_mapping_refusal) print(" bit-identical restart: step %d" % evidence.restarted.macro_step) diff --git a/examples/final/README.md b/examples/final/README.md index 1b097d9b3..aebc32267 100644 --- a/examples/final/README.md +++ b/examples/final/README.md @@ -2,12 +2,22 @@ [`EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py`](EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py) is the final public target, not a migration example. It deliberately contains one authority per -concern and no fallback to an older or lower-level API. +concern and no fallback to an older or lower-level API. The executable acceptance reopens its VTU, +removes covered coarse and replicated cells, and compares the active AMR leaf cells with the exact +characteristic solution using cell-volume-weighted norms. Its relative L2 error must remain at or +below `0.10`. The accepted `ProgramReport` must also contain flux contributions from every installed +level and exact `reflux`, then `average_down`, synchronization for each parent/child relation; strict +restart and the SSPRK2 factory run must preserve that complete transactional state. The example reads +`simulation.amr.explain_regrid()` before and after continuation: both the uninterrupted and restarted +routes must complete a topology-changing regrid, while strict restart preserves `regrid_count` and +`topology_epoch` exactly. It also refuses checkpoint publication unless +`simulation.amr.explain_checkpoint()` reports the bound hierarchy as restartable without violations. [`EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py`](EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py) extends the same public lifecycle with an explicit additive IMEX tableau, typed field solves, -two-level subcycled AMR, conservative transfers and accepted-state consumers. Its matching -contract note is +two-level subcycled AMR, conservative transfers, globally reported AMR lowering coverage, an +executed rejected-attempt rollback proof, persistent tagging hysteresis and accepted-state consumers. +Its matching contract note is [`docs/design/final-advection-imex-amr.md`](../../docs/design/final-advection-imex-amr.md). [`EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py`](EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py) @@ -18,8 +28,10 @@ matching contract note is [`EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py`](EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py) selects two state spaces of one model into two owner-qualified blocks, couples them through a typed -elliptic field on the same periodic layout, and proves scientific outputs plus bit-identical restart -continuation through the public lifecycle. +elliptic field on the same periodic layout, publishes owner-qualified signed charge-contribution +and momentum diagnostics, refuses a required cross-layout read when no mapping provider is +installed, and proves scientific outputs plus bind/layout-exact, bit-identical restart continuation +through the public lifecycle. ## Public contract diff --git a/include/pops/amr/hierarchy/nd/berger_rigoutsos.hpp b/include/pops/amr/hierarchy/nd/berger_rigoutsos.hpp new file mode 100644 index 000000000..9d5487f9c --- /dev/null +++ b/include/pops/amr/hierarchy/nd/berger_rigoutsos.hpp @@ -0,0 +1,447 @@ +/// @file +/// @brief Deterministic axis-indexed Berger-Rigoutsos clustering for tiled ND tags. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::amr::hierarchy::nd { + +template +class BergerRigoutsosProvider final : public ClusterProvider { + static_assert(Dim >= 1 && Dim <= 3, + "BergerRigoutsosProvider only supports dimensions 1, 2, and 3"); + + public: + static constexpr std::string_view kIdentity = "pops.amr.cluster.berger-rigoutsos.nd.v1"; + + std::string_view provider_identity() const noexcept override { return kIdentity; } + + ClusterResult cluster(std::span> shards, + const ClusterOptions& options) const override { + validate_options_(options); + const std::vector*> canonical = authenticate_shards_(shards, options); + Work work{options.budget}; + std::vector> raw; + const LevelLayoutIdentity& source = canonical.front()->level_identity(); + + for (std::size_t global_patch = 0; global_patch < source.patches.size(); ++global_patch) { + for (int axis = 0; axis < Dim; ++axis) + if (source.patches[global_patch].length(axis) > std::numeric_limits::max()) + throw std::length_error( + "Berger-Rigoutsos patch axis exceeds deterministic signature indexing"); + const TagMask& owner = owner_for_patch_(canonical, source, global_patch); + cluster_rec_(owner, source.patches[global_patch], options, work, raw); + } + + std::vector> boxes; + for (const Box& box : raw) { + const std::size_t chopped = chopped_count_(box, options.max_box_size); + work.require_output(chopped); + const mesh::BoxArray pieces = + mesh::BoxArray::from_domain(box, options.max_box_size); + boxes.insert(boxes.end(), pieces.boxes().begin(), pieces.boxes().end()); + } + std::sort(boxes.begin(), boxes.end(), lexicographic_less_); + + work.require_identity(std::string_view{kIdentity}.size()); + work.require_identity(checked_product_(source.patches.size(), sizeof(Box))); + work.require_identity(checked_product_(source.owners.size(), sizeof(Index))); + work.require_identity(checked_product_(boxes.size(), sizeof(Box))); + work.require_identity(checked_product_(canonical.size(), sizeof(TagShardIdentity))); + for (std::size_t shard_index = 0; shard_index < canonical.size(); ++shard_index) { + const TagMask* shard = canonical[shard_index]; + if (source.distribution_mode == mesh::DistributionMode::replicated && shard_index != 0) + continue; + work.require_identity( + checked_product_(shard->patches().size(), sizeof(PatchTagIdentity))); + for (const auto& patch : shard->patches()) + work.require_identity(patch.tags.size()); + } + + ClusterResultIdentity identity; + identity.provider = std::string(kIdentity); + identity.source_level = source; + identity.options = options; + identity.canonical_shards.reserve(canonical.size()); + for (std::size_t shard_index = 0; shard_index < canonical.size(); ++shard_index) { + if (source.distribution_mode == mesh::DistributionMode::replicated && shard_index != 0) { + identity.canonical_shards.push_back( + TagShardIdentity{canonical[shard_index]->local_rank(), {}, true}); + } else { + identity.canonical_shards.push_back(canonical[shard_index]->shard_identity()); + } + } + identity.boxes = boxes; + return ClusterResult{mesh::BoxArray{std::move(boxes)}, std::move(identity)}; + } + + private: + struct Work { + explicit Work(ClusterWorkBudget allowed) : allowed(allowed) {} + + void visit_node() { + if (nodes == allowed.recursion_nodes) + throw std::length_error("Berger-Rigoutsos exceeds its recursion-node budget"); + ++nodes; + } + + void visit_cells(std::size_t count) { + if (visited_cells > allowed.cell_visits || count > allowed.cell_visits - visited_cells) + throw std::length_error("Berger-Rigoutsos exceeds its cell-visit budget"); + visited_cells += count; + } + + void require_output(std::size_t count) { + if (output_boxes > allowed.output_boxes || count > allowed.output_boxes - output_boxes) + throw std::length_error("Berger-Rigoutsos exceeds its output-box budget"); + output_boxes += count; + } + + void require_identity(std::size_t count) { + if (identity_bytes > allowed.identity_bytes || + count > allowed.identity_bytes - identity_bytes) + throw std::length_error("Berger-Rigoutsos exceeds its identity-copy byte budget"); + identity_bytes += count; + } + + ClusterWorkBudget allowed{}; + std::size_t nodes = 0; + std::size_t visited_cells = 0; + std::size_t output_boxes = 0; + std::size_t identity_bytes = 0; + }; + + struct Scan { + Box bounds{}; + std::size_t tagged = 0; + }; + + struct AxisCut { + int axis = -1; + int offset = -1; + std::int64_t length = 0; + long double score = 0.0L; + }; + + static bool lexicographic_less_(const Box& left, const Box& right) { + for (int axis = 0; axis < Dim; ++axis) { + if (left.lo[axis] != right.lo[axis]) + return left.lo[axis] < right.lo[axis]; + if (left.hi[axis] != right.hi[axis]) + return left.hi[axis] < right.hi[axis]; + } + return false; + } + + static void validate_options_(const ClusterOptions& options) { + if (!std::isfinite(options.min_efficiency) || options.min_efficiency <= 0.0 || + options.min_efficiency > 1.0) + throw std::invalid_argument("Berger-Rigoutsos efficiency must lie in (0, 1]"); + for (int axis = 0; axis < Dim; ++axis) { + if (options.min_box_size[axis] <= 0 || options.max_box_size[axis] <= 0) + throw std::invalid_argument("Berger-Rigoutsos box sizes must be strictly positive"); + if (options.min_box_size[axis] > options.max_box_size[axis]) + throw std::invalid_argument("Berger-Rigoutsos minimum box size cannot exceed its maximum"); + } + if (options.budget.shards == 0 || options.budget.recursion_nodes == 0 || + options.budget.cell_visits == 0 || options.budget.output_boxes == 0 || + options.budget.identity_bytes == 0) + throw std::invalid_argument("Berger-Rigoutsos work budgets must be strictly positive"); + if (options.budget.cell_visits > + static_cast(std::numeric_limits::max())) + throw std::invalid_argument("Berger-Rigoutsos cell budget exceeds exact signed counters"); + } + + static std::vector*> authenticate_shards_(std::span> shards, + const ClusterOptions& options) { + if (shards.empty()) + throw std::invalid_argument("Berger-Rigoutsos requires at least one tag shard"); + if (shards.size() > options.budget.shards) + throw std::length_error("Berger-Rigoutsos exceeds its tag-shard budget"); + + const LevelLayoutIdentity& source = shards.front().level_identity(); + if (source.patches.empty() || source.rank_space.empty()) + throw std::invalid_argument("Berger-Rigoutsos source identity is incomplete"); + std::vector*> canonical; + canonical.reserve(shards.size()); + for (const TagMask& shard : shards) { + if (shard.level_identity() != source) + throw std::invalid_argument("Berger-Rigoutsos tag shards disagree on exact level identity"); + canonical.push_back(&shard); + } + std::sort(canonical.begin(), canonical.end(), [&](const auto* left, const auto* right) { + return source.rank_space.linear_rank(left->local_rank()) < + source.rank_space.linear_rank(right->local_rank()); + }); + for (std::size_t index = 1; index < canonical.size(); ++index) + if (canonical[index - 1]->local_rank() == canonical[index]->local_rank()) + throw std::invalid_argument("Berger-Rigoutsos received duplicate rank tag shards"); + + if (canonical.size() != source.rank_space.size()) + throw std::invalid_argument( + "Berger-Rigoutsos requires one tag shard for every process coordinate"); + for (std::size_t rank = 0; rank < canonical.size(); ++rank) + if (canonical[rank]->local_rank() != source.rank_space.coordinate(rank)) + throw std::invalid_argument("Berger-Rigoutsos tag shards do not cover the process space"); + + if (source.distribution_mode == mesh::DistributionMode::replicated) { + const auto& reference = canonical.front()->patches(); + for (const TagMask* shard : canonical) { + if (shard->patches() != reference) + throw std::invalid_argument( + "Berger-Rigoutsos replicated tag shards do not have identical tag bits"); + if (shard->patches().size() != source.patches.size()) + throw std::invalid_argument("Berger-Rigoutsos replicated tag shard omits a patch"); + for (std::size_t patch = 0; patch < source.patches.size(); ++patch) + if (shard->patches()[patch].global_patch != patch || + shard->patches()[patch].box != source.patches[patch]) + throw std::invalid_argument( + "Berger-Rigoutsos replicated tag shard patch identity is invalid"); + } + return canonical; + } + + std::vector seen(source.patches.size(), 0); + for (const TagMask* shard : canonical) { + for (const auto& patch : shard->patches()) { + if (patch.global_patch >= source.patches.size() || + patch.box != source.patches[patch.global_patch]) + throw std::invalid_argument("Berger-Rigoutsos tag shard patch identity is invalid"); + const bool expected = source.distribution_mode == mesh::DistributionMode::replicated || + source.owners[patch.global_patch] == shard->local_rank(); + if (!expected || seen[patch.global_patch] != 0) + throw std::invalid_argument("Berger-Rigoutsos tag shard ownership is invalid"); + seen[patch.global_patch] = 1; + } + } + if (std::find(seen.begin(), seen.end(), 0) != seen.end()) + throw std::invalid_argument("Berger-Rigoutsos tag shards omit an owned patch"); + return canonical; + } + + static std::size_t checked_product_(std::size_t left, std::size_t right) { + if (right != 0 && left > std::numeric_limits::max() / right) + throw std::length_error("Berger-Rigoutsos identity byte count exceeds size_t"); + return left * right; + } + + static const TagMask& owner_for_patch_(const std::vector*>& shards, + const LevelLayoutIdentity& source, + std::size_t global_patch) { + if (source.distribution_mode == mesh::DistributionMode::replicated) + return *shards.front(); + const std::size_t rank = source.rank_space.linear_rank(source.owners.at(global_patch)); + return *shards.at(rank); + } + + static Scan scan_(const TagMask& mask, const Box& region, Work& work) { + work.visit_cells(static_cast(region.numPts())); + Scan scan; + bool found = false; + mask.for_each_cell_in(region, [&](const Index& index, bool tagged) { + if (!tagged) + return; + ++scan.tagged; + if (!found) { + scan.bounds = Box{index, index}; + found = true; + return; + } + for (int axis = 0; axis < Dim; ++axis) { + scan.bounds.lo[axis] = std::min(scan.bounds.lo[axis], index[axis]); + scan.bounds.hi[axis] = std::max(scan.bounds.hi[axis], index[axis]); + } + }); + return scan; + } + + static std::array, Dim> signatures_(const TagMask& mask, + const Box& region, + Work& work) { + work.visit_cells(static_cast(region.numPts())); + std::array, Dim> signatures; + for (int axis = 0; axis < Dim; ++axis) + signatures[axis].assign(static_cast(region.length(axis)), 0); + mask.for_each_cell_in(region, [&](const Index& index, bool tagged) { + if (!tagged) + return; + for (int axis = 0; axis < Dim; ++axis) + ++signatures[axis][static_cast(index[axis] - region.lo[axis])]; + }); + return signatures; + } + + static int best_hole_(const std::vector& signature, int minimum) { + const int length = static_cast(signature.size()); + int best = -1; + int best_distance = std::numeric_limits::max(); + const int center = length / 2; + for (int cut = minimum; cut <= length - minimum; ++cut) { + if (signature[static_cast(cut)] != 0) + continue; + const int distance = std::abs(cut - center); + if (distance < best_distance || (distance == best_distance && cut < best)) { + best = cut; + best_distance = distance; + } + } + return best; + } + + static std::pair best_inflection_(const std::vector& signature, + int minimum) { + const int length = static_cast(signature.size()); + if (length < 3) + return {-1, 0.0L}; + std::vector laplacian(static_cast(length), 0.0L); + for (int index = 1; index < length - 1; ++index) + laplacian[static_cast(index)] = + static_cast(signature[static_cast(index + 1)]) - + 2.0L * signature[static_cast(index)] + + signature[static_cast(index - 1)]; + int best = -1; + long double score = 0.0L; + const int lower = std::max(minimum, 2); + const int upper = std::min(length - minimum, length - 2); + for (int cut = lower; cut <= upper; ++cut) { + const long double candidate = std::abs(laplacian[static_cast(cut)] - + laplacian[static_cast(cut - 1)]); + if (candidate > score) { + best = cut; + score = candidate; + } + } + return {best, score}; + } + + static void cluster_rec_(const TagMask& mask, const Box& candidate, + const ClusterOptions& options, Work& work, + std::vector>& output) { + work.visit_node(); + const Scan scan = scan_(mask, candidate, work); + if (scan.tagged == 0) + return; + const Box& region = scan.bounds; + const long double efficiency = + static_cast(scan.tagged) / static_cast(region.numPts()); + + std::array splittable{}; + bool any_split = false; + for (int axis = 0; axis < Dim; ++axis) { + splittable[axis] = region.length(axis) >= 2LL * options.min_box_size[axis]; + any_split = any_split || splittable[axis]; + } + if (efficiency >= options.min_efficiency || !any_split) { + if (output.size() == options.budget.output_boxes) + throw std::length_error("Berger-Rigoutsos exceeds its raw output-box budget"); + output.push_back(region); + return; + } + + const auto signatures = signatures_(mask, region, work); + std::vector cuts; + for (int candidate_axis = 0; candidate_axis < Dim; ++candidate_axis) { + if (!splittable[candidate_axis]) + continue; + const int candidate_cut = + best_hole_(signatures[candidate_axis], options.min_box_size[candidate_axis]); + if (candidate_cut < 0) + continue; + cuts.push_back(AxisCut{candidate_axis, candidate_cut, region.length(candidate_axis), 0.0L}); + } + if (!cuts.empty()) { + const auto longest = std::max_element( + cuts.begin(), cuts.end(), + [](const AxisCut& left, const AxisCut& right) { return left.length < right.length; }); + const std::int64_t selected_length = longest->length; + std::erase_if(cuts, [=](const AxisCut& candidate_cut) { + return candidate_cut.length != selected_length; + }); + } + + if (cuts.empty()) { + for (int candidate_axis = 0; candidate_axis < Dim; ++candidate_axis) { + if (!splittable[candidate_axis]) + continue; + const auto [candidate_cut, score] = + best_inflection_(signatures[candidate_axis], options.min_box_size[candidate_axis]); + if (candidate_cut < 0) + continue; + cuts.push_back( + AxisCut{candidate_axis, candidate_cut, region.length(candidate_axis), score}); + } + if (!cuts.empty()) { + const auto strongest = std::max_element(cuts.begin(), cuts.end(), + [](const AxisCut& left, const AxisCut& right) { + if (left.score != right.score) + return left.score < right.score; + return left.length < right.length; + }); + const long double selected_score = strongest->score; + const std::int64_t selected_length = strongest->length; + std::erase_if(cuts, [=](const AxisCut& candidate_cut) { + return candidate_cut.score != selected_score || candidate_cut.length != selected_length; + }); + } + } + + if (cuts.empty()) { + std::int64_t longest = 0; + for (int candidate_axis = 0; candidate_axis < Dim; ++candidate_axis) + if (splittable[candidate_axis]) + longest = std::max(longest, region.length(candidate_axis)); + for (int candidate_axis = 0; candidate_axis < Dim; ++candidate_axis) + if (splittable[candidate_axis] && region.length(candidate_axis) == longest) + cuts.push_back(AxisCut{candidate_axis, + static_cast(region.length(candidate_axis) / 2), + region.length(candidate_axis), 0.0L}); + } + if (cuts.empty()) + throw std::logic_error("Berger-Rigoutsos failed to select a deterministic split"); + + std::vector> children{region}; + for (const AxisCut& selected : cuts) { + if (selected.axis < 0 || selected.offset <= 0 || + selected.offset >= region.length(selected.axis)) + throw std::logic_error("Berger-Rigoutsos failed to produce a strict split"); + const std::size_t previous_size = children.size(); + for (std::size_t child = 0; child < previous_size; ++child) { + Box right = children[child]; + children[child].hi[selected.axis] = region.lo[selected.axis] + selected.offset - 1; + right.lo[selected.axis] = region.lo[selected.axis] + selected.offset; + children.push_back(right); + } + } + for (const Box& child : children) + cluster_rec_(mask, child, options, work, output); + } + + static std::size_t chopped_count_(const Box& box, const std::array& max_box_size) { + std::size_t result = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::uint64_t length = static_cast(box.length(axis)); + const std::uint64_t limit = static_cast(max_box_size[axis]); + const std::uint64_t segments = 1 + (length - 1) / limit; + if (segments > std::numeric_limits::max() / result) + throw std::length_error("Berger-Rigoutsos chopped box count exceeds size_t"); + result *= static_cast(segments); + } + return result; + } +}; + +} // namespace pops::amr::hierarchy::nd diff --git a/include/pops/amr/hierarchy/nd/cluster_provider.hpp b/include/pops/amr/hierarchy/nd/cluster_provider.hpp new file mode 100644 index 000000000..179b61947 --- /dev/null +++ b/include/pops/amr/hierarchy/nd/cluster_provider.hpp @@ -0,0 +1,63 @@ +/// @file +/// @brief Prepared ND clustering provider contract. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace pops::amr::hierarchy::nd { + +struct ClusterWorkBudget { + std::size_t shards = 0; + std::size_t recursion_nodes = 0; + std::size_t cell_visits = 0; + std::size_t output_boxes = 0; + std::size_t identity_bytes = 0; + + bool operator==(const ClusterWorkBudget&) const = default; +}; + +template +struct ClusterOptions { + double min_efficiency = 0.0; + std::array min_box_size{}; + std::array max_box_size{}; + ClusterWorkBudget budget{}; + + bool operator==(const ClusterOptions&) const = default; +}; + +template +struct ClusterResultIdentity { + std::string provider{}; + LevelLayoutIdentity source_level{}; + ClusterOptions options{}; + std::vector> canonical_shards{}; + std::vector> boxes{}; + + bool operator==(const ClusterResultIdentity&) const = default; +}; + +template +struct ClusterResult { + mesh::BoxArray boxes{}; + ClusterResultIdentity identity{}; +}; + +template +class ClusterProvider { + public: + virtual ~ClusterProvider() = default; + virtual std::string_view provider_identity() const noexcept = 0; + virtual ClusterResult cluster(std::span> shards, + const ClusterOptions& options) const = 0; +}; + +} // namespace pops::amr::hierarchy::nd diff --git a/include/pops/amr/hierarchy/nd/hierarchy_plan.hpp b/include/pops/amr/hierarchy/nd/hierarchy_plan.hpp new file mode 100644 index 000000000..fb5b3d401 --- /dev/null +++ b/include/pops/amr/hierarchy/nd/hierarchy_plan.hpp @@ -0,0 +1,132 @@ +/// @file +/// @brief Exact ND AMR hierarchy plan with anisotropic parent/child validation. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace pops::amr::hierarchy::nd { + +struct HierarchyValidationBudget { + std::size_t levels = 0; + std::size_t parent_child_patch_pairs = 0; + + bool operator==(const HierarchyValidationBudget&) const = default; +}; + +template +struct HierarchyPlanIdentity { + std::vector> levels{}; + HierarchyValidationBudget validation_budget{}; + + bool operator==(const HierarchyPlanIdentity&) const = default; +}; + +/// Pure geometry/ownership plan. Publication into fields or an MPI runtime is a later cutover. +template +class HierarchyPlan { + static_assert(Dim >= 1 && Dim <= 3, "HierarchyPlan only supports dimensions 1, 2, and 3"); + + public: + HierarchyPlan(std::vector> levels, HierarchyValidationBudget budget) + : levels_(std::move(levels)), budget_(budget) { + validate_(); + } + + std::size_t num_levels() const noexcept { return levels_.size(); } + + const LevelLayout& level(std::size_t index) const { + if (index >= levels_.size()) + throw std::out_of_range("HierarchyPlan level is outside [0, num_levels)"); + return levels_[index]; + } + + const HierarchyValidationBudget& validation_budget() const noexcept { return budget_; } + + HierarchyPlanIdentity exact_identity() const { + HierarchyPlanIdentity identity; + identity.levels.reserve(levels_.size()); + for (const LevelLayout& level_layout : levels_) + identity.levels.push_back(level_layout.exact_identity()); + identity.validation_budget = budget_; + return identity; + } + + /// Return a validated append or replacement, truncating levels finer than the candidate. + HierarchyPlan with_level(LevelLayout candidate) const { + if (candidate.level() < 0 || static_cast(candidate.level()) > levels_.size()) + throw std::out_of_range("HierarchyPlan replacement level is not contiguous"); + std::vector> next; + next.reserve(static_cast(candidate.level()) + 1); + for (int level_index = 0; level_index < candidate.level(); ++level_index) + next.push_back(levels_[static_cast(level_index)]); + next.push_back(std::move(candidate)); + return HierarchyPlan(std::move(next), budget_); + } + + bool operator==(const HierarchyPlan& other) const { + return exact_identity() == other.exact_identity(); + } + + private: + static std::size_t checked_pair_count_(std::size_t children, std::size_t parents) { + if (parents != 0 && children > std::numeric_limits::max() / parents) + throw std::length_error("HierarchyPlan parent/child patch pair count exceeds size_t"); + return children * parents; + } + + void validate_() const { + if (levels_.empty()) + throw std::invalid_argument("HierarchyPlan requires level zero"); + if (levels_.size() > budget_.levels) + throw std::length_error("HierarchyPlan exceeds its explicit level budget"); + if (levels_.front().level() != 0) + throw std::invalid_argument("HierarchyPlan first level must be level zero"); + + std::size_t pair_count = 0; + for (std::size_t level_index = 1; level_index < levels_.size(); ++level_index) { + const LevelLayout& parent = levels_[level_index - 1]; + const LevelLayout& child = levels_[level_index]; + if (child.level() != static_cast(level_index)) + throw std::invalid_argument("HierarchyPlan levels must be consecutive and ordered"); + if (child.distribution().rank_space() != parent.distribution().rank_space()) + throw std::invalid_argument("HierarchyPlan levels must share one exact process space"); + if (child.domain() != refine_box(parent.domain(), child.ratio_from_parent())) + throw std::invalid_argument( + "HierarchyPlan child domain is not the anisotropic refinement of its parent"); + + const std::size_t current_pairs = + checked_pair_count_(child.patches().size(), parent.patches().size()); + if (pair_count > budget_.parent_child_patch_pairs || + current_pairs > budget_.parent_child_patch_pairs - pair_count) + throw std::length_error("HierarchyPlan exceeds its explicit parent/child pair budget"); + pair_count += current_pairs; + + for (const Box& fine_patch : child.patches().boxes()) { + const Box footprint = coarsen_box(fine_patch, child.ratio_from_parent()); + if (refine_box(footprint, child.ratio_from_parent()) != fine_patch) + throw std::invalid_argument( + "HierarchyPlan fine patches must contain complete anisotropic parent cells"); + mesh::ExactCellCount covered; + for (const Box& parent_patch : parent.patches().boxes()) + if (!covered.add(mesh::ExactCellCount::from_box(footprint.intersect(parent_patch)))) + throw std::overflow_error("HierarchyPlan parent coverage exceeds exact count capacity"); + if (covered != mesh::ExactCellCount::from_box(footprint)) + throw std::invalid_argument( + "HierarchyPlan fine patch footprint is not covered by the parent level"); + } + } + } + + std::vector> levels_{}; + HierarchyValidationBudget budget_{}; +}; + +} // namespace pops::amr::hierarchy::nd diff --git a/include/pops/amr/hierarchy/nd/level_layout.hpp b/include/pops/amr/hierarchy/nd/level_layout.hpp new file mode 100644 index 000000000..0a0bd155d --- /dev/null +++ b/include/pops/amr/hierarchy/nd/level_layout.hpp @@ -0,0 +1,161 @@ +/// @file +/// @brief Exact, immutable-by-value ND AMR level layout contract. + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace pops::amr::hierarchy::nd { + +template +using RefinementRatio = ::pops::amr::nd::RefinementRatio; + +namespace detail { + +inline int checked_index(std::int64_t value, const char* operation) { + if (value < std::numeric_limits::min() || value > std::numeric_limits::max()) + throw std::overflow_error(operation); + return static_cast(value); +} + +inline int floor_div(int numerator, int denominator) { + if (denominator <= 0) + throw std::invalid_argument("ND refinement ratios must be strictly positive"); + const int quotient = numerator / denominator; + const int remainder = numerator % denominator; + return remainder < 0 ? quotient - 1 : quotient; +} + +template +void validate_ratio(const std::type_identity_t>& ratio) { + for (int axis = 0; axis < Dim; ++axis) + if (ratio[axis] <= 0) + throw std::invalid_argument("ND refinement ratios must be strictly positive"); +} + +} // namespace detail + +/// Refine an inclusive box independently along every axis. +template +Box refine_box(const Box& box, const std::type_identity_t>& ratio) { + detail::validate_ratio(ratio); + if (box.empty()) + return box; + Box result{}; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = detail::checked_index(static_cast(box.lo[axis]) * ratio[axis], + "refine_box lower bound exceeds signed index range"); + result.hi[axis] = detail::checked_index( + static_cast(box.hi[axis]) * ratio[axis] + ratio[axis] - 1, + "refine_box upper bound exceeds signed index range"); + } + return result; +} + +/// Coarsen an inclusive box with mathematical floor division on negative origins. +template +Box coarsen_box(const Box& box, const std::type_identity_t>& ratio) { + detail::validate_ratio(ratio); + if (box.empty()) + return box; + Box result{}; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = detail::floor_div(box.lo[axis], ratio[axis]); + result.hi[axis] = detail::floor_div(box.hi[axis], ratio[axis]); + } + return result; +} + +template +struct LevelLayoutIdentity { + int level = -1; + Box domain{}; + RefinementRatio ratio_from_parent{}; + std::vector> patches{}; + mesh::RankSpace rank_space{}; + mesh::DistributionMode distribution_mode = mesh::DistributionMode::replicated; + std::vector> owners{}; + + bool operator==(const LevelLayoutIdentity&) const = default; +}; + +/// A geometric level and its exact patch ownership. No field storage or execution state is owned. +template +class LevelLayout { + static_assert(Dim >= 1 && Dim <= 3, "LevelLayout only supports dimensions 1, 2, and 3"); + + public: + LevelLayout(int level, Box domain, mesh::BoxArray patches, + mesh::Distribution distribution, RefinementRatio ratio_from_parent, + mesh::BoxArrayValidationBudget validation_budget) + : level_(level), + domain_(domain), + patches_(std::move(patches)), + distribution_(std::move(distribution)), + ratio_from_parent_(ratio_from_parent) { + validate_(validation_budget); + } + + int level() const noexcept { return level_; } + const Box& domain() const noexcept { return domain_; } + const mesh::BoxArray& patches() const noexcept { return patches_; } + const mesh::Distribution& distribution() const noexcept { return distribution_; } + const RefinementRatio& ratio_from_parent() const noexcept { return ratio_from_parent_; } + + LevelLayoutIdentity exact_identity() const { + return LevelLayoutIdentity{level_, + domain_, + ratio_from_parent_, + patches_.boxes(), + distribution_.rank_space(), + distribution_.mode(), + distribution_.owners()}; + } + + bool operator==(const LevelLayout& other) const { + return exact_identity() == other.exact_identity(); + } + + private: + void validate_(mesh::BoxArrayValidationBudget budget) const { + if (level_ < 0) + throw std::invalid_argument("LevelLayout level must be non-negative"); + if (domain_.empty()) + throw std::invalid_argument("LevelLayout domain must be non-empty"); + if (patches_.empty()) + throw std::invalid_argument("LevelLayout must contain at least one patch"); + if (!distribution_.matches_layout(patches_)) + throw std::invalid_argument( + "LevelLayout distribution does not authenticate its patch layout"); + detail::validate_ratio(ratio_from_parent_); + if (level_ == 0) { + if (!ratio_from_parent_.is_identity()) + throw std::invalid_argument("LevelLayout level zero must use the identity ratio"); + if (!patches_.tiles_exactly(domain_, budget)) + throw std::invalid_argument("LevelLayout level zero patches must exactly tile the domain"); + } else { + if (!ratio_from_parent_.refines_any_axis()) + throw std::invalid_argument("a fine LevelLayout must refine at least one axis"); + if (!patches_.is_disjoint_within(domain_, budget)) + throw std::invalid_argument( + "a fine LevelLayout requires non-empty disjoint patches inside its domain"); + } + } + + int level_ = -1; + Box domain_{}; + mesh::BoxArray patches_{}; + mesh::Distribution distribution_{}; + RefinementRatio ratio_from_parent_{}; +}; + +} // namespace pops::amr::hierarchy::nd diff --git a/include/pops/amr/hierarchy/nd/tag_mask.hpp b/include/pops/amr/hierarchy/nd/tag_mask.hpp new file mode 100644 index 000000000..8ac1d01eb --- /dev/null +++ b/include/pops/amr/hierarchy/nd/tag_mask.hpp @@ -0,0 +1,262 @@ +/// @file +/// @brief Patch-tiled ND AMR tags with explicit local-storage budgets. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace pops::amr::hierarchy::nd { + +struct TagMaskBudget { + std::size_t global_patches = 0; + std::size_t owned_patches = 0; + std::size_t cells_per_patch = 0; + std::size_t owned_cells = 0; + std::size_t bytes = 0; + std::size_t identity_bytes = 0; + + bool operator==(const TagMaskBudget&) const = default; +}; + +template +struct PatchTagIdentity { + std::size_t global_patch = 0; + Box box{}; + std::vector tags{}; + + bool operator==(const PatchTagIdentity&) const = default; +}; + +template +struct TagShardIdentity { + Index local_rank{}; + std::vector> patches{}; + bool replicated_alias = false; + + bool operator==(const TagShardIdentity&) const = default; +}; + +template +struct TagMaskIdentity { + LevelLayoutIdentity level{}; + TagShardIdentity shard{}; + + bool operator==(const TagMaskIdentity&) const = default; +}; + +/// Stores one byte per cell only for patches visible to the selected rank coordinate. +template +class TagMask { + static_assert(Dim >= 1 && Dim <= 3, "TagMask only supports dimensions 1, 2, and 3"); + + public: + struct PatchTags { + std::size_t global_patch = 0; + Box box{}; + std::vector tags{}; + + bool operator==(const PatchTags&) const = default; + }; + + TagMask(const LevelLayout& level, Index local_rank, TagMaskBudget budget) + : local_rank_(local_rank) { + const mesh::Distribution& distribution = level.distribution(); + if (!distribution.rank_space().contains(local_rank_)) + throw std::out_of_range("TagMask rank coordinate is outside the level process space"); + if (level.patches().size() > budget.global_patches) + throw std::length_error("TagMask exceeds its explicit global-patch metadata budget"); + std::size_t identity_bytes = checked_product_(level.patches().size(), sizeof(Box)); + identity_bytes = checked_sum_( + identity_bytes, checked_product_(distribution.owners().size(), sizeof(Index))); + if (identity_bytes > budget.identity_bytes) + throw std::length_error("TagMask exceeds its explicit identity-copy byte budget"); + + const std::size_t expected_local = + distribution.replicated() + ? level.patches().size() + : static_cast(std::count(distribution.owners().begin(), + distribution.owners().end(), local_rank_)); + if (expected_local > budget.owned_patches) + throw std::length_error("TagMask exceeds its explicit owned-patch budget"); + const std::vector local = distribution.local_box_indices(local_rank_); + + std::size_t cells = 0; + for (const std::size_t global_patch : local) { + const std::int64_t exact_cells = level.patches()[global_patch].numPts(); + if (exact_cells < 0 || + static_cast(exact_cells) > + static_cast(std::numeric_limits::max())) + throw std::length_error("TagMask patch cell count exceeds size_t"); + const std::size_t patch_cells = static_cast(exact_cells); + if (patch_cells > budget.cells_per_patch) + throw std::length_error("TagMask exceeds its explicit per-patch cell budget"); + if (cells > budget.owned_cells || patch_cells > budget.owned_cells - cells) + throw std::length_error("TagMask exceeds its explicit owned-cell budget"); + cells += patch_cells; + } + if (cells > budget.bytes) + throw std::length_error("TagMask exceeds its explicit byte budget"); + identity_bytes = + checked_sum_(identity_bytes, checked_product_(local.size(), sizeof(PatchTagIdentity))); + identity_bytes = checked_sum_(identity_bytes, cells); + if (identity_bytes > budget.identity_bytes) + throw std::length_error("TagMask exceeds its explicit identity-copy byte budget"); + + level_identity_ = level.exact_identity(); + patches_.reserve(local.size()); + for (const std::size_t global_patch : local) { + const Box& box = level.patches()[global_patch]; + patches_.push_back(PatchTags{ + global_patch, box, std::vector(static_cast(box.numPts()))}); + } + } + + const LevelLayoutIdentity& level_identity() const noexcept { return level_identity_; } + const Index& local_rank() const noexcept { return local_rank_; } + const std::vector& patches() const noexcept { return patches_; } + std::size_t local_patch_count() const noexcept { return patches_.size(); } + + std::size_t local_cell_count() const noexcept { + std::size_t total = 0; + for (const PatchTags& patch : patches_) + total += patch.tags.size(); + return total; + } + + std::size_t count() const noexcept { + std::size_t total = 0; + for (const PatchTags& patch : patches_) + for (const std::uint8_t value : patch.tags) + total += value != 0 ? 1u : 0u; + return total; + } + + void set(std::size_t global_patch, const Index& index, bool tagged = true) { + PatchTags& patch = require_patch_(global_patch); + patch.tags.at(linear_index_(patch.box, index)) = tagged ? std::uint8_t{1} : std::uint8_t{0}; + } + + void set(const Index& index, bool tagged = true) { + for (PatchTags& patch : patches_) + if (patch.box.contains(index)) { + patch.tags.at(linear_index_(patch.box, index)) = tagged ? std::uint8_t{1} : std::uint8_t{0}; + return; + } + throw std::out_of_range("TagMask cell is not in a patch visible to this rank"); + } + + bool tagged(std::size_t global_patch, const Index& index) const { + const PatchTags& patch = require_patch_(global_patch); + return patch.tags.at(linear_index_(patch.box, index)) != 0; + } + + template + void for_each_tagged_in(const Box& region, Function&& function) const { + for_each_cell_in(region, [&](const Index& index, bool is_tagged) { + if (is_tagged) + function(index); + }); + } + + template + void for_each_cell_in(const Box& region, Function&& function) const { + if (region.empty()) + return; + for (const PatchTags& patch : patches_) { + const Box overlap = patch.box.intersect(region); + if (overlap.empty()) + continue; + for_each_index_(overlap, [&](const Index& index) { + function(index, patch.tags[linear_index_(patch.box, index)] != 0); + }); + } + } + + TagMaskIdentity exact_identity() const { + return TagMaskIdentity{level_identity_, shard_identity()}; + } + + TagShardIdentity shard_identity() const { + TagShardIdentity identity{local_rank_, {}, false}; + identity.patches.reserve(patches_.size()); + for (const PatchTags& patch : patches_) + identity.patches.push_back(PatchTagIdentity{patch.global_patch, patch.box, patch.tags}); + return identity; + } + + bool operator==(const TagMask& other) const { + return level_identity_ == other.level_identity_ && local_rank_ == other.local_rank_ && + patches_ == other.patches_; + } + + private: + static std::size_t checked_product_(std::size_t left, std::size_t right) { + if (right != 0 && left > std::numeric_limits::max() / right) + throw std::length_error("TagMask identity byte count exceeds size_t"); + return left * right; + } + + static std::size_t checked_sum_(std::size_t left, std::size_t right) { + if (right > std::numeric_limits::max() - left) + throw std::length_error("TagMask identity byte count exceeds size_t"); + return left + right; + } + + static std::size_t linear_index_(const Box& box, const Index& index) { + if (!box.contains(index)) + throw std::out_of_range("TagMask cell is outside the selected patch"); + std::size_t linear = 0; + std::size_t stride = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t offset = + static_cast(static_cast(index[axis]) - box.lo[axis]); + linear += offset * stride; + stride *= static_cast(box.length(axis)); + } + return linear; + } + + template + static void for_each_index_(const Box& box, Function&& function) { + const std::size_t count = static_cast(box.numPts()); + for (std::size_t ordinal = 0; ordinal < count; ++ordinal) { + Index index{}; + std::size_t quotient = ordinal; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t length = static_cast(box.length(axis)); + index[axis] = static_cast(static_cast(box.lo[axis]) + + static_cast(quotient % length)); + quotient /= length; + } + function(index); + } + } + + PatchTags& require_patch_(std::size_t global_patch) { + for (PatchTags& patch : patches_) + if (patch.global_patch == global_patch) + return patch; + throw std::out_of_range("TagMask patch is not visible to this rank"); + } + + const PatchTags& require_patch_(std::size_t global_patch) const { + for (const PatchTags& patch : patches_) + if (patch.global_patch == global_patch) + return patch; + throw std::out_of_range("TagMask patch is not visible to this rank"); + } + + LevelLayoutIdentity level_identity_{}; + Index local_rank_{}; + std::vector patches_{}; +}; + +} // namespace pops::amr::hierarchy::nd diff --git a/include/pops/amr/nd/refinement_ratio.hpp b/include/pops/amr/nd/refinement_ratio.hpp new file mode 100644 index 000000000..4c9db8704 --- /dev/null +++ b/include/pops/amr/nd/refinement_ratio.hpp @@ -0,0 +1,100 @@ +/// @file +/// @brief One validated compile-time-dimensional AMR refinement-ratio authority. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace pops::amr::nd { + +/// A positive, immutable-by-interface refinement ratio for dimensions 1, 2, and 3. +/// +/// The identity ratio is a valid hierarchy property (and is required at level zero). Operations +/// that require a true coarse/fine transition must reject it at their own preparation boundary. +template +class RefinementRatio { + public: + static_assert(Dim >= 1 && Dim <= 3, "ND refinement ratios only support dimensions 1, 2, and 3"); + + POPS_HD constexpr RefinementRatio() { + for (int axis = 0; axis < Dim; ++axis) + values_[axis] = 1; + } + + template > && ...) && + (!std::is_same_v, bool> && ...), + int> = 0> + explicit RefinementRatio(Ratios... ratios) { + const std::array requested{checked_component(ratios)...}; + initialize(requested); + } + + explicit RefinementRatio(const std::array& ratios) { + std::array requested{}; + for (int axis = 0; axis < Dim; ++axis) + requested[static_cast(axis)] = ratios[static_cast(axis)]; + initialize(requested); + } + + POPS_HD constexpr int operator[](int axis) const { return values_[axis]; } + POPS_HD constexpr std::int64_t child_count() const { return child_count_; } + + POPS_HD constexpr bool refines_any_axis() const { + for (int axis = 0; axis < Dim; ++axis) + if (values_[axis] > 1) + return true; + return false; + } + + POPS_HD constexpr bool is_identity() const { return !refines_any_axis(); } + + POPS_HD constexpr bool operator==(const RefinementRatio& other) const { + for (int axis = 0; axis < Dim; ++axis) + if (values_[axis] != other.values_[axis]) + return false; + return true; + } + + private: + template + static std::int64_t checked_component(T value) { + if (std::cmp_less(value, 1) || std::cmp_greater(value, std::numeric_limits::max())) + throw std::invalid_argument( + "ND refinement ratio components must lie in the positive signed-index range"); + return static_cast(value); + } + + void initialize(const std::array& requested) { + std::int64_t children = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t value = requested[static_cast(axis)]; + if (value < 1 || value > std::numeric_limits::max()) + throw std::invalid_argument( + "ND refinement ratio components must lie in the positive signed-index range"); + if (children > std::numeric_limits::max() / value) + throw std::overflow_error("ND refinement ratio child count exceeds int64_t"); + values_[axis] = static_cast(value); + children *= value; + } + child_count_ = children; + } + + int values_[Dim]{}; + std::int64_t child_count_ = 1; +}; + +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); + +} // namespace pops::amr::nd diff --git a/include/pops/amr/reflux/nd/face_flux_ledger.hpp b/include/pops/amr/reflux/nd/face_flux_ledger.hpp new file mode 100644 index 000000000..b8660ecef --- /dev/null +++ b/include/pops/amr/reflux/nd/face_flux_ledger.hpp @@ -0,0 +1,379 @@ +/// @file +/// @brief Transaction-local, axis-qualified AMR face-flux ledger for dimensions 1..3. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::amr::reflux::nd { + +/// Spatial centering is part of the persisted identity. This ledger accepts only face-centered +/// numerical fluxes; Cell exists so attempts to route source terms fail explicitly at the boundary. +enum class FaceLedgerCentering : std::uint8_t { Face = 0, Cell = 1 }; + +enum class FaceLedgerRole : std::uint8_t { Coarse = 0, Fine = 1 }; + +/// Sources alter a cell volume and are never conservative face exchanges. Keeping Source as an +/// explicit rejected value prevents a generic producer from silently recording it as a flux. +enum class FaceLedgerContribution : std::uint8_t { NumericalFlux = 0, Source = 1 }; + +struct LevelTransition { + int coarse = 0; + int fine = 1; + + constexpr bool operator==(const LevelTransition&) const = default; +}; + +namespace detail { + +inline auto clock_coordinate(const ClockStamp& stamp) { + return std::tuple{stamp.level, stamp.macro_step, stamp.phase.numerator, stamp.phase.denominator}; +} + +template +bool index_less(const Index& left, const Index& right) { + for (int axis = 0; axis < Dim; ++axis) { + if (left[axis] != right[axis]) + return left[axis] < right[axis]; + } + return false; +} + +template +bool index_equal(const Index& left, const Index& right) { + return !index_less(left, right) && !index_less(right, left); +} + +inline int checked_axis(int axis, int dimension) { + if (axis < 0 || axis >= dimension) + throw std::invalid_argument("ND face-flux ledger axis is outside its compile-time dimension"); + return axis; +} + +} // namespace detail + +/// Complete identity of one stage-local face-flux fragment. `face` is expressed in the index +/// space selected by role, while `coarse_face` is the common coarse-grid aggregation identity. +/// Exact clock coordinates, stage and attempt prevent contributions from retries or graph stages +/// from aliasing even when their floating-point times happen to compare equal. +template +struct FaceFluxFragmentKey { + static_assert(Dim >= 1 && Dim <= 3, "ND face-flux keys support dimensions 1..3"); + + std::string owner; + std::string state; + LevelTransition levels{}; + FaceLedgerCentering centering = FaceLedgerCentering::Face; + int axis = 0; + Index face{}; + Index coarse_face{}; + ClockStamp clock{}; + std::string stage; + std::uint64_t attempt = 0; + FaceLedgerRole role = FaceLedgerRole::Coarse; + FaceLedgerContribution contribution = FaceLedgerContribution::NumericalFlux; + + friend bool operator<(const FaceFluxFragmentKey& left, const FaceFluxFragmentKey& right) { + const auto left_prefix = std::tie(left.owner, left.state, left.levels.coarse, left.levels.fine, + left.centering, left.axis); + const auto right_prefix = std::tie(right.owner, right.state, right.levels.coarse, + right.levels.fine, right.centering, right.axis); + if (left_prefix != right_prefix) + return left_prefix < right_prefix; + if (!detail::index_equal(left.coarse_face, right.coarse_face)) + return detail::index_less(left.coarse_face, right.coarse_face); + if (!detail::index_equal(left.face, right.face)) + return detail::index_less(left.face, right.face); + const auto left_clock = detail::clock_coordinate(left.clock); + const auto right_clock = detail::clock_coordinate(right.clock); + if (left_clock != right_clock) + return left_clock < right_clock; + return std::tie(left.stage, left.attempt, left.role, left.contribution) < + std::tie(right.stage, right.attempt, right.role, right.contribution); + } +}; + +/// Metric and temporal measure of one physical flux density sample. The exact substep interval +/// authenticates temporal coverage; `substep_duration` is its physical duration. Geometry is +/// multiplied here exactly once because metric reflux compares integrated transport across coarse +/// and fine faces. +struct FaceFluxFragmentMeasure { + Rational stage_weight{1, 1}; + Rational substep_begin{0, 1}; + Rational substep_end{0, 1}; + double substep_duration = 0.0; + double face_measure = 0.0; +}; + +inline double weighted_face_flux_scale(const FaceFluxFragmentMeasure& measure) { + return measure.stage_weight.value() * measure.substep_duration * measure.face_measure; +} + +template +void validate_face_flux_fragment(const FaceFluxFragmentKey& key, + const FaceFluxFragmentMeasure& measure) { + if (key.owner.empty() || key.state.empty() || key.stage.empty()) + throw std::invalid_argument("ND face-flux identity requires owner, state, and stage"); + if (key.levels.coarse < 0 || key.levels.coarse == std::numeric_limits::max() || + key.levels.fine != key.levels.coarse + 1) + throw std::invalid_argument("ND face-flux identity requires one adjacent level transition"); + detail::checked_axis(key.axis, Dim); + if (key.centering != FaceLedgerCentering::Face) + throw std::invalid_argument("ND face-flux ledger accepts only face-centered contributions"); + if (key.contribution != FaceLedgerContribution::NumericalFlux) + throw std::invalid_argument("ND face-flux ledger explicitly excludes source contributions"); + + int clock_level = -1; + switch (key.role) { + case FaceLedgerRole::Coarse: + clock_level = key.levels.coarse; + break; + case FaceLedgerRole::Fine: + clock_level = key.levels.fine; + break; + default: + throw std::invalid_argument("ND face-flux identity has an invalid coarse/fine role"); + } + if (key.clock.level != clock_level || key.clock.macro_step < 0 || + !std::isfinite(key.clock.physical_time)) + throw std::invalid_argument("ND face-flux clock is not qualified by its role and level"); + if (key.clock.phase.denominator <= 0) + throw std::invalid_argument("ND face-flux clock phase is not a canonical exact rational"); + if (Rational{key.clock.phase.numerator, key.clock.phase.denominator} != key.clock.phase) + throw std::invalid_argument("ND face-flux clock phase must retain canonical exact form"); + + const double stage_weight = measure.stage_weight.value(); + if (measure.stage_weight.denominator <= 0 || measure.substep_begin.denominator <= 0 || + measure.substep_end.denominator <= 0 || !std::isfinite(stage_weight) || + !(measure.substep_begin < measure.substep_end) || key.clock.phase < measure.substep_begin || + measure.substep_end < key.clock.phase || !(measure.substep_duration > 0.0) || + !std::isfinite(measure.substep_duration) || !(measure.face_measure > 0.0) || + !std::isfinite(measure.face_measure)) + throw std::invalid_argument( + "ND face-flux measure requires finite stage, time, and positive metric weights"); + if (Rational{measure.stage_weight.numerator, measure.stage_weight.denominator} != + measure.stage_weight) + throw std::invalid_argument("ND face-flux stage weight must retain canonical exact form"); + if (Rational{measure.substep_begin.numerator, measure.substep_begin.denominator} != + measure.substep_begin || + Rational{measure.substep_end.numerator, measure.substep_end.denominator} != + measure.substep_end) + throw std::invalid_argument("ND face-flux substep interval must retain canonical exact form"); + if (!std::isfinite(weighted_face_flux_scale(measure))) + throw std::invalid_argument("ND face-flux weighted metric-time scale is not finite"); +} + +template +struct FaceFluxFragment { + FaceFluxFragmentKey key; + FaceFluxFragmentMeasure measure; + Payload payload; +}; + +struct FaceFluxLedgerBudget { + std::size_t max_pending_entries = 0; + std::size_t max_published_entries = 0; + std::size_t max_transaction_depth = 0; +}; + +/// One host-side ledger per normal axis. Pending fragments remain transaction-local and are not +/// visible through published_entries(). The outer commit first builds a complete candidate copy, +/// then swaps it into place, preserving the accepted ledger if allocation or payload copy fails. +/// All retained work is explicitly bounded, and accepted attempts can be discarded after reflux. +template +class TransactionalFaceFluxLedger { + public: + static_assert(Dim >= 1 && Dim <= 3, "ND face-flux ledgers support dimensions 1..3"); + static_assert(std::is_copy_constructible_v, + "transactional ND face-flux payloads must support atomic commit copies"); + + using Entry = FaceFluxFragment; + + explicit TransactionalFaceFluxLedger(FaceFluxLedgerBudget budget) : budget_(budget) { + if (budget_.max_pending_entries == 0 || budget_.max_published_entries == 0 || + budget_.max_transaction_depth == 0) + throw std::invalid_argument("ND face-flux ledger budgets must be strictly positive"); + } + + void begin(std::uint64_t attempt) { + const bool outer = !active_attempt_.has_value(); + if (!outer) { + if (*active_attempt_ != attempt) + throw std::invalid_argument( + "nested ND face-flux transaction must retain the outer attempt identity"); + } else { + if (last_closed_attempt_.has_value() && attempt <= *last_closed_attempt_) + throw std::invalid_argument("ND face-flux attempt identities must increase monotonically"); + } + + if (savepoints_.size() >= budget_.max_transaction_depth) + throw std::length_error("ND face-flux transaction depth exceeds its prepared budget"); + Savepoint savepoint{}; + for (int axis = 0; axis < Dim; ++axis) + savepoint.pending_sizes[static_cast(axis)] = + pending_[static_cast(axis)].size(); + savepoints_.push_back(savepoint); + if (outer) + active_attempt_ = attempt; + } + + void commit() { + require_transaction_("commit"); + if (savepoints_.size() > 1) { + savepoints_.pop_back(); + return; + } + + const std::size_t published_count = published_size(); + const std::size_t pending_count = pending_size(); + if (published_count > budget_.max_published_entries || + pending_count > budget_.max_published_entries - published_count) + throw std::length_error("ND face-flux publication exceeds its prepared budget"); + + auto candidate = published_; + for (int axis = 0; axis < Dim; ++axis) { + auto& destination = candidate[static_cast(axis)]; + const auto& source = pending_[static_cast(axis)]; + destination.reserve(destination.size() + source.size()); + for (const Entry& entry : source) + destination.push_back(entry); + } + published_.swap(candidate); + for (auto& entries : pending_) + entries.clear(); + close_outer_transaction_(); + } + + void rollback() { + require_transaction_("rollback"); + const Savepoint savepoint = savepoints_.back(); + for (int axis = 0; axis < Dim; ++axis) + pending_[static_cast(axis)].resize( + savepoint.pending_sizes[static_cast(axis)]); + savepoints_.pop_back(); + if (savepoints_.empty()) { + last_closed_attempt_ = active_attempt_; + active_attempt_.reset(); + } + } + + void clear() { + if (in_transaction()) + throw std::runtime_error("cannot clear an active ND face-flux ledger transaction"); + for (auto& entries : pending_) + entries.clear(); + for (auto& entries : published_) + entries.clear(); + last_closed_attempt_.reset(); + } + + void accumulate(FaceFluxFragmentKey key, FaceFluxFragmentMeasure measure, Payload payload) { + require_transaction_("accumulation"); + if (key.attempt != *active_attempt_) + throw std::invalid_argument("ND face-flux fragment uses a stale attempt identity"); + validate_face_flux_fragment(key, measure); + if (pending_size() >= budget_.max_pending_entries) + throw std::length_error("ND face-flux pending entries exceed their prepared budget"); + const std::size_t axis = static_cast(key.axis); + if (contains_identity_(pending_[axis], key) || contains_identity_(published_[axis], key)) + throw std::runtime_error( + "ND face-flux transaction contains a duplicate clock-stage face identity"); + pending_[axis].push_back({std::move(key), measure, std::move(payload)}); + } + + bool in_transaction() const noexcept { return !savepoints_.empty(); } + std::size_t transaction_depth() const noexcept { return savepoints_.size(); } + std::optional active_attempt() const noexcept { return active_attempt_; } + + std::size_t pending_size() const noexcept { return total_size_(pending_); } + std::size_t published_size() const noexcept { return total_size_(published_); } + + const std::vector& pending_entries(int axis) const { + return pending_[static_cast(detail::checked_axis(axis, Dim))]; + } + + const std::vector& published_entries(int axis) const { + return published_[static_cast(detail::checked_axis(axis, Dim))]; + } + + std::size_t discard_published_attempt(std::uint64_t attempt) { + if (in_transaction()) + throw std::runtime_error( + "cannot discard published ND face fluxes during an active transaction"); + std::array, Dim> candidate; + std::size_t removed = 0; + for (int axis = 0; axis < Dim; ++axis) { + const auto& source = published_[static_cast(axis)]; + auto& destination = candidate[static_cast(axis)]; + destination.reserve(source.size()); + for (const Entry& entry : source) { + if (entry.key.attempt == attempt) + ++removed; + else + destination.push_back(entry); + } + } + published_.swap(candidate); + return removed; + } + + private: + struct Savepoint { + std::array pending_sizes{}; + }; + + static bool same_identity_(const FaceFluxFragmentKey& left, + const FaceFluxFragmentKey& right) { + return !(left < right) && !(right < left); + } + + static bool contains_identity_(const std::vector& entries, + const FaceFluxFragmentKey& key) { + for (const Entry& entry : entries) + if (same_identity_(entry.key, key)) + return true; + return false; + } + + static std::size_t total_size_(const std::array, Dim>& entries) noexcept { + std::size_t result = 0; + for (const auto& axis : entries) + result += axis.size(); + return result; + } + + void require_transaction_(const char* operation) const { + if (!in_transaction()) + throw std::runtime_error(std::string("ND face-flux ledger ") + operation + + " requires an active transaction"); + } + + void close_outer_transaction_() { + savepoints_.pop_back(); + last_closed_attempt_ = active_attempt_; + active_attempt_.reset(); + } + + std::array, Dim> pending_{}; + std::array, Dim> published_{}; + std::vector savepoints_; + std::optional active_attempt_; + std::optional last_closed_attempt_; + FaceFluxLedgerBudget budget_; +}; + +} // namespace pops::amr::reflux::nd diff --git a/include/pops/amr/reflux/nd/metric_reflux.hpp b/include/pops/amr/reflux/nd/metric_reflux.hpp new file mode 100644 index 000000000..9041a36b9 --- /dev/null +++ b/include/pops/amr/reflux/nd/metric_reflux.hpp @@ -0,0 +1,437 @@ +/// @file +/// @brief Metric coarse/fine face matching and conservative reflux for dimensions 1..3. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::amr::reflux::nd { + +/// Affine relation between the coarse and fine face index spaces. The same mapping applies to +/// normal face coordinates and to tangential cell coordinates; the normal fine face has no child +/// offset, while tangential coordinates span their full anisotropic ratio. +template +struct FaceRefinementMapping { + Index coarse_origin{}; + Index fine_origin{}; + + constexpr bool operator==(const FaceRefinementMapping&) const = default; +}; + +/// Identity and exact macro-step window of the coarse face whose accepted fragments are reconciled. +template +struct CoarseFaceRefluxKey { + std::string owner; + std::string state; + LevelTransition levels{}; + FaceLedgerCentering centering = FaceLedgerCentering::Face; + int axis = 0; + Index coarse_face{}; + std::uint64_t attempt = 0; + std::int64_t macro_step = 0; + Rational window_begin{0, 1}; + Rational window_end{1, 1}; +}; + +struct MetricRefluxBudget { + std::size_t max_fine_faces = 0; + std::size_t max_published_entries = 0; + std::size_t max_clock_stage_slices = 0; +}; + +template +struct MetricFaceReflux { + Payload coarse_integrated{}; + Payload fine_integrated{}; + Payload mismatch{}; ///< fine_integrated - coarse_integrated in canonical positive-axis units + double coarse_weighted_measure = 0.0; + double fine_weighted_measure = 0.0; + std::size_t fine_face_count = 0; +}; + +enum class CoarseCellFaceSide : std::uint8_t { Lower = 0, Upper = 1 }; + +namespace detail { + +template +std::array coordinate_array(const Index& index) { + std::array result{}; + for (int axis = 0; axis < Dim; ++axis) + result[static_cast(axis)] = index[axis]; + return result; +} + +inline int checked_fine_face_coordinate(std::int64_t value) { + if (value < std::numeric_limits::min() || value > std::numeric_limits::max()) + throw std::overflow_error("ND metric reflux face mapping exceeds the signed index range"); + return static_cast(value); +} + +inline std::int64_t checked_fine_face_add(std::int64_t left, std::int64_t right) { + if ((right > 0 && left > std::numeric_limits::max() - right) || + (right < 0 && left < std::numeric_limits::min() - right)) + throw std::overflow_error("ND metric reflux face mapping exceeds int64_t"); + return left + right; +} + +template +void validate_reflux_key(const CoarseFaceRefluxKey& key) { + if (key.owner.empty() || key.state.empty()) + throw std::invalid_argument("ND metric reflux requires qualified owner and state identities"); + if (key.levels.coarse < 0 || key.levels.coarse == std::numeric_limits::max() || + key.levels.fine != key.levels.coarse + 1) + throw std::invalid_argument("ND metric reflux requires one adjacent level transition"); + checked_axis(key.axis, Dim); + if (key.centering != FaceLedgerCentering::Face) + throw std::invalid_argument("ND metric reflux accepts only face-centered flux identities"); + if (key.macro_step < 0 || key.window_begin.denominator <= 0 || key.window_end.denominator <= 0 || + !(key.window_begin < key.window_end) || + Rational{key.window_begin.numerator, key.window_begin.denominator} != key.window_begin || + Rational{key.window_end.numerator, key.window_end.denominator} != key.window_end) + throw std::invalid_argument("ND metric reflux requires one canonical exact clock window"); +} + +inline void validate_reflux_budget(const MetricRefluxBudget& budget) { + if (budget.max_fine_faces == 0 || budget.max_published_entries == 0 || + budget.max_clock_stage_slices == 0) + throw std::invalid_argument("ND metric reflux budgets must be strictly positive"); +} + +template +bool matches_reflux_key(const FaceFluxFragmentKey& fragment, + const CoarseFaceRefluxKey& query) { + return fragment.owner == query.owner && fragment.state == query.state && + fragment.levels == query.levels && fragment.centering == query.centering && + fragment.axis == query.axis && fragment.coarse_face == query.coarse_face && + fragment.attempt == query.attempt && fragment.clock.macro_step == query.macro_step && + !(fragment.clock.phase < query.window_begin) && !(query.window_end < fragment.clock.phase); +} + +using StageSlice = + std::tuple, std::string>; + +struct TemporalSliceMeasure { + Rational stage_weight{0, 1}; + Rational substep_begin{0, 1}; + Rational substep_end{0, 1}; + double substep_duration = 0.0; +}; + +inline StageSlice stage_slice(const ClockStamp& clock, const std::string& stage) { + return {clock_coordinate(clock), stage}; +} + +inline void register_temporal_slice(std::map& slices, + const StageSlice& slice, const FaceFluxFragmentMeasure& measure, + std::size_t total_slice_count, + const MetricRefluxBudget& budget) { + const TemporalSliceMeasure candidate{measure.stage_weight, measure.substep_begin, + measure.substep_end, measure.substep_duration}; + const auto existing = slices.find(slice); + if (existing != slices.end()) { + if (existing->second.stage_weight != candidate.stage_weight || + existing->second.substep_begin != candidate.substep_begin || + existing->second.substep_end != candidate.substep_end || + existing->second.substep_duration != candidate.substep_duration) + throw std::runtime_error( + "ND metric reflux clock-stage faces disagree on their temporal measure"); + return; + } + if (total_slice_count >= budget.max_clock_stage_slices) + throw std::length_error("ND metric reflux clock-stage slices exceed their prepared budget"); + slices.emplace(slice, candidate); +} + +struct ExactSubstep { + Rational begin{0, 1}; + Rational end{0, 1}; + + friend bool operator<(const ExactSubstep& left, const ExactSubstep& right) { + return left.begin == right.begin ? left.end < right.end : left.begin < right.begin; + } +}; + +struct SubstepQuadrature { + Rational stage_weight_sum{0, 1}; + double duration = 0.0; +}; + +inline bool roundoff_equal(double left, double right, std::size_t operations) { + if (left == right) + return true; + if (!std::isfinite(left) || !std::isfinite(right)) + return false; + const double scale = std::max(std::abs(left), std::abs(right)); + const double tolerance = 128.0 * std::numeric_limits::epsilon() * scale * + static_cast(std::max(operations, 1)); + return std::abs(left - right) <= tolerance; +} + +struct AuthenticatedWindow { + double duration = 0.0; + double duration_per_phase = 0.0; + std::size_t substep_count = 0; +}; + +inline AuthenticatedWindow authenticated_window( + const std::map& slices, Rational window_begin, + Rational window_end) { + std::map substeps; + for (const auto& [slice, measure] : slices) { + (void)slice; + const ExactSubstep interval{measure.substep_begin, measure.substep_end}; + auto [position, inserted] = + substeps.emplace(interval, SubstepQuadrature{Rational{0, 1}, measure.substep_duration}); + if (!inserted && position->second.duration != measure.substep_duration) + throw std::runtime_error( + "ND metric reflux stages disagree on their physical substep duration"); + position->second.stage_weight_sum = position->second.stage_weight_sum + measure.stage_weight; + } + + Rational cursor = window_begin; + AuthenticatedWindow result; + for (const auto& [interval, quadrature] : substeps) { + if (interval.begin != cursor || !(interval.begin < interval.end)) + throw std::runtime_error( + "ND metric reflux substeps do not form a contiguous exact clock partition"); + if (quadrature.stage_weight_sum != Rational{1, 1}) + throw std::runtime_error("ND metric reflux stage weights do not close one accepted substep"); + const double phase_span = (interval.end - interval.begin).value(); + if (!(phase_span > 0.0) || !std::isfinite(phase_span)) + throw std::overflow_error("ND metric reflux physical clock rate is not finite"); + const double duration_per_phase = quadrature.duration / phase_span; + if (!std::isfinite(duration_per_phase)) + throw std::overflow_error("ND metric reflux physical clock rate is not finite"); + if (result.substep_count == 0) + result.duration_per_phase = duration_per_phase; + else if (!roundoff_equal(result.duration_per_phase, duration_per_phase, + result.substep_count + 1)) + throw std::runtime_error("ND metric reflux substeps disagree on their physical clock rate"); + cursor = interval.end; + result.duration += quadrature.duration; + ++result.substep_count; + if (!std::isfinite(result.duration)) + throw std::overflow_error("ND metric reflux physical window duration is not finite"); + } + if (cursor != window_end) + throw std::runtime_error( + "ND metric reflux substeps do not cover the complete exact clock window"); + return result; +} + +template +void validate_temporal_coverage(const CoarseFaceRefluxKey& key, + const std::map& coarse_slices, + const std::map& fine_slices) { + const AuthenticatedWindow coarse = + authenticated_window(coarse_slices, key.window_begin, key.window_end); + const AuthenticatedWindow fine = + authenticated_window(fine_slices, key.window_begin, key.window_end); + const std::size_t operations = coarse_slices.size() + fine_slices.size(); + if (!roundoff_equal(coarse.duration, fine.duration, operations) || + !roundoff_equal(coarse.duration_per_phase, fine.duration_per_phase, operations)) + throw std::runtime_error( + "ND metric reflux coarse and fine physical clocks do not cover the same window"); +} + +template +std::set> expected_fine_face_set( + const CoarseFaceRefluxKey& key, const transfer::nd::RefinementRatio& ratio, + const FaceRefinementMapping& mapping, const MetricRefluxBudget& budget) { + validate_reflux_budget(budget); + if (!ratio.refines_any_axis()) + throw std::invalid_argument( + "ND metric reflux requires a non-identity inter-level refinement ratio"); + std::size_t fine_face_count = 1; + for (int direction = 0; direction < Dim; ++direction) { + if (direction == key.axis) + continue; + const std::size_t axis_faces = static_cast(ratio[direction]); + if (axis_faces > std::numeric_limits::max() / fine_face_count) + throw std::length_error("ND metric reflux tangential face product exceeds size_t"); + fine_face_count *= axis_faces; + } + if (fine_face_count > budget.max_fine_faces) + throw std::length_error("ND metric reflux tangential face product exceeds its prepared budget"); + Index base{}; + for (int direction = 0; direction < Dim; ++direction) { + const std::int64_t relative = + static_cast(key.coarse_face[direction]) - mapping.coarse_origin[direction]; + if (relative > std::numeric_limits::max() / ratio[direction] || + relative < std::numeric_limits::min() / ratio[direction]) + throw std::overflow_error("ND metric reflux face mapping exceeds int64_t"); + const std::int64_t scaled = relative * ratio[direction]; + const std::int64_t fine = checked_fine_face_add(mapping.fine_origin[direction], scaled); + base[direction] = checked_fine_face_coordinate(fine); + } + + std::set> result; + Index child{}; + for (;;) { + Index fine = base; + for (int direction = 0; direction < Dim; ++direction) + if (direction != key.axis) + fine[direction] = checked_fine_face_coordinate(static_cast(base[direction]) + + child[direction]); + result.insert(coordinate_array(fine)); + + int direction = 0; + for (; direction < Dim; ++direction) { + if (direction == key.axis) + continue; + ++child[direction]; + if (child[direction] < ratio[direction]) + break; + child[direction] = 0; + } + if (direction == Dim) + break; + } + return result; +} + +template +void require_complete_slices(const std::map>>& slices, + const std::set>& expected, const char* role) { + if (slices.empty()) + throw std::runtime_error(std::string("ND metric reflux has no published ") + role + + " face fragments"); + for (const auto& [slice, faces] : slices) { + (void)slice; + if (faces != expected) + throw std::runtime_error(std::string("ND metric reflux has an incomplete ") + role + + " tangential face product in one clock-stage slice"); + } +} + +} // namespace detail + +/// Enumerate the exact product of tangential fine faces covering one coarse face. In 1D the +/// tangential product is one; in 2D it is the ratio of the other axis; in 3D it is the product of +/// both other-axis ratios. The normal-axis ratio changes only the normal coordinate mapping. +template +std::vector> fine_faces_for_coarse_face(const CoarseFaceRefluxKey& key, + const transfer::nd::RefinementRatio& ratio, + const FaceRefinementMapping& mapping, + const MetricRefluxBudget& budget) { + detail::validate_reflux_key(key); + const auto expected = detail::expected_fine_face_set(key, ratio, mapping, budget); + std::vector> result; + result.reserve(expected.size()); + for (const auto& coordinate : expected) { + Index face{}; + for (int axis = 0; axis < Dim; ++axis) + face[axis] = coordinate[static_cast(axis)]; + result.push_back(face); + } + return result; +} + +/// Integrate every accepted coarse and fine stage fragment using its exact rational stage weight, +/// authored substep duration and physical face measure. Fine faces must form the complete +/// tangential product for every clock-stage slice. Pending/rejected fragments are never observed. +template +MetricFaceReflux metric_reflux(const TransactionalFaceFluxLedger& ledger, + const CoarseFaceRefluxKey& key, + const transfer::nd::RefinementRatio& ratio, + const FaceRefinementMapping& mapping, + const MetricRefluxBudget& budget, Axpy&& axpy) { + detail::validate_reflux_key(key); + detail::validate_reflux_budget(budget); + if (ledger.published_size() > budget.max_published_entries) + throw std::length_error("ND metric reflux published entries exceed their prepared budget"); + const auto expected_fine = detail::expected_fine_face_set(key, ratio, mapping, budget); + const std::set> expected_coarse{detail::coordinate_array(key.coarse_face)}; + std::map>> coarse_slices; + std::map>> fine_slices; + std::map coarse_temporal; + std::map fine_temporal; + MetricFaceReflux result; + + for (const auto& entry : ledger.published_entries(key.axis)) { + if (!detail::matches_reflux_key(entry.key, key)) + continue; + const double scale = weighted_face_flux_scale(entry.measure); + const auto slice = detail::stage_slice(entry.key.clock, entry.key.stage); + switch (entry.key.role) { + case FaceLedgerRole::Coarse: + coarse_slices[slice].insert(detail::coordinate_array(entry.key.face)); + detail::register_temporal_slice(coarse_temporal, slice, entry.measure, + coarse_temporal.size() + fine_temporal.size(), budget); + axpy(result.coarse_integrated, scale, entry.payload); + result.coarse_weighted_measure += scale; + if (!std::isfinite(result.coarse_weighted_measure)) + throw std::overflow_error("ND metric reflux coarse weighted measure is not finite"); + break; + case FaceLedgerRole::Fine: + fine_slices[slice].insert(detail::coordinate_array(entry.key.face)); + detail::register_temporal_slice(fine_temporal, slice, entry.measure, + coarse_temporal.size() + fine_temporal.size(), budget); + axpy(result.fine_integrated, scale, entry.payload); + result.fine_weighted_measure += scale; + if (!std::isfinite(result.fine_weighted_measure)) + throw std::overflow_error("ND metric reflux fine weighted measure is not finite"); + break; + default: + throw std::runtime_error("ND metric reflux observed an invalid published face role"); + } + } + + detail::require_complete_slices(coarse_slices, expected_coarse, "coarse"); + detail::require_complete_slices(fine_slices, expected_fine, "fine"); + detail::validate_temporal_coverage(key, coarse_temporal, fine_temporal); + if (!detail::roundoff_equal(result.coarse_weighted_measure, result.fine_weighted_measure, + ledger.published_size())) + throw std::runtime_error( + "ND metric reflux coarse and fine metric-time measures do not cover the same face"); + axpy(result.mismatch, 1.0, result.fine_integrated); + axpy(result.mismatch, -1.0, result.coarse_integrated); + result.fine_face_count = expected_fine.size(); + return result; +} + +/// Convert the integrated face mismatch into the correction of the adjacent coarse cell. Fluxes +/// use canonical positive-axis orientation: replacing a lower face adds mismatch/volume, while +/// replacing an upper face subtracts it. The opposite fine-side transport then closes composite +/// conservation to the arithmetic precision of the supplied payload axpy. +template +Payload coarse_cell_reflux_correction(const MetricFaceReflux& reflux, + double coarse_cell_measure, CoarseCellFaceSide side, + Axpy&& axpy) { + if (!(coarse_cell_measure > 0.0) || !std::isfinite(coarse_cell_measure)) + throw std::invalid_argument("ND metric reflux requires a finite positive coarse-cell measure"); + double sign = 0.0; + switch (side) { + case CoarseCellFaceSide::Lower: + sign = 1.0; + break; + case CoarseCellFaceSide::Upper: + sign = -1.0; + break; + default: + throw std::invalid_argument("ND metric reflux has an invalid coarse-cell face side"); + } + const double coefficient = sign / coarse_cell_measure; + if (!std::isfinite(coefficient)) + throw std::overflow_error("ND metric reflux coarse-cell correction coefficient is not finite"); + Payload correction{}; + axpy(correction, coefficient, reflux.mismatch); + return correction; +} + +} // namespace pops::amr::reflux::nd diff --git a/include/pops/amr/transfer/nd/refinement_ratio.hpp b/include/pops/amr/transfer/nd/refinement_ratio.hpp new file mode 100644 index 000000000..f0054a2fb --- /dev/null +++ b/include/pops/amr/transfer/nd/refinement_ratio.hpp @@ -0,0 +1,13 @@ +/// @file +/// @brief Compatibility name for the common validated ND refinement ratio. + +#pragma once + +#include + +namespace pops::amr::transfer::nd { + +template +using RefinementRatio = ::pops::amr::nd::RefinementRatio; + +} // namespace pops::amr::transfer::nd diff --git a/include/pops/amr/transfer/nd/transfer_provider.hpp b/include/pops/amr/transfer/nd/transfer_provider.hpp new file mode 100644 index 000000000..5da3b74d2 --- /dev/null +++ b/include/pops/amr/transfer/nd/transfer_provider.hpp @@ -0,0 +1,409 @@ +/// @file +/// @brief Allocation-free prepared AMR restriction and interpolation in 1D, 2D, and 3D. + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace pops::amr::transfer::nd { + +/// Logical location of transferred values. The first ND substrate intentionally authenticates +/// only cell-centered transfers; the other values make unsupported routes explicit and fail-closed. +enum class Centering : unsigned char { Cell = 0, Node = 1, Face0 = 2, Face1 = 3, Face2 = 4 }; + +enum class TransferKind : unsigned char { + ConservativeRestriction = 0, + LinearProlongation = 1, + CoarseFineGhostInterpolation = 2, +}; + +struct TransferCapabilities { + int interpolation_order = 0; + int source_stencil_radius = 0; + bool conservative = false; + bool allocation_free_hot_path = false; + + constexpr bool operator==(const TransferCapabilities&) const = default; +}; + +/// Component interval bound during preparation. A prepared kernel never owns a dynamic list. +struct ComponentRange { + int source_begin = 0; + int destination_begin = 0; + int count = 1; + + constexpr bool operator==(const ComponentRange&) const = default; +}; + +/// Affine relationship between level index spaces. Origins need not be zero or positive. +template +struct IndexMapping { + Index coarse_origin{}; + Index fine_origin{}; + + constexpr bool operator==(const IndexMapping&) const = default; +}; + +namespace detail { + +inline int checked_transfer_index(std::int64_t value, const char* operation) { + if (value < std::numeric_limits::min() || value > std::numeric_limits::max()) + throw std::overflow_error(operation); + return static_cast(value); +} + +inline std::int64_t checked_transfer_add(std::int64_t left, std::int64_t right, + const char* operation) { + if ((right > 0 && left > std::numeric_limits::max() - right) || + (right < 0 && left < std::numeric_limits::min() - right)) + throw std::overflow_error(operation); + return left + right; +} + +inline std::int64_t checked_transfer_multiply(std::int64_t value, int positive_multiplier, + const char* operation) { + if (value > std::numeric_limits::max() / positive_multiplier || + value < std::numeric_limits::min() / positive_multiplier) + throw std::overflow_error(operation); + return value * positive_multiplier; +} + +POPS_HD constexpr std::int64_t floor_div_positive(std::int64_t numerator, int denominator) { + const std::int64_t quotient = numerator / denominator; + const std::int64_t remainder = numerator % denominator; + return remainder < 0 ? quotient - 1 : quotient; +} + +template +struct ValidatedView { + Box box{}; + std::uintptr_t begin = 0; + std::uintptr_t end = 0; +}; + +template +ValidatedView validate_view(const FieldView& view) { + if (view.data == nullptr || view.ncomp < 1 || view.component_stride < 1) + throw std::invalid_argument("prepared ND transfer requires a valid non-empty FieldView"); + + Box box{}; + box.lo = view.origin; + std::int64_t maximum_offset = 0; + std::int64_t minimum_stride = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t extent = view.extents[axis]; + const std::int64_t stride = view.strides[axis]; + if (extent < 1 || stride < minimum_stride) + throw std::invalid_argument( + "prepared ND transfer requires positive non-overlapping FieldView strides"); + box.hi[axis] = checked_transfer_index( + checked_transfer_add(view.origin[axis], extent - 1, + "prepared ND transfer FieldView index range exceeds int64_t"), + "prepared ND transfer FieldView index range exceeds signed coordinates"); + if (extent - 1 > (std::numeric_limits::max() - maximum_offset) / stride) + throw std::overflow_error("prepared ND transfer FieldView spatial span exceeds int64_t"); + maximum_offset += (extent - 1) * stride; + if (extent > std::numeric_limits::max() / stride) + throw std::overflow_error("prepared ND transfer FieldView stride hierarchy exceeds int64_t"); + minimum_stride = extent * stride; + } + if (view.component_stride < minimum_stride) + throw std::invalid_argument( + "prepared ND transfer FieldView components overlap its spatial storage"); + const std::int64_t component_count = static_cast(view.ncomp) - 1; + if (component_count > + (std::numeric_limits::max() - maximum_offset) / view.component_stride) + throw std::overflow_error("prepared ND transfer FieldView component span exceeds int64_t"); + maximum_offset += component_count * view.component_stride; + if (maximum_offset == std::numeric_limits::max()) + throw std::overflow_error("prepared ND transfer FieldView element span exceeds int64_t"); + + const auto elements = static_cast(maximum_offset) + 1; + if (elements > std::numeric_limits::max() / sizeof(std::remove_const_t)) + throw std::overflow_error("prepared ND transfer FieldView byte span exceeds uintptr_t"); + const std::uintptr_t begin = reinterpret_cast(view.data); + const std::uintptr_t bytes = + static_cast(elements * sizeof(std::remove_const_t)); + if (begin > std::numeric_limits::max() - bytes) + throw std::overflow_error("prepared ND transfer FieldView address span wraps uintptr_t"); + return {box, begin, begin + bytes}; +} + +template +void validate_components(const FieldView& source, + const FieldView& destination, + const ComponentRange& components) { + if (components.source_begin < 0 || components.destination_begin < 0 || components.count < 1 || + components.source_begin > source.ncomp - components.count || + components.destination_begin > destination.ncomp - components.count) + throw std::invalid_argument("prepared ND transfer component interval is outside its fields"); +} + +template +Box refined_source_box(const Box& coarse_region, const RefinementRatio& ratio, + const IndexMapping& mapping) { + Box result{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t lower_relative = + static_cast(coarse_region.lo[axis]) - mapping.coarse_origin[axis]; + const std::int64_t upper_relative = + static_cast(coarse_region.hi[axis]) - mapping.coarse_origin[axis]; + const std::int64_t lower_scaled = checked_transfer_multiply( + lower_relative, ratio[axis], "prepared ND restriction index mapping exceeds int64_t"); + const std::int64_t upper_scaled = checked_transfer_multiply( + upper_relative, ratio[axis], "prepared ND restriction index mapping exceeds int64_t"); + const std::int64_t lower = + checked_transfer_add(mapping.fine_origin[axis], lower_scaled, + "prepared ND restriction lower source index exceeds int64_t"); + const std::int64_t upper = checked_transfer_add( + checked_transfer_add(mapping.fine_origin[axis], upper_scaled, + "prepared ND restriction upper source index exceeds int64_t"), + static_cast(ratio[axis]) - 1, + "prepared ND restriction upper source index exceeds int64_t"); + result.lo[axis] = checked_transfer_index( + lower, "prepared ND restriction lower source index exceeds signed coordinates"); + result.hi[axis] = checked_transfer_index( + upper, "prepared ND restriction upper source index exceeds signed coordinates"); + } + return result; +} + +template +Box interpolation_source_box(const Box& fine_region, const RefinementRatio& ratio, + const IndexMapping& mapping) { + Box result{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t lower_relative = + static_cast(fine_region.lo[axis]) - mapping.fine_origin[axis]; + const std::int64_t upper_relative = + static_cast(fine_region.hi[axis]) - mapping.fine_origin[axis]; + std::int64_t lower = checked_transfer_add( + mapping.coarse_origin[axis], floor_div_positive(lower_relative, ratio[axis]), + "prepared ND interpolation lower source index exceeds int64_t"); + std::int64_t upper = checked_transfer_add( + mapping.coarse_origin[axis], floor_div_positive(upper_relative, ratio[axis]), + "prepared ND interpolation upper source index exceeds int64_t"); + if (ratio[axis] > 1) { + --lower; + ++upper; + } + result.lo[axis] = checked_transfer_index( + lower, "prepared ND interpolation lower stencil exceeds signed coordinates"); + result.hi[axis] = checked_transfer_index( + upper, "prepared ND interpolation upper stencil exceeds signed coordinates"); + } + return result; +} + +template +POPS_HD bool increment_child(Index& child, const RefinementRatio& ratio) { + for (int axis = 0; axis < Dim; ++axis) { + ++child[axis]; + if (child[axis] < ratio[axis]) + return true; + child[axis] = 0; + } + return false; +} + +template +POPS_HD void fine_parent_and_child(const Index& fine, const RefinementRatio& ratio, + const IndexMapping& mapping, Index& parent, + Index& child) { + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t relative = static_cast(fine[axis]) - mapping.fine_origin[axis]; + const std::int64_t parent_relative = floor_div_positive(relative, ratio[axis]); + parent[axis] = + static_cast(static_cast(mapping.coarse_origin[axis]) + parent_relative); + child[axis] = static_cast(relative - parent_relative * ratio[axis]); + } +} + +} // namespace detail + +template +class PreparedTransfer { + public: + static_assert(Dim >= 1 && Dim <= 3, "PreparedTransfer only supports dimensions 1, 2, and 3"); + + POPS_HD void operator()(const Index& destination_index) const { + if (kind_ == TransferKind::ConservativeRestriction) + restrict_cell(destination_index); + else + interpolate_cell(destination_index); + } + + POPS_HD TransferKind kind() const { return kind_; } + POPS_HD const Box& destination_region() const { return destination_region_; } + POPS_HD const RefinementRatio& refinement_ratio() const { return ratio_; } + POPS_HD ComponentRange components() const { return components_; } + + private: + template + friend class TransferProvider; + + POPS_HD PreparedTransfer(TransferKind kind, RefinementRatio ratio, IndexMapping mapping, + ComponentRange components, FieldView source, + FieldView destination, Box destination_region) + : kind_(kind), + ratio_(ratio), + mapping_(mapping), + components_(components), + source_(source), + destination_(destination), + destination_region_(destination_region) {} + + POPS_HD void restrict_cell(const Index& coarse) const { + Index fine_base{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t coarse_relative = + static_cast(coarse[axis]) - mapping_.coarse_origin[axis]; + fine_base[axis] = static_cast(static_cast(mapping_.fine_origin[axis]) + + coarse_relative * ratio_[axis]); + } + for (int component = 0; component < components_.count; ++component) { + const int source_component = components_.source_begin + component; + const int destination_component = components_.destination_begin + component; + const Real anchor = source_(fine_base, source_component); + Real correction = Real(0); + Index child{}; + do { + Index fine = fine_base; + for (int axis = 0; axis < Dim; ++axis) + fine[axis] += child[axis]; + correction += source_(fine, source_component) - anchor; + } while (detail::increment_child(child, ratio_)); + destination_(coarse, destination_component) = + anchor + correction / static_cast(ratio_.child_count()); + } + } + + POPS_HD void interpolate_cell(const Index& fine) const { + Index parent{}; + Index child{}; + detail::fine_parent_and_child(fine, ratio_, mapping_, parent, child); + for (int component = 0; component < components_.count; ++component) { + const int source_component = components_.source_begin + component; + const int destination_component = components_.destination_begin + component; + Real value = source_(parent, source_component); + for (int axis = 0; axis < Dim; ++axis) { + if (ratio_[axis] == 1) + continue; + Index lower = parent; + Index upper = parent; + --lower[axis]; + ++upper[axis]; + const Real slope = + Real(0.5) * (source_(upper, source_component) - source_(lower, source_component)); + const std::int64_t offset_numerator = std::int64_t{2} * child[axis] + 1 - ratio_[axis]; + const std::int64_t offset_denominator = std::int64_t{2} * ratio_[axis]; + value += + slope * static_cast(offset_numerator) / static_cast(offset_denominator); + } + destination_(fine, destination_component) = value; + } + } + + TransferKind kind_; + RefinementRatio ratio_; + IndexMapping mapping_{}; + ComponentRange components_{}; + FieldView source_{}; + FieldView destination_{}; + Box destination_region_{}; +}; + +/// Authenticated construction boundary for one prepared ND transfer operation. +/// +/// The provider performs all pointer, extent, component, stencil, centering and operation checks +/// on the host. The returned value contains only fixed-size metadata and non-owning FieldViews; +/// invoking it for each destination index performs no allocation or dynamic dispatch. +template +class TransferProvider { + public: + static_assert(Dim >= 1 && Dim <= 3, "TransferProvider only supports dimensions 1, 2, and 3"); + + constexpr explicit TransferProvider(TransferKind kind) : kind_(kind) {} + + static constexpr TransferProvider conservative_restriction() { + return TransferProvider(TransferKind::ConservativeRestriction); + } + + static constexpr TransferProvider linear_prolongation() { + return TransferProvider(TransferKind::LinearProlongation); + } + + static constexpr TransferProvider coarse_fine_ghost_interpolation() { + return TransferProvider(TransferKind::CoarseFineGhostInterpolation); + } + + TransferCapabilities capabilities() const { + require_supported_route(); + if (kind_ == TransferKind::ConservativeRestriction) + return {1, 0, true, true}; + return {2, 1, false, true}; + } + + PreparedTransfer prepare(FieldView source, FieldView destination, + const Box& destination_region, RefinementRatio ratio, + IndexMapping mapping = {}, + ComponentRange components = {}) const { + require_supported_route(); + if (!ratio.refines_any_axis()) + throw std::invalid_argument( + "prepared ND transfer requires a non-identity inter-level refinement ratio"); + const auto source_view = detail::validate_view(source); + const auto destination_view = detail::validate_view(destination); + if (destination_region.empty() || !destination_view.box.contains(destination_region)) + throw std::invalid_argument( + "prepared ND transfer destination region is empty or outside its FieldView"); + detail::validate_components(source, destination, components); + if (source_view.begin < destination_view.end && destination_view.begin < source_view.end) + throw std::invalid_argument("prepared ND transfer requires non-overlapping field storage"); + + const Box required_source = + kind_ == TransferKind::ConservativeRestriction + ? detail::refined_source_box(destination_region, ratio, mapping) + : detail::interpolation_source_box(destination_region, ratio, mapping); + if (!source_view.box.contains(required_source)) + throw std::invalid_argument( + "prepared ND transfer source FieldView does not contain the complete stencil"); + + return PreparedTransfer(kind_, ratio, mapping, components, source, destination, + destination_region); + } + + private: + void require_supported_route() const { + if constexpr (Center != Centering::Cell) + throw std::invalid_argument( + "ND transfer provider currently authenticates only cell-centered fields"); + switch (kind_) { + case TransferKind::ConservativeRestriction: + case TransferKind::LinearProlongation: + case TransferKind::CoarseFineGhostInterpolation: + return; + } + throw std::invalid_argument("ND transfer provider identity is not registered"); + } + + TransferKind kind_; +}; + +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); + +} // namespace pops::amr::transfer::nd diff --git a/include/pops/core/foundation/native_dimension.hpp b/include/pops/core/foundation/native_dimension.hpp new file mode 100644 index 000000000..7a724833f --- /dev/null +++ b/include/pops/core/foundation/native_dimension.hpp @@ -0,0 +1,9 @@ +#pragma once + +namespace pops { + +/// Exact dimension carried by the current Box2D/Fab2D runtime. Dimension-generic local providers +/// advertise their own compile-time dimension and do not change this runtime fact. +inline constexpr int kNativeDimension = 2; + +} // namespace pops diff --git a/include/pops/core/model/physical_model.hpp b/include/pops/core/model/physical_model.hpp index d6b201c42..fa9582575 100644 --- a/include/pops/core/model/physical_model.hpp +++ b/include/pops/core/model/physical_model.hpp @@ -76,6 +76,8 @@ POPS_HD constexpr int aux_comps() { /// Requires: State, Aux == pops::Aux, n_vars, flux(u,a,dir), max_wave_speed(u,a,dir), /// source(u,a), elliptic_rhs(u). All these methods must be POPS_HD if called /// in kernels (not checked by the concept; responsibility of the author). +/// Finite-volume execution additionally instantiates the hyperbolic methods with the exact +/// BoundFluxProviders protocol; Aux remains the pointwise source/implicit carrier. /// Do not confuse with HyperbolicPhysicalModel which adds the variables and conversions. template concept PhysicalModel = @@ -171,6 +173,19 @@ concept HasPrimitiveVars = { m.to_conservative(p) } -> std::same_as; }; +/// OPTIONAL physical admissibility contract for conservative-to-primitive recovery. +/// +/// The conversion formula and the admissibility policy are deliberately separate: a finite +/// primitive candidate may still be physically invalid (for example non-positive density or +/// pressure). When present, the prepared recovery service invokes this device-callable predicate +/// before publication. `failing_component` identifies the primitive component whose declared +/// constraint failed; implementations set it to -1 on success. +template +concept HasRecoveryAdmissibility = + HasPrimitiveVars && requires(const M m, const typename M::Prim p, int* failing_component) { + { m.recovery_admissible(p, failing_component) } -> std::same_as; + }; + /// Hyperbolic brick of a model: flux + wave speed + variables + cons<->prim conversions. /// /// Variables, conversions and flux are physically LINKED (a flux is written for a given layout diff --git a/include/pops/core/state/state.hpp b/include/pops/core/state/state.hpp index a49260cc3..0b7b6170d 100644 --- a/include/pops/core/state/state.hpp +++ b/include/pops/core/state/state.hpp @@ -103,6 +103,12 @@ POPS_HD StateVec operator*(Real s, StateVec a) { // on the DSL side (python/pops/dsl.py) if more than four named fields per model are wanted. inline constexpr int kAuxMaxExtra = 4; +// Width of the base provider channel and first model-named provider component. These constants +// precede Aux because both the legacy source-term carrier and the exact physical-flux provider pack +// implement the same compile-time read protocol. +inline constexpr int kAuxBaseComps = 3; +inline constexpr int kAuxNamedBase = kAuxBaseComps + 2; // = 5 (after B_z=3, T_e=4) + /// @brief POINTWISE auxiliary fields shared with the physics: single coupling channel. /// /// Role: carry to the point the outputs of the elliptic solver and the fields provided by the system, @@ -146,18 +152,32 @@ struct Aux { assert(k >= 0 && k < kAuxMaxExtra); return (k >= 0 && k < kAuxMaxExtra) ? extra[k] : std::numeric_limits::quiet_NaN(); } -}; -// Width of the aux channel of the base contract (phi, grad phi). A model reading additional -// fields declares a larger n_aux; cf. aux_comps()/load_aux(). -inline constexpr int kAuxBaseComps = 3; + /// Compile-time provider read used by pointwise physical laws. Source/implicit routes may still + /// carry Aux, while finite-volume fluxes pass the exact model-qualified pack; the law therefore + /// depends on this narrow read protocol rather than on either storage representation. + template + POPS_HD Real flux_provider() const { + static_assert(Component >= 0 && Component < kAuxNamedBase + kAuxMaxExtra, + "physical flux provider component is outside the declared native capability"); + if constexpr (Component == 0) + return phi; + else if constexpr (Component == 1) + return grad_x; + else if constexpr (Component == 2) + return grad_y; +#define POPS_AUX_PROVIDER_READ(name, index) else if constexpr (Component == index) return name; + POPS_AUX_FIELDS(POPS_AUX_PROVIDER_READ) +#undef POPS_AUX_PROVIDER_READ + else return extra[Component - kAuxNamedBase]; + } +}; // First component of the NAMED aux fields (ADC-70 phase 1): right AFTER the canonical fields // B_z (3) and T_e (4), so index 5. A model declaring K named fields sets n_aux = kAuxNamedBase + // K; extra[k] is component (kAuxNamedBase + k). Placed AFTER the canonical channel so that user // names never encroach on B_z / T_e (which keep their dedicated paths // set_magnetic_field / set_electron_temperature_from). Python MIRROR: AUX_NAMED_BASE (dsl.py). -inline constexpr int kAuxNamedBase = kAuxBaseComps + 2; // = 5 (after B_z=3, T_e=4) // Safeguard: the base of the named fields must be STRICTLY beyond the last canonical extra // field (the largest index of POPS_AUX_FIELDS + 1). If a canonical field is added beyond T_e, diff --git a/include/pops/core/state/variables.hpp b/include/pops/core/state/variables.hpp index 43c9e2512..f4e43ba60 100644 --- a/include/pops/core/state/variables.hpp +++ b/include/pops/core/state/variables.hpp @@ -36,7 +36,11 @@ enum class VariableRole { Pressure, Temperature, Scalar, - Custom + Custom, + // Append new canonical roles so the numeric values of the established role ABI stay stable. + AxialX, + AxialY, + AxialZ }; /// Forward declaration: VariableSet::index_of(const std::string&) resolves a canonical role NAME via @@ -120,6 +124,12 @@ inline const char* role_name(VariableRole r) { return "scalar"; case VariableRole::Custom: return "custom"; + case VariableRole::AxialX: + return "axial_x"; + case VariableRole::AxialY: + return "axial_y"; + case VariableRole::AxialZ: + return "axial_z"; } return "custom"; } @@ -150,6 +160,12 @@ inline VariableRole role_from_name(const std::string& s) { return VariableRole::Temperature; if (s == "scalar") return VariableRole::Scalar; + if (s == "axial_x") + return VariableRole::AxialX; + if (s == "axial_y") + return VariableRole::AxialY; + if (s == "axial_z") + return VariableRole::AxialZ; return VariableRole::Custom; } diff --git a/include/pops/coupling/amr/amr_coupler_mp.hpp b/include/pops/coupling/amr/amr_coupler_mp.hpp index f0da4d988..5ceafebeb 100644 --- a/include/pops/coupling/amr/amr_coupler_mp.hpp +++ b/include/pops/coupling/amr/amr_coupler_mp.hpp @@ -617,7 +617,8 @@ class AmrCouplerMP { // the single rank 0 and compute_aux would read a phi absent elsewhere). In serial, both coincide. template > requires pops::EllipticFactory - AmrCouplerMP(const Model& model, const Geometry& geom, const BoxArray& ba_coarse, const BCRec& bc, + AmrCouplerMP(const Model& model, const Geometry& geom, const BoxArray& ba_coarse, + const BCRec& elliptic_bc, Periodicity transport_periodicity, std::vector levels, ActiveRegionProvider2D active, bool replicated_coarse, std::shared_ptr load_balance, @@ -626,16 +627,18 @@ class AmrCouplerMP { geom_(detail::coupler_validated_geometry(geom)), coarse_boxes_(ba_coarse), coarse_mapping_(detail::coupler_authoritative_coarse_mapping(ba_coarse, levels)), - elliptic_bc_(bc), + elliptic_bc_(elliptic_bc), mg_(make_elliptic_solver( {geom_, coarse_boxes_, coarse_mapping_, elliptic_bc_, std::move(active), replicated_coarse ? FieldDistribution::Replicated : FieldDistribution::Distributed}, std::move(elliptic_factory))), stack_(geom_.domain, std::move(levels), aux_comps()), replicated_coarse_(replicated_coarse), - load_balance_authority_(std::move(load_balance)) { + load_balance_authority_(std::move(load_balance)), + transport_periodicity_(transport_periodicity) { if (!load_balance_authority_) throw std::invalid_argument("AmrCouplerMP requires a prepared load-balance authority"); + detail::validate_periodic_pairs(elliptic_bc); for (const AmrLevelMP& level : stack_.levels()) detail::require_positive_finite_amr_spacing(level.dx, level.dy); prepare_aux_transfer_workspaces_(); @@ -680,13 +683,6 @@ class AmrCouplerMP { } const Box2D& domain() const { return stack_.domain(); } int nlev() const { return stack_.nlev(); } - void set_transport_boundary_fill(AmrBoundaryFillAuthority authority) { - validate_amr_boundary_fill_authority(authority.periodicity, &authority, stack_.L()); - transport_periodicity_ = authority.periodicity; - transport_boundary_fill_ = std::move(authority); - prepare_aux_transfer_workspaces_(next_transfer_topology_generation_()); - } - // ---------------------------------------------------------------------------------------------- // AMR ACCEPTED-STATE CHECKPOINT / RESTART. The mono-block coupler carries the FULL conservative // state per level (all components) plus phi (multigrid warm-start), and can impose a saved fine @@ -850,6 +846,10 @@ class AmrCouplerMP { // rely only on the IMPOSED LAYOUT. SINGLE-RANK, 2-level mono-block hierarchy (so we impose // ONLY level 1). Clear rejection if the hierarchy has no fine level or if no box was saved. void set_hierarchy(const std::vector& fine_boxes) { + if (!transport_periodicity_.x || !transport_periodicity_.y) + throw std::logic_error( + "AmrCouplerMP::set_hierarchy refuses non-periodic transport without a prepared " + "boundary plan providing physical ghost support"); std::vector& L = stack_.L(); if (L.size() < 2) throw std::runtime_error( @@ -985,6 +985,10 @@ class AmrCouplerMP { // margin = nesting. The coupler only orders the call. template void regrid(Crit crit, int grow = 2, int margin = 2) { + if (!transport_periodicity_.x || !transport_periodicity_.y) + throw std::logic_error( + "AmrCouplerMP::regrid refuses non-periodic transport without a prepared boundary plan " + "providing physical ghost support"); const RegridProlongation prolong = [base_domain = stack_.domain(), periodicity = transport_periodicity_]( const MultiFab& parent, MultiFab& fine, int parent_level, @@ -997,15 +1001,10 @@ class AmrCouplerMP { {fine_domain.lo[0], fine_domain.lo[1]}, {ratio, ratio}, parent_replicated, periodicity); (void)parent_level; }; - std::optional physical_support; - if (transport_boundary_fill_) - physical_support = - RegridPhysicalGhostSupport{transport_boundary_fill_->provided_depth, - transport_boundary_fill_->fills_all_allocated_ghosts}; amr_regrid_finest(stack_.L(), stack_.aux(), stack_.domain(), crit, grow, margin, prolong, aux_comps(), replicated_coarse_, *load_balance_authority_, RegridPeriodicity{transport_periodicity_.x, transport_periodicity_.y}, - world_communicator_view(), physical_support ? &*physical_support : nullptr); + world_communicator_view()); prepare_aux_transfer_workspaces_(next_transfer_topology_generation_()); } @@ -1161,7 +1160,6 @@ class AmrCouplerMP { replicated_coarse_; // level 0 replicated (true) or distributed multi-box (false, de-replication) std::shared_ptr load_balance_authority_; Periodicity transport_periodicity_{true, true}; - std::optional transport_boundary_fill_; // COMPOSITE FAC Poisson path (opt-in, set_composite_poisson). fac_ built lazily on the // current fine patch (rebuilt if the patch changes after regrid). Default OFF -> Option A bit-identical. bool composite_poisson_ = false; diff --git a/include/pops/mesh/boundary/boundary_component_executor.hpp b/include/pops/mesh/boundary/boundary_component_executor.hpp index f926dd45b..8d0a18cd2 100644 --- a/include/pops/mesh/boundary/boundary_component_executor.hpp +++ b/include/pops/mesh/boundary/boundary_component_executor.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -539,6 +540,187 @@ inline void apply_ghost_component(const PreparedGhostBoundaryComponent::Session& scatter_field(state, workspace.locations, workspace.ghosts); } +struct PreparedBoundaryFluxWorkspace { + int axis = 0; + int side = 1; + int state_components = 0; + std::vector face_locations; + std::vector cell_locations; + std::vector base_outward_flux; + std::vector transformed_outward_flux; + std::vector xy; + std::vector outward_normals; + std::vector face_measures; + std::vector packed_dependencies; + std::vector dependencies; + std::vector parameters; + std::vector actions; +}; + +inline Box2D boundary_flux_face_box(Box2D cells, int axis) { + if (axis < 0 || axis >= 2) + throw std::invalid_argument("boundary flux face axis is invalid"); + ++cells.hi[axis]; + return cells; +} + +inline PreparedBoundaryFluxWorkspace prepare_boundary_flux_workspace( + const PreparedBoundaryFluxComponent::Session& component, const MultiFab& prototype, + const BoundaryFieldRegistry& registry, const Geometry& geometry) { + const auto& spec = component.spec(); + if (spec.region.kind != POPS_BOUNDARY_FACE_V1 || spec.region.codimension != 1 || + spec.region.axes.size() != 1 || spec.region.sides.size() != 1) + throw std::invalid_argument("post-Riemann boundary flux requires one exact oriented face"); + PreparedBoundaryFluxWorkspace workspace; + workspace.axis = spec.region.axes.front(); + workspace.side = spec.region.sides.front(); + workspace.state_components = prototype.ncomp(); + if (workspace.axis < 0 || workspace.axis >= 2 || (workspace.side != -1 && workspace.side != 1)) + throw std::invalid_argument("post-Riemann boundary flux face is invalid"); + const int normal_face = workspace.side < 0 ? geometry.domain.lo[workspace.axis] + : geometry.domain.hi[workspace.axis] + 1; + const int normal_cell = + workspace.side < 0 ? geometry.domain.lo[workspace.axis] : geometry.domain.hi[workspace.axis]; + for (int local = 0; local < prototype.local_size(); ++local) { + const Box2D valid = prototype.box(local); + const Box2D faces = boundary_flux_face_box(valid, workspace.axis); + if (normal_face < faces.lo[workspace.axis] || normal_face > faces.hi[workspace.axis]) + continue; + const int tangent = 1 - workspace.axis; + const int lower = std::max(valid.lo[tangent], geometry.domain.lo[tangent]); + const int upper = std::min(valid.hi[tangent], geometry.domain.hi[tangent]); + for (int coordinate = lower; coordinate <= upper; ++coordinate) { + const int face_i = workspace.axis == 0 ? normal_face : coordinate; + const int face_j = workspace.axis == 1 ? normal_face : coordinate; + const int cell_i = workspace.axis == 0 ? normal_cell : coordinate; + const int cell_j = workspace.axis == 1 ? normal_cell : coordinate; + workspace.face_locations.push_back({local, face_i, face_j}); + workspace.cell_locations.push_back({local, cell_i, cell_j}); + } + } + const std::size_t count = workspace.face_locations.size(); + const std::size_t components = static_cast(prototype.ncomp()); + workspace.base_outward_flux.resize(count * components); + workspace.transformed_outward_flux.resize(count * components); + workspace.xy.resize(count * 2u); + workspace.outward_normals.assign(count * 2u, 0.0); + workspace.face_measures.assign( + count, static_cast(workspace.axis == 0 ? geometry.dy() : geometry.dx())); + workspace.actions.resize(count, POPS_COMPONENT_CONTINUE_V1); + for (std::size_t point = 0; point < count; ++point) { + const auto& face = workspace.face_locations[point]; + workspace.xy[2u * point] = + workspace.axis == 0 ? static_cast(workspace.side < 0 ? geometry.xlo : geometry.xhi) + : static_cast(geometry.x_cell(face.i)); + workspace.xy[2u * point + 1u] = + workspace.axis == 1 ? static_cast(workspace.side < 0 ? geometry.ylo : geometry.yhi) + : static_cast(geometry.y_cell(face.j)); + workspace.outward_normals[2u * point + static_cast(workspace.axis)] = + static_cast(workspace.side); + } + workspace.packed_dependencies.reserve(spec.states.size() + spec.fields.size()); + for (const std::string& identity : spec.states) + workspace.packed_dependencies.push_back( + prepare_const_field(BoundaryConstRole::State, registry.state_index(identity), identity, + registry, count, spec.layout_identity, spec.region.identity)); + for (const std::string& identity : spec.fields) + workspace.packed_dependencies.push_back( + prepare_const_field(BoundaryConstRole::Field, registry.field_index(identity), identity, + registry, count, spec.layout_identity, spec.region.identity)); + workspace.dependencies.reserve(workspace.packed_dependencies.size()); + for (const auto& row : workspace.packed_dependencies) + workspace.dependencies.push_back(row.view); + workspace.parameters = scalar_table(spec); + return workspace; +} + +inline void apply_boundary_flux_component( + const PreparedBoundaryFluxComponent::Session& component, + PreparedBoundaryFluxWorkspace& workspace, const MultiFab& state, + const BoundaryFieldRegistry& registry, const Geometry& geometry, MultiFab& fx, MultiFab& fy, + const runtime::multiblock::BoundaryEvaluationPoint& point) { + if (state.ncomp() != workspace.state_components || fx.ncomp() != state.ncomp() || + fy.ncomp() != state.ncomp() || fx.local_size() != state.local_size() || + fy.local_size() != state.local_size()) + throw std::runtime_error( + "post-Riemann boundary flux layout changed after executor preparation"); + if (workspace.face_locations.empty()) + return; + const auto& spec = component.spec(); + MultiFab& face_flux = workspace.axis == 0 ? fx : fy; + for (int local = 0; local < state.local_size(); ++local) { + const Box2D expected = boundary_flux_face_box(state.box(local), workspace.axis); + if (face_flux.box(local) != expected) + throw std::runtime_error("post-Riemann boundary flux differs from the prepared face layout"); + } + const std::size_t count = workspace.face_locations.size(); + const std::size_t components = static_cast(state.ncomp()); + for (std::size_t index = 0; index < count; ++index) { + const auto& location = workspace.face_locations[index]; + const ConstArray4 values = face_flux.fab(location.local_fab).const_array(); + for (std::size_t component_index = 0; component_index < components; ++component_index) { + const double outward = + static_cast(workspace.side) * + static_cast(values(location.i, location.j, static_cast(component_index))); + workspace.base_outward_flux[index * components + component_index] = outward; + workspace.transformed_outward_flux[index * components + component_index] = outward; + } + } + for (auto& row : workspace.packed_dependencies) + pack_field_into(bound_const(registry, row.role, row.slot), workspace.cell_locations, + geometry.domain, false, state, row.values); + std::fill(workspace.actions.begin(), workspace.actions.end(), POPS_COMPONENT_CONTINUE_V1); + PopsConstFieldViewV1 base_view = const_view(workspace.base_outward_flux, count, components, + spec.layout_identity, spec.region.identity); + base_view.centering = POPS_FIELD_CENTERING_FACE_V1; + base_view.centering_axes = 1u << static_cast(workspace.axis); + const PopsConstFieldViewV1 coordinate_view = + const_view(workspace.xy, count, 2, spec.layout_identity, spec.region.identity); + const PopsConstFieldViewV1 normal_view = + const_view(workspace.outward_normals, count, 2, spec.layout_identity, spec.region.identity); + PopsBoundaryFluxRequestV1 request{sizeof(PopsBoundaryFluxRequestV1), + spec.target_identity.c_str(), + spec.state_identity.c_str(), + base_view, + coordinate_view, + normal_view, + workspace.face_measures.data(), + spec.region.view(), + workspace.dependencies.size(), + workspace.dependencies.data(), + workspace.parameters.size(), + workspace.parameters.data(), + logical_time(point), + component.execution().view()}; + PopsFieldViewV1 transformed_view = + field_view(workspace.transformed_outward_flux, count, components, spec.layout_identity, + spec.region.identity); + transformed_view.centering = POPS_FIELD_CENTERING_FACE_V1; + transformed_view.centering_axes = 1u << static_cast(workspace.axis); + PopsBoundaryFluxResultV1 result{ + sizeof(PopsBoundaryFluxResultV1), + transformed_view, + workspace.actions.data(), + {sizeof(PopsComponentStatusV1), 0, POPS_COMPONENT_CONTINUE_V1, nullptr}}; + const int code = component::transform_boundary_flux(component.boundary_flux_api(), + component.state(), request, result); + PreparedBoundaryFluxComponent::require_success(code, result.status, "transform_faces"); + for (std::size_t index = 0; index < count; ++index) { + if (workspace.actions[index] != POPS_COMPONENT_CONTINUE_V1) + throw std::runtime_error("native BoundaryFlux returned a non-continue per-face action"); + const auto& location = workspace.face_locations[index]; + Array4 values = face_flux.fab(location.local_fab).array(); + for (std::size_t component_index = 0; component_index < components; ++component_index) { + const double outward = + workspace.transformed_outward_flux[index * components + component_index]; + if (!std::isfinite(outward)) + throw std::runtime_error("native BoundaryFlux returned a non-finite flux"); + values(location.i, location.j, static_cast(component_index)) = + static_cast(static_cast(workspace.side) * outward); + } + } +} + struct PreparedFieldBoundaryWorkspace { std::vector locations; std::vector xy; diff --git a/include/pops/mesh/boundary/nd_boundary_schedule.hpp b/include/pops/mesh/boundary/nd_boundary_schedule.hpp new file mode 100644 index 000000000..9083f400b --- /dev/null +++ b/include/pops/mesh/boundary/nd_boundary_schedule.hpp @@ -0,0 +1,375 @@ +/// @file +/// @brief Backend-neutral 1D/2D/3D Cartesian boundary-region schedule. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { + +enum class BoundaryRegionKind : unsigned char { face, edge, corner }; + +/// A canonical non-empty intersection of oriented boundary faces. At most one side per axis may +/// participate. Faces are stored in increasing axis/ordinal order, independent of authoring order. +template +struct BoundaryRegion { + std::array, Dim> faces{}; + unsigned char count = 0; + + BoundaryRegion() = default; + + BoundaryRegion(std::array, Dim> region_faces, std::size_t region_count) + : faces(region_faces), count(static_cast(region_count)) { + if (region_count == 0 || region_count > static_cast(Dim)) + throw std::invalid_argument("pops::BoundaryRegion requires between one and Dim faces"); + std::sort(faces.begin(), faces.begin() + static_cast(region_count), + face_less); + for (std::size_t index = 1; index < region_count; ++index) { + if (faces[index - 1].axis == faces[index].axis) + throw std::invalid_argument("pops::BoundaryRegion cannot contain two sides of one axis"); + } + } + + std::size_t codimension() const noexcept { return count; } + + BoundaryRegionKind kind() const { + if (count == 0) + throw std::logic_error("pops::BoundaryRegion empty value has no boundary kind"); + if (count == 1) + return BoundaryRegionKind::face; + if constexpr (Dim == 3) { + if (count == 2) + return BoundaryRegionKind::edge; + } + return BoundaryRegionKind::corner; + } + + /// Base-three identity, axis 0 fastest: interior=0, lower=1, upper=2. + std::size_t ordinal() const noexcept { + std::size_t result = 0; + std::size_t stride = 1; + std::size_t face_index = 0; + for (int axis = 0; axis < Dim; ++axis) { + if (face_index < count && faces[face_index].axis == axis) { + result += stride * + (faces[face_index].side == BoundarySide::lower ? std::size_t{1} : std::size_t{2}); + ++face_index; + } + stride *= 3; + } + return result; + } + + bool operator==(const BoundaryRegion&) const = default; +}; + +template +struct BoundaryOperation { + Face face{}; + BoundaryFaceKind kind = BoundaryFaceKind::physical; + /// Translation applied to a destination ghost index to locate its periodic source. Physical + /// operations must carry the zero shift. + Index source_from_destination_shift{}; + + bool operator==(const BoundaryOperation&) const = default; +}; + +/// One disjoint destination region and its canonical face/edge/corner composition. This is a +/// fixed-size, trivially-copyable execution record: a backend may mirror it without interpreting a +/// host pointer, communicator, or callback. +template +struct BoundaryRegionPlan { + BoundaryRegion region{}; + Box destination{}; + std::array, Dim> operations{}; + unsigned char operation_count = 0; + Index source_from_destination_shift{}; + + bool has_physical() const noexcept { + for (std::size_t index = 0; index < operation_count; ++index) + if (operations[index].kind == BoundaryFaceKind::physical) + return true; + return false; + } + + bool has_periodic() const noexcept { + for (std::size_t index = 0; index < operation_count; ++index) + if (operations[index].kind == BoundaryFaceKind::periodic) + return true; + return false; + } + + bool operator==(const BoundaryRegionPlan&) const = default; +}; + +struct BoundaryScheduleBudget { + std::size_t entries; +}; + +namespace boundary_schedule_detail { + +inline int checked_index(std::int64_t value, const char* operation) { + if (value < std::numeric_limits::min() || value > std::numeric_limits::max()) + throw std::overflow_error(operation); + return static_cast(value); +} + +inline std::int64_t checked_add(std::int64_t left, std::int64_t right, const char* operation) { + if ((right > 0 && left > std::numeric_limits::max() - right) || + (right < 0 && left < std::numeric_limits::min() - right)) + throw std::overflow_error(operation); + return left + right; +} + +inline std::int64_t checked_multiply(std::int64_t left, std::int64_t right, const char* operation) { + if (left < 0 || right < 0) + throw std::invalid_argument(operation); + if (left != 0 && right > std::numeric_limits::max() / left) + throw std::overflow_error(operation); + return left * right; +} + +inline std::size_t checked_axis_segment_count(std::int64_t ghosts, std::int64_t extent, + bool periodic) { + if (ghosts < 0) + throw std::invalid_argument("pops::mesh boundary ghost depths must be non-negative"); + if (ghosts == 0) + return 1; + if (!periodic) + return 3; + if (extent <= 0) + throw std::invalid_argument("pops::mesh periodic boundary requires a positive domain extent"); + const std::int64_t wraps = ghosts / extent + (ghosts % extent == 0 ? 0 : 1); + if (wraps > static_cast((std::numeric_limits::max() - 1) / 2)) + throw std::length_error("pops::mesh boundary axis schedule exceeds size_t"); + return 1 + 2 * static_cast(wraps); +} + +template +bool zero_shift(const Index& shift) noexcept { + for (int axis = 0; axis < Dim; ++axis) + if (shift[axis] != 0) + return false; + return true; +} + +template +struct AxisSlice { + int lower = 0; + int upper = -1; + bool boundary = false; + BoundaryOperation operation{}; +}; + +template +std::vector> make_axis_slices(const Box& domain, const Extent& ghosts, + const BoundaryTopology& topology, int axis) { + std::vector> result; + const std::int64_t depth = ghosts[axis]; + const std::int64_t extent = domain.length(axis); + const Face lower_face{axis, BoundarySide::lower}; + const Face upper_face{axis, BoundarySide::upper}; + const bool periodic = topology.is_periodic(lower_face); + const std::size_t count = checked_axis_segment_count(depth, extent, periodic); + result.reserve(count); + result.push_back(AxisSlice{domain.lo[axis], domain.hi[axis], false, {}}); + if (depth == 0) + return result; + + if (!periodic) { + result.push_back(AxisSlice{ + checked_index(checked_add(domain.lo[axis], -depth, + "pops::mesh lower physical ghost region overflows int64_t"), + "pops::mesh lower physical ghost region exceeds native index range"), + checked_index(static_cast(domain.lo[axis]) - 1, + "pops::mesh lower physical ghost region exceeds native index range"), + true, BoundaryOperation{lower_face, BoundaryFaceKind::physical, {}}}); + result.push_back(AxisSlice{ + checked_index(static_cast(domain.hi[axis]) + 1, + "pops::mesh upper physical ghost region exceeds native index range"), + checked_index(checked_add(domain.hi[axis], depth, + "pops::mesh upper physical ghost region overflows int64_t"), + "pops::mesh upper physical ghost region exceeds native index range"), + true, BoundaryOperation{upper_face, BoundaryFaceKind::physical, {}}}); + return result; + } + + const std::int64_t wraps = depth / extent + (depth % extent == 0 ? 0 : 1); + for (std::int64_t wrap = 1; wrap <= wraps; ++wrap) { + const std::int64_t previous = + checked_multiply(wrap - 1, extent, "pops::mesh periodic wrap offset overflow"); + const std::int64_t reached = + checked_multiply(wrap, extent, "pops::mesh periodic wrap offset overflow"); + const std::int64_t capped = std::min(depth, reached); + const int shift = checked_index(reached, "pops::mesh periodic shift exceeds Index range"); + + Index lower_shift{}; + lower_shift[axis] = shift; + result.push_back(AxisSlice{ + checked_index(checked_add(domain.lo[axis], -capped, + "pops::mesh lower periodic ghost region overflows int64_t"), + "pops::mesh lower periodic ghost region exceeds native index range"), + checked_index( + checked_add(checked_add(domain.lo[axis], -previous, + "pops::mesh lower periodic ghost region overflows int64_t"), + -1, "pops::mesh lower periodic ghost region overflows int64_t"), + "pops::mesh lower periodic ghost region exceeds native index range"), + true, BoundaryOperation{lower_face, BoundaryFaceKind::periodic, lower_shift}}); + + Index upper_shift{}; + upper_shift[axis] = -shift; + result.push_back(AxisSlice{ + checked_index( + checked_add(checked_add(domain.hi[axis], previous, + "pops::mesh upper periodic ghost region overflows int64_t"), + 1, "pops::mesh upper periodic ghost region overflows int64_t"), + "pops::mesh upper periodic ghost region exceeds native index range"), + checked_index(checked_add(domain.hi[axis], capped, + "pops::mesh upper periodic ghost region overflows int64_t"), + "pops::mesh upper periodic ghost region exceeds native index range"), + true, BoundaryOperation{upper_face, BoundaryFaceKind::periodic, upper_shift}}); + } + return result; +} + +} // namespace boundary_schedule_detail + +/// Canonicalizes the operation order and validates one composed record. In particular it rejects +/// two sides of one axis, physical shifts, tangential shifts, and periodic zero shifts; callers can +/// therefore compose independently authored face rules without last-writer-wins behavior. +template +BoundaryRegionPlan compose_boundary_region_plan( + const Box& destination, + std::type_identity_t, static_cast(Dim)>> + operations, + std::size_t operation_count) { + if (destination.empty()) + throw std::invalid_argument("pops::mesh boundary plan destination must be non-empty"); + if (operation_count == 0 || operation_count > static_cast(Dim)) + throw std::invalid_argument("pops::mesh boundary plan operation count is outside [1, Dim]"); + std::sort(operations.begin(), operations.begin() + static_cast(operation_count), + [](const BoundaryOperation& left, const BoundaryOperation& right) { + return face_less(left.face, right.face); + }); + + std::array, Dim> faces{}; + Index composed{}; + for (std::size_t index = 0; index < operation_count; ++index) { + const BoundaryOperation& operation = operations[index]; + faces[index] = operation.face; + if (index != 0 && operations[index - 1].face.axis == operation.face.axis) + throw std::invalid_argument( + "pops::mesh boundary composition has conflicting sides on one axis"); + if (operation.kind == BoundaryFaceKind::physical) { + if (!boundary_schedule_detail::zero_shift(operation.source_from_destination_shift)) + throw std::invalid_argument("pops::mesh physical boundary operation carries a shift"); + continue; + } + if (operation.source_from_destination_shift[operation.face.axis] == 0) + throw std::invalid_argument("pops::mesh periodic boundary operation carries a zero shift"); + for (int shift_axis = 0; shift_axis < Dim; ++shift_axis) { + const int value = operation.source_from_destination_shift[shift_axis]; + if (shift_axis != operation.face.axis && value != 0) + throw std::invalid_argument( + "pops::mesh axis-translation boundary operation carries a tangential shift"); + if (value != 0 && composed[shift_axis] != 0) + throw std::invalid_argument( + "pops::mesh boundary composition has conflicting translation contributors"); + if (value != 0) + composed[shift_axis] = value; + } + } + + return BoundaryRegionPlan{BoundaryRegion{faces, operation_count}, destination, + operations, static_cast(operation_count), composed}; +} + +/// Host owner for a backend-neutral array of fixed-size execution records. It performs no MPI, +/// Kokkos, callback, or field access; those execution layers consume this authenticated plan. +template +class BoundarySchedule { + public: + BoundarySchedule(Box domain, Extent ghosts, BoundaryTopology topology, + std::vector> entries) + : domain_(domain), ghosts_(ghosts), topology_(topology), entries_(std::move(entries)) {} + + const Box& domain() const noexcept { return domain_; } + const Extent& ghosts() const noexcept { return ghosts_; } + const BoundaryTopology& topology() const noexcept { return topology_; } + const std::vector>& entries() const noexcept { return entries_; } + std::size_t size() const noexcept { return entries_.size(); } + + private: + Box domain_{}; + Extent ghosts_{}; + BoundaryTopology topology_{}; + std::vector> entries_{}; +}; + +/// Enumerates disjoint face/edge/corner regions. Axis 0 is the fastest Cartesian schedule +/// coordinate; deep periodic ghosts are split into exact wrap-width strips instead of being mapped +/// by one insufficient shift. +template +BoundarySchedule prepare_boundary_schedule(const Box& domain, const Extent& ghosts, + const BoundaryTopology& topology, + BoundaryScheduleBudget budget) { + if (domain.empty()) + throw std::invalid_argument("pops::mesh boundary schedule requires a non-empty domain"); + + std::array>, Dim> axes; + std::array axis_counts{}; + std::size_t cartesian_count = 1; + for (int axis = 0; axis < Dim; ++axis) { + const Face lower{axis, BoundarySide::lower}; + axis_counts[axis] = boundary_schedule_detail::checked_axis_segment_count( + ghosts[axis], domain.length(axis), topology.is_periodic(lower)); + if (axis_counts[axis] > std::numeric_limits::max() / cartesian_count) + throw std::length_error("pops::mesh boundary schedule Cartesian size overflows size_t"); + cartesian_count *= axis_counts[axis]; + } + const std::size_t entry_count = cartesian_count - 1; + if (entry_count > budget.entries) + throw std::length_error("pops::mesh boundary schedule exceeds its explicit entry budget"); + + for (int axis = 0; axis < Dim; ++axis) + axes[axis] = boundary_schedule_detail::make_axis_slices(domain, ghosts, topology, axis); + + std::vector> entries; + if (entry_count > entries.max_size()) + throw std::length_error("pops::mesh boundary schedule exceeds vector capacity"); + entries.reserve(entry_count); + for (std::size_t ordinal = 1; ordinal < cartesian_count; ++ordinal) { + std::size_t quotient = ordinal; + Box destination{}; + std::array, Dim> operations{}; + std::size_t operation_count = 0; + for (int axis = 0; axis < Dim; ++axis) { + const auto& axis_slices = axes[axis]; + const auto& slice = axis_slices[quotient % axis_slices.size()]; + quotient /= axis_slices.size(); + destination.lo[axis] = slice.lower; + destination.hi[axis] = slice.upper; + if (slice.boundary) + operations[operation_count++] = slice.operation; + } + entries.push_back(compose_boundary_region_plan(destination, operations, operation_count)); + } + return BoundarySchedule{domain, ghosts, topology, std::move(entries)}; +} + +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); + +} // namespace pops diff --git a/include/pops/mesh/boundary/prepared_boundary_component.hpp b/include/pops/mesh/boundary/prepared_boundary_component.hpp index cdbef5140..676169d64 100644 --- a/include/pops/mesh/boundary/prepared_boundary_component.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_component.hpp @@ -57,7 +57,7 @@ struct PreparedBoundaryComponentSpec { std::shared_ptr execution; }; -enum class PreparedBoundaryOperation { GhostRegion, FieldResidual, FieldJvp }; +enum class PreparedBoundaryOperation { GhostRegion, FluxTransform, FieldResidual, FieldJvp }; /// One statically typed prepared component invocation. The operation is a template argument, never /// a production string branch: installation chooses one typed entry point and scientific calls retain @@ -86,8 +86,15 @@ class PreparedBoundaryComponent final { spec_.interface_version); } + [[nodiscard]] const PopsBoundaryFluxApiV1& boundary_flux_api() const { + static_assert(Operation == PreparedBoundaryOperation::FluxTransform); + return component_->table(POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, + spec_.interface_version); + } + [[nodiscard]] const PopsFieldBoundaryClosureApiV1& field_api() const { - static_assert(Operation != PreparedBoundaryOperation::GhostRegion); + static_assert(Operation == PreparedBoundaryOperation::FieldResidual || + Operation == PreparedBoundaryOperation::FieldJvp); return component_->table( POPS_NATIVE_INTERFACE_FIELD_BOUNDARY_CLOSURE_V1, spec_.interface_version); } @@ -159,8 +166,15 @@ class PreparedBoundaryComponent final { spec_.interface_version); } + const PopsBoundaryFluxApiV1& boundary_flux_api() const { + static_assert(Operation == PreparedBoundaryOperation::FluxTransform); + return component_->table(POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, + spec_.interface_version); + } + const PopsFieldBoundaryClosureApiV1& field_api() const { - static_assert(Operation != PreparedBoundaryOperation::GhostRegion); + static_assert(Operation == PreparedBoundaryOperation::FieldResidual || + Operation == PreparedBoundaryOperation::FieldJvp); return component_->table( POPS_NATIVE_INTERFACE_FIELD_BOUNDARY_CLOSURE_V1, spec_.interface_version); } @@ -177,6 +191,8 @@ class PreparedBoundaryComponent final { static constexpr PopsNativeInterfaceIdV1 native_interface_id_() { if constexpr (Operation == PreparedBoundaryOperation::GhostRegion) return POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1; + else if constexpr (Operation == PreparedBoundaryOperation::FluxTransform) + return POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1; else return POPS_NATIVE_INTERFACE_FIELD_BOUNDARY_CLOSURE_V1; } @@ -184,6 +200,8 @@ class PreparedBoundaryComponent final { const PopsComponentTableHeaderV1& table_header() const { if constexpr (Operation == PreparedBoundaryOperation::GhostRegion) return ghost_api().header; + else if constexpr (Operation == PreparedBoundaryOperation::FluxTransform) + return boundary_flux_api().header; else return field_api().header; } @@ -202,6 +220,14 @@ class PreparedBoundaryComponent final { component::validate_execution_context(spec_.execution->view()); if constexpr (Operation == PreparedBoundaryOperation::GhostRegion) { component::require_operation(ghost_api().apply_region_batch != nullptr, "apply_region_batch"); + } else if constexpr (Operation == PreparedBoundaryOperation::FluxTransform) { + component::require_operation(boundary_flux_api().transform_faces != nullptr, + "transform_faces"); + if (spec_.region.kind != POPS_BOUNDARY_FACE_V1 || spec_.region.codimension != 1 || + spec_.outputs.size() != 1 || spec_.outputs.front() != spec_.state_identity || + !spec_.directions.empty()) + throw std::invalid_argument( + "BoundaryFlux requires one oriented face, one state output and no direction table"); } else { component::require_operation( Operation == PreparedBoundaryOperation::FieldResidual ? field_api().residual != nullptr @@ -226,6 +252,8 @@ class PreparedBoundaryComponent final { using PreparedGhostBoundaryComponent = PreparedBoundaryComponent; +using PreparedBoundaryFluxComponent = + PreparedBoundaryComponent; using PreparedFieldBoundaryResidualComponent = PreparedBoundaryComponent; using PreparedFieldBoundaryJvpComponent = diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index b6711c619..43cdd9638 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -1,9 +1,10 @@ /// @file -/// @brief Executable, immutable boundary authority prepared before block construction. +/// @brief Executable boundary authority finalized before numerical execution. /// /// A PreparedBoundaryPlan is the executable native transport-face lowering of one resolved -/// GhostProducerPlan. Resolution and string/Handle dispatch happen once during installation, never -/// in a face-cell loop. The executed order is: +/// GhostProducerPlan. Resolution, one-time model conversion, component preparation and +/// string/Handle dispatch happen before a session is retained, never in a face-cell loop. The +/// executed order is: /// /// same-level/MPI + prepared periodic identifications -> physical faces. /// @@ -17,14 +18,20 @@ #include #include -#include +#include #include +#include #include #include +#include #include +#include +#include #include +#include #include +#include #include #include #include @@ -85,11 +92,115 @@ struct PreparedBoundaryReadDependencies { std::vector fields; }; -/// Native boundary plan captured by every block closure. Component BCs permit systems with -/// different Dirichlet data per conservative component while the topology (periodic vs physical) -/// remains common to the state. +namespace detail { + +inline void append_boundary_request_u64(std::string& payload, std::uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) + payload.push_back(static_cast((value >> shift) & UINT64_C(0xff))); +} + +inline void append_boundary_request_size(std::string& payload, std::size_t value) { + if constexpr (sizeof(std::size_t) > sizeof(std::uint64_t)) { + if (value > static_cast(std::numeric_limits::max())) + throw std::length_error("boundary request exceeds canonical uint64 length capacity"); + } + append_boundary_request_u64(payload, static_cast(value)); +} + +inline void append_boundary_request_bytes(std::string& payload, std::string_view value) { + append_boundary_request_size(payload, value.size()); + if (!value.empty()) + payload.append(value.data(), value.size()); +} + +inline void append_boundary_request_int(std::string& payload, int value) { + append_boundary_request_u64(payload, + static_cast(static_cast(value))); +} + +inline void append_boundary_request_strings(std::string& payload, + const std::vector& values) { + append_boundary_request_size(payload, values.size()); + for (const auto& value : values) + append_boundary_request_bytes(payload, value); +} + +inline void append_boundary_request_reals(std::string& payload, const std::vector& values) { + static_assert(sizeof(double) == sizeof(std::uint64_t)); + static_assert(std::numeric_limits::is_iec559, + "boundary request consensus requires IEEE-754 binary64"); + append_boundary_request_size(payload, values.size()); + for (double value : values) + append_boundary_request_u64(payload, std::bit_cast(value)); +} + +/// Exact byte identity of every argument that can affect one prepared boundary plan. Analytic +/// opcode/literal rows are included here as well as fixed-state and topology metadata so a +/// collectively prepared plan cannot diverge through a non-program field. +inline std::string canonical_prepared_boundary_plan_request( + std::string_view name, std::string_view identity, int required_depth, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, + const std::vector& omitted_interface_faces, std::string_view state_identity, + const PreparedBoundaryReadDependencies& read_dependencies, + const std::vector& periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { + std::string payload; + append_boundary_request_bytes(payload, "pops.prepared-boundary-plan.request.v1"); + append_boundary_request_bytes(payload, name); + append_boundary_request_bytes(payload, identity); + append_boundary_request_int(payload, required_depth); + append_boundary_request_strings(payload, face_types); + append_boundary_request_reals(payload, face_values); + append_boundary_request_strings(payload, face_identities); + append_boundary_request_strings(payload, component_roles); + append_boundary_request_size(payload, omitted_interface_faces.size()); + for (int face : omitted_interface_faces) + append_boundary_request_int(payload, face); + append_boundary_request_bytes(payload, state_identity); + append_boundary_request_strings(payload, read_dependencies.states); + append_boundary_request_strings(payload, read_dependencies.fields); + append_boundary_request_size(payload, periodic_identifications.size()); + for (const auto& periodic : periodic_identifications) { + append_boundary_request_int(payload, periodic.source_face); + append_boundary_request_int(payload, periodic.target_face); + for (int axis : periodic.permutation) + append_boundary_request_int(payload, axis); + for (int sign : periodic.signs) + append_boundary_request_int(payload, sign); + } + append_boundary_request_strings(payload, face_representations); + append_boundary_request_strings(payload, face_converter_identities); + append_boundary_request_size(payload, face_analytic_opcodes.size()); + for (const auto& row : face_analytic_opcodes) + append_boundary_request_strings(payload, row); + append_boundary_request_size(payload, face_analytic_literals.size()); + for (const auto& row : face_analytic_literals) + append_boundary_request_reals(payload, row); + append_boundary_request_strings(payload, face_analytic_clocks); + return payload; +} + +} // namespace detail + +/// Native boundary plan captured by every block closure. The built-in physical-face authority is +/// one model-aware hyperbolic plan; field/elliptic BCRec data is not a transport semantic here. class PreparedBoundaryPlan { + private: + struct BoundaryRecoveryWorkspace { + std::vector snapshot; + std::vector conserved; + std::vector primitive; + bool prepared = false; + }; + public: + using CharacteristicNoInflowFill = std::function; /// Move-only, lane-bound executable state for this immutable plan. /// /// Session construction is the sole materialization point for component-owned native state. Its @@ -103,22 +214,28 @@ class PreparedBoundaryPlan { lane_(std::exchange(other.lane_, nullptr)), component_revision_(std::exchange(other.component_revision_, 0)), ghost_components_(std::move(other.ghost_components_)), + flux_components_(std::move(other.flux_components_)), residual_components_(std::move(other.residual_components_)), jvp_components_(std::move(other.jvp_components_)), ghost_workspaces_(std::move(other.ghost_workspaces_)), + flux_workspaces_(std::move(other.flux_workspaces_)), residual_workspaces_(std::move(other.residual_workspaces_)), - jvp_workspaces_(std::move(other.jvp_workspaces_)) {} + jvp_workspaces_(std::move(other.jvp_workspaces_)), + recovery_workspace_(std::move(other.recovery_workspace_)) {} Session& operator=(Session&& other) noexcept { if (this != &other) { plan_ = std::exchange(other.plan_, nullptr); lane_ = std::exchange(other.lane_, nullptr); component_revision_ = std::exchange(other.component_revision_, 0); ghost_components_ = std::move(other.ghost_components_); + flux_components_ = std::move(other.flux_components_); residual_components_ = std::move(other.residual_components_); jvp_components_ = std::move(other.jvp_components_); ghost_workspaces_ = std::move(other.ghost_workspaces_); + flux_workspaces_ = std::move(other.flux_workspaces_); residual_workspaces_ = std::move(other.residual_workspaces_); jvp_workspaces_ = std::move(other.jvp_workspaces_); + recovery_workspace_ = std::move(other.recovery_workspace_); } return *this; } @@ -129,9 +246,15 @@ class PreparedBoundaryPlan { void fill_same_level_and_physical(MultiFab& state, const Box2D& domain) const; void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry) const; + void fill_same_level_and_physical( + MultiFab& state, const Geometry& geometry, + const runtime::multiblock::BoundaryEvaluationPoint& point) const; void fill_same_level_and_physical( MultiFab& state, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, const runtime::multiblock::BoundaryEvaluationPoint& point) const; + void transform_fluxes(const runtime::multiblock::BoundaryEvaluationPoint& point, + const MultiFab& state, const detail::BoundaryFieldRegistry& fields, + const Geometry& geometry, MultiFab& fx, MultiFab& fy) const; void add_residual(const runtime::multiblock::BoundaryEvaluationPoint& point, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry) const; void apply_jvp(const runtime::multiblock::BoundaryEvaluationPoint& point, @@ -143,10 +266,17 @@ class PreparedBoundaryPlan { void prepare_ghost_executor(const MultiFab& prototype, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry); + void prepare_flux_executor(const MultiFab& prototype, + const detail::BoundaryFieldRegistry& fields, + const Geometry& geometry); void prepare_residual_executor(const detail::BoundaryFieldRegistry& fields, const Geometry& geometry); void prepare_jvp_executor(const detail::BoundaryFieldRegistry& fields, const Geometry& geometry); + /// Allocate the exact lane-private transaction storage used to validate physical ghost traces. + /// Production GridContext sessions call this once while binding their prototype, never lazily + /// from a numerical fill. + void prepare_trace_recovery_workspace(const MultiFab& prototype); private: friend class PreparedBoundaryPlan; @@ -158,6 +288,9 @@ class PreparedBoundaryPlan { void fill_same_level_and_physical_control( MultiFab& state, const MultiFab* auxiliary, const Geometry& geometry, const runtime::multiblock::BoundaryEvaluationPoint& point) const; + void transform_fluxes_control(const runtime::multiblock::BoundaryEvaluationPoint& point, + const MultiFab& state, const MultiFab* auxiliary, + const Geometry& geometry, MultiFab& fx, MultiFab& fy) const; void add_residual_control(const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, const MultiFab* auxiliary, const Geometry& geometry, MultiFab& residual) const; @@ -170,22 +303,26 @@ class PreparedBoundaryPlan { const ExecutionLane* lane_ = nullptr; std::size_t component_revision_ = 0; std::vector ghost_components_; + std::vector flux_components_; std::vector residual_components_; std::vector jvp_components_; mutable std::vector ghost_workspaces_; + mutable std::vector flux_workspaces_; mutable std::vector residual_workspaces_; mutable std::vector jvp_workspaces_; + mutable BoundaryRecoveryWorkspace recovery_workspace_; }; PreparedBoundaryPlan() = default; - PreparedBoundaryPlan(std::string identity, int required_depth, std::vector component_bc, + PreparedBoundaryPlan(std::string identity, int required_depth, + PreparedHyperbolicBoundary<2> hyperbolic_boundary, std::vector omitted_face_ordinals = {}, std::string state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}, std::vector periodic_identifications = {}) : identity_(std::move(identity)), required_depth_(required_depth), - component_bc_(std::move(component_bc)), + hyperbolic_boundary_(std::move(hyperbolic_boundary)), state_identity_(std::move(state_identity)), read_dependencies_(std::move(read_dependencies)), periodic_identifications_(std::move(periodic_identifications)) { @@ -195,16 +332,79 @@ class PreparedBoundaryPlan { "PreparedBoundaryPlan omitted interface faces must be unique ordinals 0..3"); omitted_faces_[static_cast(face)] = true; } + for (int face = 0; face < 4; ++face) + if (omitted_faces_[static_cast(face)] && + (hyperbolic_boundary_.face(face / 2, face % 2 == 0 ? -1 : 1).law == + HyperbolicBoundaryLaw::NoFlux || + hyperbolic_boundary_.face(face / 2, face % 2 == 0 ? -1 : 1).law == + HyperbolicBoundaryLaw::CharacteristicNoInflow)) + throw std::invalid_argument( + "a prepared interface face cannot also be a physical no-flux/characteristic boundary"); validate_base(); } const std::string& identity() const { return identity_; } const std::string& state_identity() const { return state_identity_; } int required_depth() const { return required_depth_; } - int ncomp() const { return static_cast(component_bc_.size()); } + int ncomp() const { return hyperbolic_boundary_.ncomp(); } + const PreparedHyperbolicBoundary<2>& hyperbolic_boundary() const { return hyperbolic_boundary_; } + bool requires_fixed_state_conversion() const { + return hyperbolic_boundary_.requires_fixed_state_conversion(); + } + /// Complete one model-dependent preparation step before any execution session is retained. + /// + /// The revision increment invalidates any session or dependency token created too early instead + /// of allowing it to observe a changed numerical table. + void prepare_fixed_state_conversion( + const std::function& primitive_to_conservative) { + if (!requires_fixed_state_conversion()) + return; + hyperbolic_boundary_ = + hyperbolic_boundary_.with_converted_fixed_states(primitive_to_conservative); + ++component_revision_; + } + /// Attach the exact block-model conservative-to-primitive recovery used to authenticate every + /// produced physical ghost trace before it becomes visible to a reconstruction kernel. + void prepare_trace_recovery( + std::function conservative_to_primitive) { + if (!conservative_to_primitive) + throw std::invalid_argument( + "PreparedBoundaryPlan trace recovery requires a prepared block-model authority"); + if (trace_recovery_) + throw std::logic_error("PreparedBoundaryPlan trace recovery authority is already finalized"); + trace_recovery_ = std::move(conservative_to_primitive); + ++component_revision_; + } + bool has_trace_recovery() const noexcept { return static_cast(trace_recovery_); } + bool requires_characteristic_no_inflow() const noexcept { + return hyperbolic_boundary_.has_characteristic_no_inflow(); + } + void prepare_characteristic_no_inflow(CharacteristicNoInflowFill fill) { + if (!requires_characteristic_no_inflow()) + throw std::logic_error( + "PreparedBoundaryPlan cannot install an unrequested characteristic provider"); + if (!fill) + throw std::invalid_argument( + "PreparedBoundaryPlan characteristic no-inflow requires an executable model provider"); + if (characteristic_no_inflow_fill_) + throw std::logic_error( + "PreparedBoundaryPlan characteristic no-inflow provider is already finalized"); + characteristic_no_inflow_fill_ = std::move(fill); + ++component_revision_; + } const std::vector& periodic_identifications() const noexcept { return periodic_identifications_; } + bool has_mapped_periodicity() const noexcept { return has_mapped_periodicity_(); } + std::optional axis_aligned_periodicity() const noexcept { + const bool xlo = hyperbolic_boundary_.face(0, -1).law == HyperbolicBoundaryLaw::Periodic; + const bool xhi = hyperbolic_boundary_.face(0, 1).law == HyperbolicBoundaryLaw::Periodic; + const bool ylo = hyperbolic_boundary_.face(1, -1).law == HyperbolicBoundaryLaw::Periodic; + const bool yhi = hyperbolic_boundary_.face(1, 1).law == HyperbolicBoundaryLaw::Periodic; + if (xlo != xhi || ylo != yhi) + return std::nullopt; + return Periodicity{xlo, ylo}; + } /// Validate that an already allocated state can execute this prepared plan. Installation-time /// consumers use the same invariant as the fill path, without performing or probing a fill. void validate_state_layout(const MultiFab& state) const { validate_for(state); } @@ -212,33 +412,34 @@ class PreparedBoundaryPlan { return std::any_of(omitted_faces_.begin(), omitted_faces_.end(), [](bool value) { return value; }); } + bool has_zero_flux_faces() const noexcept { + for (int axis = 0; axis < 2; ++axis) + for (const int side : {-1, 1}) + if (zeroes_face(axis, side)) + return true; + return false; + } + bool zeroes_face(int axis, int side) const { + if (axis < 0 || axis >= 2 || (side != -1 && side != 1)) + throw std::invalid_argument("PreparedBoundaryPlan face selector is invalid"); + return hyperbolic_boundary_.face(axis, side).law == HyperbolicBoundaryLaw::NoFlux; + } bool omits_face(int axis, int side) const { if (axis < 0 || axis >= 2 || (side != -1 && side != 1)) throw std::invalid_argument("PreparedBoundaryPlan face selector is invalid"); return omitted_faces_[static_cast(2 * axis + (side > 0 ? 1 : 0))]; } - const BCRec& component_bc(int comp) const { - if (comp < 0 || comp >= ncomp()) - throw std::runtime_error("PreparedBoundaryPlan component index out of range"); - return component_bc_[static_cast(comp)]; - } - - /// Materialize the immutable face law on one exact grid metric. BCRec stores spacing because - /// Robin extensions need the cell-to-face distance; that spacing is execution geometry, not part - /// of a reusable boundary plan's identity. - BCRec component_bc(int comp, const Geometry& geometry) const { - BCRec result = component_bc(comp); - result.dx = geometry.dx(); - result.dy = geometry.dy(); - return result; - } - void install_ghost_component(PreparedBoundaryComponentSpec spec, std::shared_ptr component) { install_typed_(ghost_components_, std::move(spec), std::move(component)); ++component_revision_; } + void install_flux_component(PreparedBoundaryComponentSpec spec, + std::shared_ptr component) { + install_typed_(flux_components_, std::move(spec), std::move(component)); + ++component_revision_; + } void install_residual_component(PreparedBoundaryComponentSpec spec, std::shared_ptr component) { install_typed_(residual_components_, std::move(spec), std::move(component)); @@ -256,12 +457,14 @@ class PreparedBoundaryPlan { } bool has_component_boundaries() const { - return !ghost_components_.empty() || !residual_components_.empty() || !jvp_components_.empty(); + return !ghost_components_.empty() || !flux_components_.empty() || + !residual_components_.empty() || !jvp_components_.empty(); } + bool has_flux_transformations() const noexcept { return !flux_components_.empty(); } - /// The built-in BCRec laws fill every ghost layer allocated by the state. A dynamically loaded - /// ghost component is prepared only for this plan's authenticated required_depth(), so it keeps - /// the bounded-depth contract even though residual/JVP-only components do not affect ghost fill. + /// The built-in hyperbolic laws fill every ghost layer allocated by the state. A dynamically + /// loaded ghost component is prepared only for this plan's authenticated required_depth(), so it + /// keeps the bounded-depth contract even though residual/JVP-only components do not affect fill. bool fills_all_allocated_physical_ghosts() const noexcept { return ghost_components_.empty(); } /// Whether this plan owns an executable residual/JVP pair for an implicit operator. A partial @@ -321,6 +524,8 @@ class PreparedBoundaryPlan { std::vector result = read_dependencies_.fields; for (const auto& component : ghost_components_) append_unique_(result, component->spec().fields); + for (const auto& component : flux_components_) + append_unique_(result, component->spec().fields); for (const auto& component : residual_components_) append_unique_(result, component->spec().fields); for (const auto& component : jvp_components_) @@ -332,6 +537,8 @@ class PreparedBoundaryPlan { std::vector result = read_dependencies_.states; for (const auto& component : ghost_components_) append_unique_(result, component->spec().states); + for (const auto& component : flux_components_) + append_unique_(result, component->spec().states); for (const auto& component : residual_components_) append_unique_(result, component->spec().states); for (const auto& component : jvp_components_) @@ -394,22 +601,11 @@ class PreparedBoundaryPlan { Periodicity periodicity() const { validate_topology(); - const BCRec& bc = component_bc_.front(); - const bool xlo = bc.xlo == BCType::Periodic; - const bool xhi = bc.xhi == BCType::Periodic; - const bool ylo = bc.ylo == BCType::Periodic; - const bool yhi = bc.yhi == BCType::Periodic; - if (xlo != xhi || ylo != yhi) + const auto result = axis_aligned_periodicity(); + if (!result) throw std::logic_error( "axis-permuted periodic topology has no per-axis runtime Periodicity projection"); - return Periodicity{xlo, ylo}; - } - - bool requires_grid_metric() const { - return std::any_of(component_bc_.begin(), component_bc_.end(), [](const BCRec& bc) { - return bc.xlo == BCType::Robin || bc.xhi == BCType::Robin || bc.ylo == BCType::Robin || - bc.yhi == BCType::Robin; - }); + return *result; } /// Same-level/MPI and prepared periodic production are performed by the memoized native halo @@ -419,13 +615,15 @@ class PreparedBoundaryPlan { if (has_component_boundaries()) throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); - if (requires_grid_metric()) - throw std::invalid_argument( - "PreparedBoundaryPlan Robin boundaries require an exact Geometry metric"); validate_for(state); - fill_native_halos_(state, domain); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, component_bc(comp), comp); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, domain); + BoundaryRecoveryWorkspace workspace; + fill_with_trace_recovery_transaction_( + state, domain, world_communicator_view(), workspace, false, [&] { + fill_native_halos_(state, domain); + fill_prepared_physical_(state, domain, world_communicator_view(), + std::move(physical_preflight)); + }); } void fill_same_level_and_physical(MultiFab& state, const Box2D& domain, @@ -433,13 +631,15 @@ class PreparedBoundaryPlan { if (has_component_boundaries()) throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); - if (requires_grid_metric()) - throw std::invalid_argument( - "PreparedBoundaryPlan Robin boundaries require an exact Geometry metric"); validate_for(state); - fill_native_halos_(state, domain, lane); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, component_bc(comp), comp); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, domain); + BoundaryRecoveryWorkspace workspace; + fill_with_trace_recovery_transaction_( + state, domain, lane.communicator(), workspace, false, [&] { + fill_native_halos_(state, domain, lane); + fill_prepared_physical_(state, domain, lane.communicator(), + std::move(physical_preflight)); + }); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry) const { @@ -447,9 +647,14 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); - fill_native_halos_(state, geometry.domain); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, component_bc(comp, geometry), comp); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, geometry); + BoundaryRecoveryWorkspace workspace; + fill_with_trace_recovery_transaction_( + state, geometry.domain, world_communicator_view(), workspace, false, [&] { + fill_native_halos_(state, geometry.domain); + fill_prepared_physical_(state, geometry.domain, world_communicator_view(), + std::move(physical_preflight)); + }); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry, @@ -458,9 +663,15 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); - fill_native_halos_(state, geometry.domain, lane); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, component_bc(comp, geometry), comp); + auto physical_preflight = + hyperbolic_boundary_.preflight_physical(state, geometry, lane.communicator()); + BoundaryRecoveryWorkspace workspace; + fill_with_trace_recovery_transaction_( + state, geometry.domain, lane.communicator(), workspace, false, [&] { + fill_native_halos_(state, geometry.domain, lane); + fill_prepared_physical_(state, geometry.domain, lane.communicator(), + std::move(physical_preflight)); + }); } /// One-shot control/diagnostic adapter. It materializes a fresh component session and workspace; @@ -470,6 +681,7 @@ class PreparedBoundaryPlan { const runtime::multiblock::BoundaryEvaluationPoint& point) const { const auto lane = ExecutionLane::world(identity_, "::boundary-control"); auto session = make_session(lane); + session.prepare_trace_recovery_workspace(state); session.fill_same_level_and_physical_control(state, auxiliary, geometry, point); } @@ -479,6 +691,7 @@ class PreparedBoundaryPlan { // Honest control-path convenience: callers that execute repeatedly retain make_session(lane) // and invoke it directly, avoiding preparation and allocation in the numerical hot path. auto session = make_session(lane); + session.prepare_trace_recovery_workspace(state); session.fill_same_level_and_physical_control(state, auxiliary, geometry, point); } @@ -489,6 +702,7 @@ class PreparedBoundaryPlan { const runtime::multiblock::BoundaryEvaluationPoint& point) const { const auto lane = ExecutionLane::world(identity_, "::boundary-control"); auto session = make_session(lane); + session.prepare_trace_recovery_workspace(state); session.prepare_ghost_executor(state, fields, geometry); session.fill_same_level_and_physical(state, fields, geometry, point); } @@ -497,6 +711,7 @@ class PreparedBoundaryPlan { MultiFab& state, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, const runtime::multiblock::BoundaryEvaluationPoint& point, const ExecutionLane& lane) const { auto session = make_session(lane); + session.prepare_trace_recovery_workspace(state); session.prepare_ghost_executor(state, fields, geometry); session.fill_same_level_and_physical(state, fields, geometry, point); } @@ -562,6 +777,16 @@ class PreparedBoundaryPlan { session.apply_jvp(point, fields, geometry); } + void transform_fluxes_control(const runtime::multiblock::BoundaryEvaluationPoint& point, + const MultiFab& state, const MultiFab* auxiliary, + const Geometry& geometry, MultiFab& fx, MultiFab& fy, + const ExecutionLane& lane = ExecutionLane::world()) const { + if (!has_flux_transformations()) + return; + auto session = make_session(lane); + session.transform_fluxes_control(point, state, auxiliary, geometry, fx, fy); + } + void apply_jvp_control(const runtime::multiblock::BoundaryEvaluationPoint& point, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, const ExecutionLane& lane) const { @@ -575,14 +800,17 @@ class PreparedBoundaryPlan { std::string identity_; int required_depth_ = 0; - std::vector component_bc_; + PreparedHyperbolicBoundary<2> hyperbolic_boundary_; std::array omitted_faces_{{false, false, false, false}}; std::string state_identity_; PreparedBoundaryReadDependencies read_dependencies_; std::vector periodic_identifications_; std::vector> ghost_components_; + std::vector> flux_components_; std::vector> residual_components_; std::vector> jvp_components_; + std::function trace_recovery_; + CharacteristicNoInflowFill characteristic_no_inflow_fill_; std::size_t component_revision_ = 0; template @@ -667,10 +895,6 @@ class PreparedBoundaryPlan { "PreparedBoundaryPlan residual/JVP direction identities are not executable"); } - static std::array face_types(const BCRec& bc) { - return {bc.xlo, bc.xhi, bc.ylo, bc.yhi}; - } - bool has_mapped_periodicity_() const noexcept { return std::any_of(periodic_identifications_.begin(), periodic_identifications_.end(), [](const PeriodicIdentification2D& identification) { @@ -692,13 +916,221 @@ class PreparedBoundaryPlan { fill_boundary(state, domain, lane, periodicity()); } + template + void fill_prepared_physical_(MultiFab& state, const Box2D& domain, CommunicatorView communicator, + PhysicalPreflight&& physical_preflight) const { + hyperbolic_boundary_.fill_physical_preflighted( + state, std::forward(physical_preflight)); + if (requires_characteristic_no_inflow()) { + if (!characteristic_no_inflow_fill_) + throw std::logic_error( + "characteristic no-inflow reached execution without its exact block-model provider"); + characteristic_no_inflow_fill_(state, domain, communicator); + } + } + + static std::size_t ghost_snapshot_value_count_(const MultiFab& state) { + std::size_t cells = 0; + for (int local = 0; local < state.local_size(); ++local) { + const std::int64_t ghost_cells = + state.fab(local).grown_box().num_cells() - state.box(local).num_cells(); + if (ghost_cells < 0) + throw std::logic_error("PreparedBoundaryPlan observed an invalid grown state box"); + const auto count = static_cast(ghost_cells); + if (count > std::numeric_limits::max() - cells) + throw std::length_error("PreparedBoundaryPlan ghost transaction exceeds size_t"); + cells += count; + } + const auto components = static_cast(state.ncomp()); + if (components != 0 && cells > std::numeric_limits::max() / components) + throw std::length_error("PreparedBoundaryPlan ghost transaction exceeds size_t"); + return cells * components; + } + + void prepare_boundary_recovery_workspace_(const MultiFab& prototype, + BoundaryRecoveryWorkspace& workspace) const { + validate_for(prototype); + const bool recover_traces = trace_recovery_ && has_physical_trace_faces_(); + const bool rollback_characteristics = requires_characteristic_no_inflow(); + if (!recover_traces && !rollback_characteristics) { + workspace = {}; + return; + } + workspace.snapshot.resize(ghost_snapshot_value_count_(prototype)); + if (recover_traces) { + workspace.conserved.resize(static_cast(prototype.ncomp())); + workspace.primitive.resize(static_cast(prototype.ncomp())); + } else { + workspace.conserved.clear(); + workspace.primitive.clear(); + } + workspace.prepared = true; + } + + bool has_physical_trace_faces_() const { + for (int face = 0; face < 4; ++face) + if (const auto law = hyperbolic_boundary_.face(face / 2, face % 2 == 0 ? -1 : 1).law; + detail::is_physical_hyperbolic_law(law) || + law == HyperbolicBoundaryLaw::CharacteristicNoInflow) + return true; + return false; + } + + static void snapshot_ghost_values_(MultiFab& state, std::vector& snapshot) { + state.sync_host(); + std::size_t cursor = 0; + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& fab = state.fab(local); + const Box2D valid = fab.box(); + const Box2D grown = fab.grown_box(); + for (int component = 0; component < state.ncomp(); ++component) + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + if (!valid.contains(i, j)) + snapshot[cursor++] = fab(i, j, component); + } + if (cursor != snapshot.size()) + throw std::logic_error("PreparedBoundaryPlan ghost snapshot size changed after preparation"); + state.sync_device(); + } + + static void restore_ghost_values_(MultiFab& state, const std::vector& snapshot) { + state.sync_host(); + std::size_t cursor = 0; + for (int local = 0; local < state.local_size(); ++local) { + Fab2D& fab = state.fab(local); + const Box2D valid = fab.box(); + const Box2D grown = fab.grown_box(); + for (int component = 0; component < state.ncomp(); ++component) + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + if (!valid.contains(i, j)) + fab(i, j, component) = snapshot[cursor++]; + } + if (cursor != snapshot.size()) + throw std::logic_error("PreparedBoundaryPlan ghost restore size changed after preparation"); + state.sync_device(); + } + + template + void for_each_physical_trace_cell_(const MultiFab& state, const Box2D& domain, + Visitor&& visitor) const { + const int depth = state.n_grow(); + const auto physical = [this](int face) { + const auto law = hyperbolic_boundary_.face(face / 2, face % 2 == 0 ? -1 : 1).law; + return detail::is_physical_hyperbolic_law(law) || + law == HyperbolicBoundaryLaw::CharacteristicNoInflow; + }; + const auto visit = [&visitor](const Fab2D& fab, const Box2D& region) { + for (int j = region.lo[1]; j <= region.hi[1]; ++j) + for (int i = region.lo[0]; i <= region.hi[0]; ++i) + visitor(fab, i, j); + }; + + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& fab = state.fab(local); + const Box2D valid = fab.box(); + int tangential_lo = valid.lo[1] - depth; + int tangential_hi = valid.hi[1] + depth; + if (hyperbolic_boundary_.face(1, -1).law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[1]); + if (hyperbolic_boundary_.face(1, 1).law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[1]); + if (physical(0) && valid.lo[0] == domain.lo[0]) + visit(fab, Box2D{{domain.lo[0] - depth, tangential_lo}, {domain.lo[0] - 1, tangential_hi}}); + if (physical(1) && valid.hi[0] == domain.hi[0]) + visit(fab, Box2D{{domain.hi[0] + 1, tangential_lo}, {domain.hi[0] + depth, tangential_hi}}); + + tangential_lo = valid.lo[0] - depth; + tangential_hi = valid.hi[0] + depth; + if (hyperbolic_boundary_.face(0, -1).law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[0]); + if (hyperbolic_boundary_.face(0, 1).law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[0]); + if (physical(2) && valid.lo[1] == domain.lo[1]) + visit(fab, Box2D{{tangential_lo, domain.lo[1] - depth}, {tangential_hi, domain.lo[1] - 1}}); + if (physical(3) && valid.hi[1] == domain.hi[1]) + visit(fab, Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}); + } + } + + void require_recoverable_physical_traces_(const MultiFab& state, const Box2D& domain, + CommunicatorView communicator, + BoundaryRecoveryWorkspace& workspace) const { + state.sync_host(); + long local_failures = 0; + for_each_physical_trace_cell_(state, domain, [&](const Fab2D& fab, int i, int j) { + for (int component = 0; component < state.ncomp(); ++component) + workspace.conserved[static_cast(component)] = fab(i, j, component); + std::fill(workspace.primitive.begin(), workspace.primitive.end(), + std::numeric_limits::quiet_NaN()); + try { + const RecoveryReport report = + trace_recovery_(workspace.conserved.data(), workspace.primitive.data()); + const bool finite = std::all_of(workspace.conserved.begin(), workspace.conserved.end(), + [](double value) { return std::isfinite(value); }) && + std::all_of(workspace.primitive.begin(), workspace.primitive.end(), + [](double value) { return std::isfinite(value); }); + if (!report.publication_permitted() || !finite) + ++local_failures; + } catch (...) { + // A rank-local provider exception is data, not control flow: every peer still reaches the + // one collective verdict before the transaction either commits or restores its snapshot. + ++local_failures; + } + }); + const long failures = all_reduce_sum(local_failures, communicator); + if (failures != 0) + throw std::runtime_error( + "PreparedBoundaryPlan prepared variable recovery rejected physical boundary traces " + "before publication (failed cells=" + + std::to_string(failures) + ")"); + state.sync_device(); + } + + template + void fill_with_trace_recovery_transaction_(MultiFab& state, const Box2D& domain, + CommunicatorView communicator, + BoundaryRecoveryWorkspace& workspace, + bool require_prepared_workspace, Fill&& fill) const { + const bool recover_traces = trace_recovery_ && has_physical_trace_faces_(); + const bool rollback_characteristics = requires_characteristic_no_inflow(); + if (!recover_traces && !rollback_characteristics) { + std::forward(fill)(); + return; + } + if (!workspace.prepared) { + if (require_prepared_workspace) + throw std::logic_error( + "PreparedBoundaryPlan trace recovery workspace was not materialized before execution"); + prepare_boundary_recovery_workspace_(state, workspace); + } + if (workspace.snapshot.size() != ghost_snapshot_value_count_(state) || + (recover_traces && (workspace.conserved.size() != static_cast(state.ncomp()) || + workspace.primitive.size() != static_cast(state.ncomp())))) + throw std::logic_error( + "PreparedBoundaryPlan trace recovery workspace does not match the execution layout"); + + snapshot_ghost_values_(state, workspace.snapshot); + try { + std::forward(fill)(); + if (recover_traces) + require_recoverable_physical_traces_(state, domain, communicator, workspace); + } catch (...) { + device_fence(); + restore_ghost_values_(state, workspace.snapshot); + throw; + } + } + void validate_base() const { if (identity_.empty()) throw std::runtime_error("PreparedBoundaryPlan requires a canonical identity"); if (required_depth_ < 1) throw std::runtime_error("PreparedBoundaryPlan required depth must be >= 1"); - if (component_bc_.empty()) - throw std::runtime_error("PreparedBoundaryPlan requires one BC record per component"); + if (hyperbolic_boundary_.ncomp() < 1) + throw std::runtime_error( + "PreparedBoundaryPlan requires one model-aware component transform per state component"); validate_read_dependencies_(read_dependencies_.states, "state"); validate_read_dependencies_(read_dependencies_.fields, "field"); validate_topology(); @@ -720,22 +1152,13 @@ class PreparedBoundaryPlan { } void validate_topology() const { - if (component_bc_.empty()) - throw std::runtime_error("PreparedBoundaryPlan has no component BCs"); - const auto expected = face_types(component_bc_.front()); - for (std::size_t comp = 1; comp < component_bc_.size(); ++comp) { - const auto actual = face_types(component_bc_[comp]); - for (std::size_t face = 0; face < actual.size(); ++face) { - const bool expected_periodic = expected[face] == BCType::Periodic; - const bool actual_periodic = actual[face] == BCType::Periodic; - if (expected_periodic != actual_periodic) - throw std::runtime_error( - "PreparedBoundaryPlan periodic/physical topology differs between components"); - } - } + std::array periodic{}; + for (int face = 0; face < 4; ++face) + periodic[static_cast(face)] = + hyperbolic_boundary_.face(face / 2, face % 2 == 0 ? -1 : 1).law == + HyperbolicBoundaryLaw::Periodic; if (periodic_identifications_.empty()) { - if ((expected[0] == BCType::Periodic) != (expected[1] == BCType::Periodic) || - (expected[2] == BCType::Periodic) != (expected[3] == BCType::Periodic)) + if (periodic[0] != periodic[1] || periodic[2] != periodic[3]) throw std::runtime_error( "axis-aligned PreparedBoundaryPlan requires periodic faces in complete axis pairs"); return; @@ -749,26 +1172,39 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan assigns one face to multiple periodic identifications"); claimed[static_cast(face)] = true; - if (expected[static_cast(face)] != BCType::Periodic) + if (!periodic[static_cast(face)]) throw std::runtime_error( "PreparedBoundaryPlan periodic identification endpoint is not a periodic face"); } } - for (std::size_t face = 0; face < expected.size(); ++face) - if ((expected[face] == BCType::Periodic) != claimed[face]) + for (std::size_t face = 0; face < periodic.size(); ++face) + if (periodic[face] != claimed[face]) throw std::runtime_error( "PreparedBoundaryPlan periodic face table differs from explicit identifications"); if (has_mapped_periodicity_() && periodic_identifications_.size() != 1) throw std::runtime_error( "PreparedBoundaryPlan mapped periodic topology currently requires one identification; " "mixed periodic corners need a composed scheduler"); + if (has_mapped_periodicity_() && hyperbolic_boundary_.has_analytic_state()) + throw std::runtime_error( + "PreparedBoundaryPlan analytic faces do not yet support mapped periodic coordinates; " + "install an axis-aligned periodic identification or a prepared coordinate map"); + if (has_mapped_periodicity_()) + for (int component = 0; component < hyperbolic_boundary_.ncomp(); ++component) + if (hyperbolic_boundary_.component_transform(component).parity != + HyperbolicComponentParity::Scalar) + throw std::runtime_error( + "mapped periodic topology currently supports scalar component transforms only; " + "vector and axial states require a model-aware component map"); } - void validate_for(const MultiFab& state) const { if (state.ncomp() != ncomp()) throw std::runtime_error("PreparedBoundaryPlan component count does not match block state"); if (state.n_grow() < required_depth_) throw std::runtime_error("PreparedBoundaryPlan stencil depth exceeds allocated ghosts"); + if (requires_characteristic_no_inflow() && !characteristic_no_inflow_fill_) + throw std::runtime_error( + "PreparedBoundaryPlan characteristic no-inflow has no authenticated model provider"); } }; @@ -777,10 +1213,13 @@ inline PreparedBoundaryPlan::Session::Session(const PreparedBoundaryPlan& plan, : plan_(&plan), lane_(&lane), component_revision_(plan.component_revision_) { plan.validate_base(); ghost_components_.reserve(plan.ghost_components_.size()); + flux_components_.reserve(plan.flux_components_.size()); residual_components_.reserve(plan.residual_components_.size()); jvp_components_.reserve(plan.jvp_components_.size()); for (const auto& component : plan.ghost_components_) ghost_components_.push_back(component->make_session(lane)); + for (const auto& component : plan.flux_components_) + flux_components_.push_back(component->make_session(lane)); for (const auto& component : plan.residual_components_) residual_components_.push_back(component->make_session(lane)); for (const auto& component : plan.jvp_components_) @@ -795,19 +1234,26 @@ inline void PreparedBoundaryPlan::Session::validate_current_() const { "PreparedBoundaryPlan was modified after its execution session was materialized"); } +inline void PreparedBoundaryPlan::Session::prepare_trace_recovery_workspace( + const MultiFab& prototype) { + validate_current_(); + plan_->prepare_boundary_recovery_workspace_(prototype, recovery_workspace_); +} + inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical(MultiFab& state, const Box2D& domain) const { validate_current_(); if (!ghost_components_.empty()) throw std::invalid_argument( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); - if (plan_->requires_grid_metric()) - throw std::invalid_argument( - "PreparedBoundaryPlan Robin boundaries require an exact Geometry metric"); plan_->validate_for(state); - plan_->fill_native_halos_(state, domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, plan_->component_bc(comp), comp); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical(state, domain); + plan_->fill_with_trace_recovery_transaction_( + state, domain, lane_->communicator(), recovery_workspace_, true, [&] { + plan_->fill_native_halos_(state, domain, *lane_); + plan_->fill_prepared_physical_(state, domain, lane_->communicator(), + std::move(physical_preflight)); + }); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( @@ -817,9 +1263,32 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( throw std::invalid_argument( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); plan_->validate_for(state); - plan_->fill_native_halos_(state, geometry.domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + auto physical_preflight = + plan_->hyperbolic_boundary_.preflight_physical(state, geometry, lane_->communicator()); + plan_->fill_with_trace_recovery_transaction_( + state, geometry.domain, lane_->communicator(), recovery_workspace_, true, [&] { + plan_->fill_native_halos_(state, geometry.domain, *lane_); + plan_->fill_prepared_physical_(state, geometry.domain, lane_->communicator(), + std::move(physical_preflight)); + }); +} + +inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( + MultiFab& state, const Geometry& geometry, + const runtime::multiblock::BoundaryEvaluationPoint& point) const { + validate_current_(); + if (!ghost_components_.empty()) + throw std::invalid_argument( + "PreparedBoundaryPlan component session requires its prepared field registry"); + plan_->validate_for(state); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical( + state, geometry, static_cast(point.physical_time), point.clock, lane_->communicator()); + plan_->fill_with_trace_recovery_transaction_( + state, geometry.domain, lane_->communicator(), recovery_workspace_, true, [&] { + plan_->fill_native_halos_(state, geometry.domain, *lane_); + plan_->fill_prepared_physical_(state, geometry.domain, lane_->communicator(), + std::move(physical_preflight)); + }); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( @@ -827,47 +1296,103 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( const runtime::multiblock::BoundaryEvaluationPoint& point) const { validate_current_(); plan_->validate_for(state); - plan_->fill_native_halos_(state, geometry.domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical( + state, geometry, static_cast(point.physical_time), point.clock, lane_->communicator()); + plan_->fill_with_trace_recovery_transaction_( + state, geometry.domain, lane_->communicator(), recovery_workspace_, true, [&] { + plan_->fill_native_halos_(state, geometry.domain, *lane_); + plan_->fill_prepared_physical_(state, geometry.domain, lane_->communicator(), + std::move(physical_preflight)); + detail::BoundaryFieldRegistry fields; + fields.configure_states(plan_->required_state_identities()); + fields.configure_fields(plan_->required_field_identities()); + fields.begin_binding(); + const auto states = plan_->required_state_identities(); + if (states.size() != 1 || states.front() != plan_->state_identity()) + throw std::runtime_error( + "component boundary with multiple states requires the N-ary prepared registry seam"); + fields.bind_state(states.front(), state); + const auto dependencies = plan_->required_field_identities(); + if (!dependencies.empty()) { + if (dependencies.size() != 1 || auxiliary == nullptr) + throw std::runtime_error( + "component boundary fields require the N-ary prepared registry seam"); + fields.bind_field(dependencies.front(), *auxiliary); + } + for (std::size_t index = 0; index < ghost_components_.size(); ++index) { + auto workspace = detail::prepare_ghost_workspace(ghost_components_[index], state, fields, + geometry, plan_->required_depth_); + detail::apply_ghost_component(ghost_components_[index], workspace, state, fields, + geometry, point); + } + }); +} + +inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( + MultiFab& state, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, + const runtime::multiblock::BoundaryEvaluationPoint& point) const { + validate_current_(); + plan_->validate_for(state); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical( + state, geometry, static_cast(point.physical_time), point.clock, lane_->communicator()); + plan_->fill_with_trace_recovery_transaction_( + state, geometry.domain, lane_->communicator(), recovery_workspace_, true, [&] { + plan_->fill_native_halos_(state, geometry.domain, *lane_); + plan_->fill_prepared_physical_(state, geometry.domain, lane_->communicator(), + std::move(physical_preflight)); + if (ghost_workspaces_.size() != ghost_components_.size()) + throw std::logic_error( + "PreparedBoundaryPlan ghost executor was not materialized before numerical " + "execution"); + for (std::size_t index = 0; index < ghost_components_.size(); ++index) + detail::apply_ghost_component(ghost_components_[index], ghost_workspaces_[index], state, + fields, geometry, point); + }); +} + +inline void PreparedBoundaryPlan::Session::transform_fluxes_control( + const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, + const MultiFab* auxiliary, const Geometry& geometry, MultiFab& fx, MultiFab& fy) const { + validate_current_(); + if (flux_components_.empty()) + return; detail::BoundaryFieldRegistry fields; fields.configure_states(plan_->required_state_identities()); fields.configure_fields(plan_->required_field_identities()); fields.begin_binding(); const auto states = plan_->required_state_identities(); - if (states.size() != 1 || states.front() != plan_->state_identity()) - throw std::runtime_error( - "component boundary with multiple states requires the N-ary prepared registry seam"); - fields.bind_state(states.front(), state); + if (!states.empty()) { + if (states.size() != 1 || states.front() != plan_->state_identity()) + throw std::runtime_error( + "post-Riemann boundary flux with multiple states requires the N-ary prepared registry " + "seam"); + fields.bind_state(states.front(), state); + } const auto dependencies = plan_->required_field_identities(); if (!dependencies.empty()) { if (dependencies.size() != 1 || auxiliary == nullptr) throw std::runtime_error( - "component boundary fields require the N-ary prepared registry seam"); + "post-Riemann boundary flux fields require the N-ary prepared registry seam"); fields.bind_field(dependencies.front(), *auxiliary); } - for (std::size_t index = 0; index < ghost_components_.size(); ++index) { - auto workspace = detail::prepare_ghost_workspace(ghost_components_[index], state, fields, - geometry, plan_->required_depth_); - detail::apply_ghost_component(ghost_components_[index], workspace, state, fields, geometry, - point); + for (const auto& component : flux_components_) { + auto workspace = detail::prepare_boundary_flux_workspace(component, state, fields, geometry); + detail::apply_boundary_flux_component(component, workspace, state, fields, geometry, fx, fy, + point); } } -inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( - MultiFab& state, const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, - const runtime::multiblock::BoundaryEvaluationPoint& point) const { +inline void PreparedBoundaryPlan::Session::transform_fluxes( + const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, + const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, MultiFab& fx, + MultiFab& fy) const { validate_current_(); - plan_->validate_for(state); - plan_->fill_native_halos_(state, geometry.domain, *lane_); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); - if (ghost_workspaces_.size() != ghost_components_.size()) + if (flux_workspaces_.size() != flux_components_.size()) throw std::logic_error( - "PreparedBoundaryPlan ghost executor was not materialized before numerical execution"); - for (std::size_t index = 0; index < ghost_components_.size(); ++index) - detail::apply_ghost_component(ghost_components_[index], ghost_workspaces_[index], state, fields, - geometry, point); + "PreparedBoundaryPlan flux executor was not materialized before numerical execution"); + for (std::size_t index = 0; index < flux_components_.size(); ++index) + detail::apply_boundary_flux_component(flux_components_[index], flux_workspaces_[index], state, + fields, geometry, fx, fy, point); } inline void PreparedBoundaryPlan::Session::add_residual_control( @@ -979,6 +1504,18 @@ inline void PreparedBoundaryPlan::Session::prepare_ghost_executor( geometry, plan_->required_depth_)); } +inline void PreparedBoundaryPlan::Session::prepare_flux_executor( + const MultiFab& prototype, const detail::BoundaryFieldRegistry& fields, + const Geometry& geometry) { + validate_current_(); + plan_->validate_for(prototype); + flux_workspaces_.clear(); + flux_workspaces_.reserve(flux_components_.size()); + for (const auto& component : flux_components_) + flux_workspaces_.push_back( + detail::prepare_boundary_flux_workspace(component, prototype, fields, geometry)); +} + inline void PreparedBoundaryPlan::Session::prepare_residual_executor( const detail::BoundaryFieldRegistry& fields, const Geometry& geometry) { validate_current_(); diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp new file mode 100644 index 000000000..be0a949ef --- /dev/null +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -0,0 +1,1130 @@ +/// @file +/// @brief One prepared, model-aware physical-boundary authority for hyperbolic state transport. +/// +/// Boundary topology (periodic/external) and physical law (extrapolation, fixed state, reflective +/// slip wall) are represented independently. Component transforms are resolved from model roles +/// before a numerical loop; face kernels therefore execute one immutable table without model +/// switches, component-index inference, Python callbacks, or per-cell allocation. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { + +enum class HyperbolicBoundaryLaw { + Periodic, + Extrapolate, + FixedState, + CharacteristicNoInflow, + NoFlux, + ReflectiveSlip, + External +}; + +enum class HyperbolicComponentParity { Scalar, PolarVector, AxialVector }; + +/// Representation in which a fixed-state face was authored. +/// +/// Native face kernels consume conservative values. Primitive data therefore remains explicitly +/// pending until the owning compiled block supplies its exact pointwise model conversion. +enum class HyperbolicStateRepresentation { Conservative, Primitive }; + +/// Reflection behavior of one model-qualified state component. +/// +/// A polar vector reverses its normal component at a reflective plane. An axial vector applies +/// det(R)R, so its normal component is preserved and every tangential component reverses. Scalars +/// are even. The axis is a three-dimensional physical-component axis, never inferred from the +/// component index. It is intentionally independent of @p Dim: a 1D/2D mesh may evolve transverse +/// polar components or an out-of-plane axial component (the usual 2.5D case). +template +struct HyperbolicComponentTransform { + static_assert(Dim >= 1 && Dim <= 3); + + HyperbolicComponentParity parity = HyperbolicComponentParity::Scalar; + int axis = -1; + + static HyperbolicComponentTransform scalar() { return {}; } + static HyperbolicComponentTransform polar_vector(int component_axis) { + if (component_axis < 0 || component_axis >= 3) + throw std::invalid_argument( + "polar boundary component axis is outside the physical x/y/z embedding"); + return {HyperbolicComponentParity::PolarVector, component_axis}; + } + static HyperbolicComponentTransform axial_vector(int component_axis) { + if (component_axis < 0 || component_axis >= 3) + throw std::invalid_argument( + "axial boundary component axis is outside the physical x/y/z embedding"); + return {HyperbolicComponentParity::AxialVector, component_axis}; + } + + POPS_HD Real reflection_sign(int normal_axis) const { + if (parity == HyperbolicComponentParity::Scalar) + return Real(1); + const bool normal_component = axis == normal_axis; + if (parity == HyperbolicComponentParity::PolarVector) + return normal_component ? Real(-1) : Real(1); + return normal_component ? Real(1) : Real(-1); + } +}; + +/// Exact axis-aligned face context supplied to a prepared physical law. +/// +/// The pointer fields are optional device-accessible packs. Built-in constant/extrapolation/wall +/// laws do not read them; compiled analytic providers may consume them without a Python callback. +template +struct HyperbolicFaceContext { + static_assert(Dim >= 1 && Dim <= 3); + + int axis = 0; + int side = -1; + std::array coordinate{}; + std::array normal{}; + std::array, (Dim > 1 ? Dim - 1 : 0)> tangents{}; + Real metric = Real(1); + Real area = Real(1); + Real time = Real(0); + const Real* runtime_parameters = nullptr; + int runtime_parameter_count = 0; + const Real* auxiliary_values = nullptr; + int auxiliary_value_count = 0; + std::uint64_t boundary_identity = 0; +}; + +struct PreparedHyperbolicFace { + HyperbolicBoundaryLaw law = HyperbolicBoundaryLaw::Periodic; + std::string identity; + std::uint64_t identity_token = 0; + std::vector fixed_state; + HyperbolicStateRepresentation authored_representation = + HyperbolicStateRepresentation::Conservative; + std::string converter_identity; + bool fixed_state_converted = true; + std::vector analytic_state; + std::string analytic_clock; +}; + +/// Dimension-split FV stencils do not read double-physical corners. Such corners are therefore +/// explicitly excluded rather than being assigned an implicit X-then-Y precedence. +enum class HyperbolicCornerPolicy { NotRequired }; + +namespace detail { + +inline std::uint64_t stable_boundary_identity(std::string_view identity) { + if (identity.empty()) + throw std::invalid_argument("hyperbolic boundary identity must be non-empty"); + std::uint64_t value = UINT64_C(1469598103934665603); + for (const unsigned char byte : identity) { + value ^= static_cast(byte); + value *= UINT64_C(1099511628211); + } + return value; +} + +template +struct HyperbolicBoundaryTableView { + const HyperbolicComponentTransform* transforms = nullptr; + const Real* fixed_values = nullptr; + int ncomp = 0; + + POPS_HD const HyperbolicComponentTransform& transform(int component) const { + return transforms[component]; + } + POPS_HD Real fixed_value(int face, int component) const { + return fixed_values[face * ncomp + component]; + } +}; + +struct HyperbolicBoundarySample { + int source; + Real scale; + Real offset; +}; + +template +POPS_HD inline HyperbolicBoundarySample hyperbolic_boundary_sample_1d( + int index, int lo, int hi, int axis, HyperbolicBoundaryLaw low, HyperbolicBoundaryLaw high, + const HyperbolicBoundaryTableView& table, int component) { + std::int64_t current = index; + Real scale = Real(1); + Real offset = Real(0); + while (current < lo || current > hi) { + const bool below = current < lo; + const HyperbolicBoundaryLaw law = below ? low : high; + const std::int64_t boundary = below ? lo : hi; + if (law == HyperbolicBoundaryLaw::Extrapolate || law == HyperbolicBoundaryLaw::NoFlux) { + current = boundary; + break; + } + + Real face_scale = Real(1); + Real face_offset = Real(0); + if (law == HyperbolicBoundaryLaw::FixedState) { + face_scale = Real(-1); + const int face = 2 * axis + (below ? 0 : 1); + face_offset = Real(2) * table.fixed_value(face, component); + } else if (law == HyperbolicBoundaryLaw::ReflectiveSlip) { + face_scale = table.transform(component).reflection_sign(axis); + } else { + // Installation preflight rejects an extension that can reach periodic/external ownership. + current = boundary; + break; + } + offset += scale * face_offset; + scale *= face_scale; + current = below ? 2 * boundary - current - 1 : 2 * boundary - current + 1; + } + return {static_cast(current), scale, offset}; +} + +inline bool is_physical_hyperbolic_law(HyperbolicBoundaryLaw law) { + return law == HyperbolicBoundaryLaw::Extrapolate || law == HyperbolicBoundaryLaw::FixedState || + law == HyperbolicBoundaryLaw::NoFlux || law == HyperbolicBoundaryLaw::ReflectiveSlip; +} + +inline const char* hyperbolic_law_name(HyperbolicBoundaryLaw law) { + switch (law) { + case HyperbolicBoundaryLaw::Periodic: + return "periodic"; + case HyperbolicBoundaryLaw::Extrapolate: + return "extrapolate"; + case HyperbolicBoundaryLaw::FixedState: + return "fixed_state"; + case HyperbolicBoundaryLaw::CharacteristicNoInflow: + return "characteristic_no_inflow"; + case HyperbolicBoundaryLaw::NoFlux: + return "no_flux"; + case HyperbolicBoundaryLaw::ReflectiveSlip: + return "reflective_slip"; + case HyperbolicBoundaryLaw::External: + return "external"; + } + return "unknown"; +} + +template +inline void validate_hyperbolic_extension(int index, int lo, int hi, int axis, + HyperbolicBoundaryLaw low, HyperbolicBoundaryLaw high, + const HyperbolicBoundaryTableView& table, + int component) { + std::int64_t current = index; + Real scale = Real(1); + Real offset = Real(0); + while (current < lo || current > hi) { + const bool below = current < lo; + const HyperbolicBoundaryLaw law = below ? low : high; + const std::int64_t boundary = below ? lo : hi; + if (!is_physical_hyperbolic_law(law)) + throw std::invalid_argument(std::string("prepared hyperbolic halo reaches a ") + + hyperbolic_law_name(law) + + " face whose values belong to another topology authority"); + if (law == HyperbolicBoundaryLaw::Extrapolate || law == HyperbolicBoundaryLaw::NoFlux) + return; + + Real face_scale = Real(1); + Real face_offset = Real(0); + if (law == HyperbolicBoundaryLaw::FixedState) { + face_scale = Real(-1); + const int face = 2 * axis + (below ? 0 : 1); + face_offset = Real(2) * table.fixed_value(face, component); + } else { + face_scale = table.transform(component).reflection_sign(axis); + } + offset += scale * face_offset; + scale *= face_scale; + if (!std::isfinite(scale) || !std::isfinite(offset)) + throw std::overflow_error("prepared hyperbolic halo produced a non-finite affine extension"); + current = below ? 2 * boundary - current - 1 : 2 * boundary - current + 1; + } +} + +template +struct HyperbolicFaceXKernel { + Array4 state; + HyperbolicBoundaryTableView table; + int lo; + int hi; + HyperbolicBoundaryLaw low; + HyperbolicBoundaryLaw high; + + POPS_HD void operator()(int i, int j) const { + for (int component = 0; component < table.ncomp; ++component) { + const auto sample = hyperbolic_boundary_sample_1d(i, lo, hi, 0, low, high, table, component); + state(i, j, component) = sample.scale * state(sample.source, j, component) + sample.offset; + } + } +}; + +template +struct HyperbolicFaceYKernel { + Array4 state; + HyperbolicBoundaryTableView table; + int lo; + int hi; + HyperbolicBoundaryLaw low; + HyperbolicBoundaryLaw high; + + POPS_HD void operator()(int i, int j) const { + for (int component = 0; component < table.ncomp; ++component) { + const auto sample = hyperbolic_boundary_sample_1d(j, lo, hi, 1, low, high, table, component); + state(i, j, component) = sample.scale * state(i, sample.source, component) + sample.offset; + } + } +}; + +POPS_HD inline int periodic_index(int index, int lo, int hi) { + const int count = hi - lo + 1; + int offset = (index - lo) % count; + if (offset < 0) + offset += count; + return lo + offset; +} + +struct AnalyticFixedSource { + int i = 0; + int j = 0; +}; + +template +struct AnalyticFixedFaceEvaluator { + static_assert(Axis == 0 || Axis == 1); + + // The analytic value is the Dirichlet trace on the physical face, not a ghost-cell sample. + // AnalyticFixedFaceKernel applies the same affine mirror rule as a constant FixedState face. + analytic::AnalyticProgramView program; + Geometry geometry; + int side; + bool periodic_tangent; + Real time; + + POPS_HD analytic::AnalyticEvaluation evaluate(int i, int j) const { + int coordinate_i = i; + int coordinate_j = j; + if constexpr (Axis == 0) { + if (periodic_tangent) + coordinate_j = periodic_index(j, geometry.domain.lo[1], geometry.domain.hi[1]); + } else if (periodic_tangent) { + coordinate_i = periodic_index(i, geometry.domain.lo[0], geometry.domain.hi[0]); + } + const Real x = + Axis == 0 ? (side < 0 ? geometry.xlo : geometry.xhi) : geometry.x_cell(coordinate_i); + const Real y = + Axis == 1 ? (side < 0 ? geometry.ylo : geometry.yhi) : geometry.y_cell(coordinate_j); + return program.eval_checked(x, y, &time, std::uint8_t{1}); + } + + POPS_HD AnalyticFixedSource source(int i, int j) const { + if constexpr (Axis == 0) { + const int boundary = side < 0 ? geometry.domain.lo[0] : geometry.domain.hi[0]; + return {side < 0 ? 2 * boundary - i - 1 : 2 * boundary - i + 1, j}; + } else { + const int boundary = side < 0 ? geometry.domain.lo[1] : geometry.domain.hi[1]; + return {i, side < 0 ? 2 * boundary - j - 1 : 2 * boundary - j + 1}; + } + } +}; + +template +struct AnalyticFixedFaceFiniteKernel { + AnalyticFixedFaceEvaluator evaluator; + + POPS_HD Real operator()(int i, int j) const { + return evaluator.evaluate(i, j).valid ? Real(0) : Real(1); + } +}; + +template +struct AnalyticFixedFaceKernel { + Array4 state; + int component; + AnalyticFixedFaceEvaluator evaluator; + + POPS_HD void operator()(int i, int j) const { + const auto value = evaluator.evaluate(i, j); + const auto source = evaluator.source(i, j); + state(i, j, component) = Real(2) * value.value - state(source.i, source.j, component); + } +}; + +template +inline HyperbolicComponentTransform transform_from_role(std::string_view role) { + if (role == "MomentumX" || role == "VelocityX") + return HyperbolicComponentTransform::polar_vector(0); + if (role == "MomentumY" || role == "VelocityY") + return HyperbolicComponentTransform::polar_vector(1); + if (role == "MomentumZ" || role == "VelocityZ") + return HyperbolicComponentTransform::polar_vector(2); + if (role == "AxialX") + return HyperbolicComponentTransform::axial_vector(0); + if (role == "AxialY") + return HyperbolicComponentTransform::axial_vector(1); + if (role == "AxialZ") + return HyperbolicComponentTransform::axial_vector(2); + if (role == "Density" || role == "Energy" || role == "Pressure" || role == "Temperature" || + role == "Scalar" || role == "Custom") + return HyperbolicComponentTransform::scalar(); + throw std::invalid_argument("unsupported hyperbolic boundary component role '" + + std::string(role) + "'"); +} + +inline HyperbolicBoundaryLaw hyperbolic_law_from_token(std::string_view token) { + if (token == "periodic") + return HyperbolicBoundaryLaw::Periodic; + if (token == "foextrap") + return HyperbolicBoundaryLaw::Extrapolate; + if (token == "dirichlet") + return HyperbolicBoundaryLaw::FixedState; + if (token == "characteristic_no_inflow") + return HyperbolicBoundaryLaw::CharacteristicNoInflow; + if (token == "no_flux") + return HyperbolicBoundaryLaw::NoFlux; + if (token == "slip_wall") + return HyperbolicBoundaryLaw::ReflectiveSlip; + if (token == "external") + return HyperbolicBoundaryLaw::External; + throw std::invalid_argument("unsupported prepared hyperbolic face law '" + std::string(token) + + "'"); +} + +inline HyperbolicStateRepresentation hyperbolic_representation_from_token(std::string_view token) { + if (token == "conservative") + return HyperbolicStateRepresentation::Conservative; + if (token == "primitive") + return HyperbolicStateRepresentation::Primitive; + throw std::invalid_argument("unsupported prepared hyperbolic representation '" + + std::string(token) + "'"); +} + +} // namespace detail + +template +class PreparedHyperbolicBoundary { + public: + static_assert(Dim >= 1 && Dim <= 3); + using Transform = HyperbolicComponentTransform; + + /// Opaque proof that every fallible physical-face check, including the collective finite-value + /// scan of analytic programs, completed for one exact state/layout. PreparedBoundaryPlan obtains + /// this proof before mutating same-level, periodic or MPI halos, then consumes it immediately. + class PhysicalFillPreflight final { + public: + PhysicalFillPreflight(const PhysicalFillPreflight&) = delete; + PhysicalFillPreflight& operator=(const PhysicalFillPreflight&) = delete; + PhysicalFillPreflight(PhysicalFillPreflight&& other) noexcept + : owner_(std::exchange(other.owner_, nullptr)), + state_(std::exchange(other.state_, nullptr)), + domain_(other.domain_), + geometry_(other.geometry_), + has_geometry_(other.has_geometry_), + physical_time_(other.physical_time_), + ncomp_(other.ncomp_), + depth_(other.depth_) {} + PhysicalFillPreflight& operator=(PhysicalFillPreflight&& other) noexcept { + if (this == &other) + return *this; + owner_ = std::exchange(other.owner_, nullptr); + state_ = std::exchange(other.state_, nullptr); + domain_ = other.domain_; + geometry_ = other.geometry_; + has_geometry_ = other.has_geometry_; + physical_time_ = other.physical_time_; + ncomp_ = other.ncomp_; + depth_ = other.depth_; + return *this; + } + + private: + friend class PreparedHyperbolicBoundary; + + PhysicalFillPreflight(const PreparedHyperbolicBoundary* owner, const MultiFab* state, + Box2D domain, const Geometry* geometry, Real physical_time) + : owner_(owner), + state_(state), + domain_(domain), + geometry_(geometry == nullptr ? Geometry{} : *geometry), + has_geometry_(geometry != nullptr), + physical_time_(physical_time), + ncomp_(state->ncomp()), + depth_(state->n_grow()) {} + + const PreparedHyperbolicBoundary* owner_ = nullptr; + const MultiFab* state_ = nullptr; + Box2D domain_{}; + Geometry geometry_{}; + bool has_geometry_ = false; + Real physical_time_ = Real(0); + int ncomp_ = 0; + int depth_ = 0; + }; + + PreparedHyperbolicBoundary() = default; + + PreparedHyperbolicBoundary( + std::array faces, + std::vector component_transforms, + HyperbolicCornerPolicy corner_policy = HyperbolicCornerPolicy::NotRequired, + bool explicit_periodic_identifications = false) + : faces_(std::move(faces)), + component_transforms_(std::move(component_transforms)), + corner_policy_(corner_policy), + explicit_periodic_identifications_(explicit_periodic_identifications) { + validate(explicit_periodic_identifications); + prepare_device_tables(); + } + + int ncomp() const { return static_cast(component_transforms_.size()); } + const PreparedHyperbolicFace& face(int axis, int side) const { + if (axis < 0 || axis >= Dim || (side != -1 && side != 1)) + throw std::out_of_range("prepared hyperbolic face selector is outside the model dimension"); + return faces_[static_cast(2 * axis + (side > 0 ? 1 : 0))]; + } + const Transform& component_transform(int component) const { + if (component < 0 || component >= ncomp()) + throw std::out_of_range("prepared hyperbolic component is outside the state"); + return component_transforms_[static_cast(component)]; + } + HyperbolicCornerPolicy corner_policy() const { return corner_policy_; } + bool has_analytic_state() const { + return std::any_of(faces_.begin(), faces_.end(), [](const PreparedHyperbolicFace& face) { + return !face.analytic_state.empty(); + }); + } + + bool has_characteristic_no_inflow() const { + return std::any_of(faces_.begin(), faces_.end(), [](const PreparedHyperbolicFace& face) { + return face.law == HyperbolicBoundaryLaw::CharacteristicNoInflow; + }); + } + + bool requires_fixed_state_conversion() const { + return std::any_of(faces_.begin(), faces_.end(), [](const PreparedHyperbolicFace& prepared) { + return prepared.law == HyperbolicBoundaryLaw::FixedState && + prepared.authored_representation == HyperbolicStateRepresentation::Primitive && + !prepared.fixed_state_converted; + }); + } + + /// Return a new executable table after converting every pending primitive fixed state. + /// + /// Conversion is transactional: this immutable table is untouched if the model conversion + /// throws or produces a non-finite component. + PreparedHyperbolicBoundary with_converted_fixed_states( + const std::function& primitive_to_conservative) const { + if (!requires_fixed_state_conversion()) + return *this; + if (!primitive_to_conservative) + throw std::invalid_argument( + "primitive fixed-state boundary requires the compiled block-model conversion"); + + auto converted_faces = faces_; + std::vector input(static_cast(ncomp())); + std::vector output(static_cast(ncomp())); + for (auto& prepared : converted_faces) { + if (prepared.law != HyperbolicBoundaryLaw::FixedState || + prepared.authored_representation != HyperbolicStateRepresentation::Primitive || + prepared.fixed_state_converted) + continue; + for (int component = 0; component < ncomp(); ++component) + input[static_cast(component)] = + static_cast(prepared.fixed_state[static_cast(component)]); + std::fill(output.begin(), output.end(), std::numeric_limits::quiet_NaN()); + primitive_to_conservative(input.data(), output.data()); + if (std::any_of(output.begin(), output.end(), + [](double value) { return !std::isfinite(value); })) + throw std::runtime_error( + "primitive fixed-state boundary conversion produced a non-finite component"); + for (int component = 0; component < ncomp(); ++component) { + const Real converted = static_cast(output[static_cast(component)]); + if (!std::isfinite(converted)) + throw std::runtime_error( + "primitive fixed-state boundary conversion exceeds the runtime precision"); + prepared.fixed_state[static_cast(component)] = converted; + } + prepared.fixed_state_converted = true; + } + return PreparedHyperbolicBoundary(std::move(converted_faces), component_transforms_, + corner_policy_, explicit_periodic_identifications_); + } + + Periodicity periodicity() const { + static_assert(Dim == 2, "the current MultiFab topology is two-dimensional"); + const bool xlo = faces_[0].law == HyperbolicBoundaryLaw::Periodic; + const bool xhi = faces_[1].law == HyperbolicBoundaryLaw::Periodic; + const bool ylo = faces_[2].law == HyperbolicBoundaryLaw::Periodic; + const bool yhi = faces_[3].law == HyperbolicBoundaryLaw::Periodic; + if (xlo != xhi || ylo != yhi) + throw std::logic_error( + "axis-permuted periodic topology has no per-axis runtime Periodicity projection"); + return Periodicity{ + xlo, + ylo, + }; + } + + /// Fill only physical faces. Same-level/MPI and periodic topology remain owned by fill_boundary. + /// + /// The explicit NotRequired corner policy excludes double-physical corners. Periodic tangential + /// ghosts are included because they were already produced by fill_boundary and are valid inputs. + void fill_physical(MultiFab& state, const Box2D& domain) const { + auto preflight = preflight_physical(state, domain); + fill_physical_preflighted(state, std::move(preflight)); + } + + void fill_physical(MultiFab& state, const Geometry& geometry) const { + fill_physical(state, geometry, world_communicator_view()); + } + + void fill_physical(MultiFab& state, const Geometry& geometry, + CommunicatorView communicator) const { + auto preflight = preflight_physical(state, geometry, communicator); + fill_physical_preflighted(state, std::move(preflight)); + } + + void fill_physical(MultiFab& state, const Geometry& geometry, Real physical_time, + std::string_view clock) const { + fill_physical(state, geometry, physical_time, clock, world_communicator_view()); + } + + void fill_physical(MultiFab& state, const Geometry& geometry, Real physical_time, + std::string_view clock, CommunicatorView communicator) const { + auto preflight = preflight_physical(state, geometry, physical_time, clock, communicator); + fill_physical_preflighted(state, std::move(preflight)); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Box2D& domain) const { + if (has_analytic_state()) + throw std::logic_error( + "analytic hyperbolic boundary requires physical Geometry at execution"); + return preflight_physical_impl_(state, domain, nullptr, Real(0), {}, false, + world_communicator_view()); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry) const { + return preflight_physical(state, geometry, world_communicator_view()); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry, + CommunicatorView communicator) const { + return preflight_physical_impl_(state, geometry.domain, &geometry, Real(0), {}, false, + communicator); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry, + Real physical_time, std::string_view clock) const { + return preflight_physical(state, geometry, physical_time, clock, world_communicator_view()); + } + + PhysicalFillPreflight preflight_physical(MultiFab& state, const Geometry& geometry, + Real physical_time, std::string_view clock, + CommunicatorView communicator) const { + return preflight_physical_impl_(state, geometry.domain, &geometry, physical_time, clock, true, + communicator); + } + + /// Consume one exact preflight after the owning PreparedBoundaryPlan has produced native halos. + /// No validation or host allocation remains on this commit path. + void fill_physical_preflighted(MultiFab& state, PhysicalFillPreflight&& preflight) const { + if (preflight.owner_ != this || preflight.state_ != &state || + preflight.ncomp_ != state.ncomp() || preflight.depth_ != state.n_grow()) + throw std::logic_error( + "prepared hyperbolic boundary received a foreign or stale physical preflight"); + preflight.owner_ = nullptr; + fill_physical_preflighted_impl_(state, preflight.domain_, + preflight.has_geometry_ ? &preflight.geometry_ : nullptr, + preflight.physical_time_); + } + + private: + PhysicalFillPreflight preflight_physical_impl_(MultiFab& state, const Box2D& domain, + const Geometry* geometry, Real physical_time, + std::string_view clock, bool has_evaluation_point, + CommunicatorView communicator) const { + static_assert(Dim == 2, "the current MultiFab storage is two-dimensional"); + if (requires_fixed_state_conversion()) + throw std::logic_error( + "primitive fixed-state boundary reached execution before model conversion"); + if (state.ncomp() != ncomp()) + throw std::invalid_argument( + "prepared hyperbolic boundary component count differs from the state"); + const int depth = state.n_grow(); + if (depth == 0) + return PhysicalFillPreflight(this, &state, domain, geometry, physical_time); + if (has_analytic_state()) { + if (geometry == nullptr || geometry->domain != domain) + throw std::invalid_argument( + "analytic hyperbolic boundary requires matching physical Geometry"); + for (int face = 0; face < 2 * Dim; ++face) { + const auto& prepared = faces_[static_cast(face)]; + if (prepared.analytic_state.empty()) + continue; + const int axis_cells = face / 2 == 0 ? domain.nx() : domain.ny(); + if (depth > axis_cells) + throw std::invalid_argument( + "analytic hyperbolic boundary does not support multi-reflection ghost depth"); + if (!prepared.analytic_clock.empty() && + (!has_evaluation_point || prepared.analytic_clock != clock || + !std::isfinite(physical_time))) + throw std::invalid_argument( + "analytic hyperbolic boundary requires its exact finite BoundaryEvaluationPoint"); + } + validate_analytic_values_(state, *geometry, physical_time, communicator); + } + const auto table = table_view(); + for (int component = 0; component < ncomp(); ++component) { + for (int offset = 1; offset <= depth; ++offset) { + if (detail::is_physical_hyperbolic_law(faces_[0].law)) + detail::validate_hyperbolic_extension(domain.lo[0] - offset, domain.lo[0], domain.hi[0], + 0, faces_[0].law, faces_[1].law, table, component); + if (detail::is_physical_hyperbolic_law(faces_[1].law)) + detail::validate_hyperbolic_extension(domain.hi[0] + offset, domain.lo[0], domain.hi[0], + 0, faces_[0].law, faces_[1].law, table, component); + if (detail::is_physical_hyperbolic_law(faces_[2].law)) + detail::validate_hyperbolic_extension(domain.lo[1] - offset, domain.lo[1], domain.hi[1], + 1, faces_[2].law, faces_[3].law, table, component); + if (detail::is_physical_hyperbolic_law(faces_[3].law)) + detail::validate_hyperbolic_extension(domain.hi[1] + offset, domain.lo[1], domain.hi[1], + 1, faces_[2].law, faces_[3].law, table, component); + } + } + return PhysicalFillPreflight(this, &state, domain, geometry, physical_time); + } + + void fill_physical_preflighted_impl_(MultiFab& state, const Box2D& domain, + const Geometry* geometry, Real physical_time) const { + const int depth = state.n_grow(); + if (depth == 0) + return; + const auto table = table_view(); + for (int local = 0; local < state.local_size(); ++local) { + Fab2D& fab = state.fab(local); + const Box2D valid = fab.box(); + const Array4 values = fab.array(); + + int tangential_lo = valid.lo[1] - depth; + int tangential_hi = valid.hi[1] + depth; + if (faces_[2].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[1]); + if (faces_[3].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[1]); + if (detail::is_physical_hyperbolic_law(faces_[0].law) && valid.lo[0] == domain.lo[0]) + fill_x_face_( + values, Box2D{{domain.lo[0] - depth, tangential_lo}, {domain.lo[0] - 1, tangential_hi}}, + 0, domain, geometry, physical_time, table); + if (detail::is_physical_hyperbolic_law(faces_[1].law) && valid.hi[0] == domain.hi[0]) + fill_x_face_( + values, Box2D{{domain.hi[0] + 1, tangential_lo}, {domain.hi[0] + depth, tangential_hi}}, + 1, domain, geometry, physical_time, table); + + tangential_lo = valid.lo[0] - depth; + tangential_hi = valid.hi[0] + depth; + if (faces_[0].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[0]); + if (faces_[1].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[0]); + if (detail::is_physical_hyperbolic_law(faces_[2].law) && valid.lo[1] == domain.lo[1]) + fill_y_face_( + values, Box2D{{tangential_lo, domain.lo[1] - depth}, {tangential_hi, domain.lo[1] - 1}}, + 2, domain, geometry, physical_time, table); + if (detail::is_physical_hyperbolic_law(faces_[3].law) && valid.hi[1] == domain.hi[1]) + fill_y_face_( + values, Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}, + 3, domain, geometry, physical_time, table); + } + } + + std::array faces_{}; + std::vector component_transforms_; + HyperbolicCornerPolicy corner_policy_ = HyperbolicCornerPolicy::NotRequired; + bool explicit_periodic_identifications_ = false; +#if defined(POPS_HAS_KOKKOS) + Kokkos::View device_transforms_; + Kokkos::View device_fixed_values_; +#else + std::vector device_transforms_; + std::vector device_fixed_values_; +#endif + + template + detail::AnalyticFixedFaceEvaluator analytic_evaluator_(int face_ordinal, int component, + const Geometry& geometry, + Real physical_time) const { + const auto& face = faces_[static_cast(face_ordinal)]; + const bool periodic_tangent = Axis == 0 ? faces_[2].law == HyperbolicBoundaryLaw::Periodic + : faces_[0].law == HyperbolicBoundaryLaw::Periodic; + return {face.analytic_state[static_cast(component)].view(), geometry, + face_ordinal % 2 == 0 ? -1 : 1, periodic_tangent, physical_time}; + } + + void fill_x_face_(const Array4& values, const Box2D& region, int face_ordinal, + const Box2D& domain, const Geometry* geometry, Real physical_time, + const detail::HyperbolicBoundaryTableView& table) const { + const auto& face = faces_[static_cast(face_ordinal)]; + if (face.analytic_state.empty()) { + for_each_cell(region, + detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], + faces_[0].law, faces_[1].law}); + return; + } + if (geometry == nullptr) + throw std::logic_error("analytic x-face execution lost physical Geometry"); + for (int component = 0; component < ncomp(); ++component) + for_each_cell(region, + detail::AnalyticFixedFaceKernel<0>{ + values, component, + analytic_evaluator_<0>(face_ordinal, component, *geometry, physical_time)}); + } + + void fill_y_face_(const Array4& values, const Box2D& region, int face_ordinal, + const Box2D& domain, const Geometry* geometry, Real physical_time, + const detail::HyperbolicBoundaryTableView& table) const { + const auto& face = faces_[static_cast(face_ordinal)]; + if (face.analytic_state.empty()) { + for_each_cell(region, + detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], + faces_[2].law, faces_[3].law}); + return; + } + if (geometry == nullptr) + throw std::logic_error("analytic y-face execution lost physical Geometry"); + for (int component = 0; component < ncomp(); ++component) + for_each_cell(region, + detail::AnalyticFixedFaceKernel<1>{ + values, component, + analytic_evaluator_<1>(face_ordinal, component, *geometry, physical_time)}); + } + + void validate_analytic_values_(const MultiFab& state, const Geometry& geometry, + Real physical_time, CommunicatorView communicator) const { + const int depth = state.n_grow(); + long invalid_local = 0; + for (int local = 0; local < state.local_size(); ++local) { + const Box2D valid = state.fab(local).box(); + int tangential_lo = valid.lo[1] - depth; + int tangential_hi = valid.hi[1] + depth; + if (faces_[2].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, geometry.domain.lo[1]); + if (faces_[3].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, geometry.domain.hi[1]); + for (int face_ordinal = 0; face_ordinal < 2; ++face_ordinal) { + const auto& face = faces_[static_cast(face_ordinal)]; + const bool touches = face_ordinal == 0 ? valid.lo[0] == geometry.domain.lo[0] + : valid.hi[0] == geometry.domain.hi[0]; + if (face.analytic_state.empty() || !touches) + continue; + const Box2D region = face_ordinal == 0 + ? Box2D{{geometry.domain.lo[0] - depth, tangential_lo}, + {geometry.domain.lo[0] - 1, tangential_hi}} + : Box2D{{geometry.domain.hi[0] + 1, tangential_lo}, + {geometry.domain.hi[0] + depth, tangential_hi}}; + for (int component = 0; component < ncomp(); ++component) + invalid_local += static_cast(for_each_cell_reduce_sum( + region, detail::AnalyticFixedFaceFiniteKernel<0>{analytic_evaluator_<0>( + face_ordinal, component, geometry, physical_time)})); + } + + tangential_lo = valid.lo[0] - depth; + tangential_hi = valid.hi[0] + depth; + if (faces_[0].law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, geometry.domain.lo[0]); + if (faces_[1].law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, geometry.domain.hi[0]); + for (int face_ordinal = 2; face_ordinal < 4; ++face_ordinal) { + const auto& face = faces_[static_cast(face_ordinal)]; + const bool touches = face_ordinal == 2 ? valid.lo[1] == geometry.domain.lo[1] + : valid.hi[1] == geometry.domain.hi[1]; + if (face.analytic_state.empty() || !touches) + continue; + const Box2D region = face_ordinal == 2 + ? Box2D{{tangential_lo, geometry.domain.lo[1] - depth}, + {tangential_hi, geometry.domain.lo[1] - 1}} + : Box2D{{tangential_lo, geometry.domain.hi[1] + 1}, + {tangential_hi, geometry.domain.hi[1] + depth}}; + for (int component = 0; component < ncomp(); ++component) + invalid_local += static_cast(for_each_cell_reduce_sum( + region, detail::AnalyticFixedFaceFiniteKernel<1>{analytic_evaluator_<1>( + face_ordinal, component, geometry, physical_time)})); + } + } + const long invalid = all_reduce_sum(invalid_local, communicator); + if (invalid != 0) + throw std::runtime_error("analytic hyperbolic boundary produced non-finite values (count=" + + std::to_string(invalid) + ")"); + } + + void validate(bool explicit_periodic_identifications) const { + if (component_transforms_.empty()) + throw std::invalid_argument( + "prepared hyperbolic boundary requires model-qualified components"); + if (corner_policy_ != HyperbolicCornerPolicy::NotRequired) + throw std::invalid_argument("unsupported hyperbolic corner policy"); + for (int axis = 0; axis < Dim; ++axis) { + const auto& low = faces_[static_cast(2 * axis)]; + const auto& high = faces_[static_cast(2 * axis + 1)]; + if (!explicit_periodic_identifications && (low.law == HyperbolicBoundaryLaw::Periodic) != + (high.law == HyperbolicBoundaryLaw::Periodic)) + throw std::invalid_argument( + "prepared hyperbolic periodic topology requires complete axis pairs"); + } + for (int face_ordinal = 0; face_ordinal < 2 * Dim; ++face_ordinal) { + const auto& prepared_face = faces_[static_cast(face_ordinal)]; + if (prepared_face.identity.empty() || prepared_face.identity_token == 0) + throw std::invalid_argument("prepared hyperbolic faces require owner-qualified identities"); + if (!prepared_face.analytic_state.empty()) { + if (prepared_face.law != HyperbolicBoundaryLaw::FixedState || + prepared_face.authored_representation != HyperbolicStateRepresentation::Conservative || + !prepared_face.converter_identity.empty() || !prepared_face.fixed_state_converted || + prepared_face.analytic_state.size() != component_transforms_.size() || + std::any_of(prepared_face.fixed_state.begin(), prepared_face.fixed_state.end(), + [](Real value) { return value != Real(0); }) || + std::any_of(prepared_face.analytic_state.begin(), prepared_face.analytic_state.end(), + [](const analytic::AnalyticProgram& program) { + return program.empty() || + program.result_type() != analytic::AnalyticValueType::Scalar; + })) + throw std::invalid_argument( + "analytic hyperbolic boundary requires zero fixed-state placeholders and one " + "conservative scalar program per component"); + } else if (!prepared_face.analytic_clock.empty()) { + throw std::invalid_argument( + "only an analytic hyperbolic boundary may carry a logical Clock"); + } + if (prepared_face.law == HyperbolicBoundaryLaw::FixedState || + prepared_face.law == HyperbolicBoundaryLaw::CharacteristicNoInflow) { + if (prepared_face.fixed_state.size() != component_transforms_.size() || + std::any_of(prepared_face.fixed_state.begin(), prepared_face.fixed_state.end(), + [](Real value) { return !std::isfinite(value); })) + throw std::invalid_argument( + "fixed-state hyperbolic boundary must provide one finite value per component"); + if (prepared_face.law == HyperbolicBoundaryLaw::CharacteristicNoInflow && + prepared_face.authored_representation != HyperbolicStateRepresentation::Conservative) + throw std::invalid_argument( + "characteristic no-inflow requires a conservative reference state"); + if (prepared_face.authored_representation == HyperbolicStateRepresentation::Primitive) { + if (prepared_face.converter_identity.empty()) + throw std::invalid_argument( + "primitive fixed-state boundary requires one converter identity"); + } else if (!prepared_face.converter_identity.empty() || + !prepared_face.fixed_state_converted) { + throw std::invalid_argument( + "conservative fixed-state boundary must not carry conversion metadata"); + } + } else if (!prepared_face.fixed_state.empty()) { + throw std::invalid_argument( + "only fixed-state or characteristic no-inflow boundaries may carry component values"); + } else if (prepared_face.authored_representation != + HyperbolicStateRepresentation::Conservative || + !prepared_face.converter_identity.empty() || + !prepared_face.fixed_state_converted) { + throw std::invalid_argument( + "only a fixed-state hyperbolic boundary may carry conversion metadata"); + } + if (prepared_face.law == HyperbolicBoundaryLaw::ReflectiveSlip) { + const int normal_axis = face_ordinal / 2; + const bool owns_normal_polar_component = + std::any_of(component_transforms_.begin(), component_transforms_.end(), + [normal_axis](const Transform& transform) { + return transform.parity == HyperbolicComponentParity::PolarVector && + transform.axis == normal_axis; + }); + if (!owns_normal_polar_component) + throw std::invalid_argument( + "reflective slip wall requires a declared normal polar-vector component"); + } + } + } + + void prepare_device_tables() { + const std::size_t components = component_transforms_.size(); + std::vector fixed(static_cast(2 * Dim) * components, Real(0)); + for (int face_ordinal = 0; face_ordinal < 2 * Dim; ++face_ordinal) { + const auto& source = faces_[static_cast(face_ordinal)].fixed_state; + if (source.empty()) + continue; + std::copy(source.begin(), source.end(), + fixed.begin() + static_cast(face_ordinal * components)); + } +#if defined(POPS_HAS_KOKKOS) + detail::ensure_kokkos_initialized(); + device_transforms_ = + Kokkos::View("pops_boundary_transforms", components); + device_fixed_values_ = + Kokkos::View("pops_boundary_fixed_values", fixed.size()); + auto host_transforms = Kokkos::create_mirror_view(device_transforms_); + auto host_fixed = Kokkos::create_mirror_view(device_fixed_values_); + for (std::size_t index = 0; index < components; ++index) + host_transforms(index) = component_transforms_[index]; + for (std::size_t index = 0; index < fixed.size(); ++index) + host_fixed(index) = fixed[index]; + Kokkos::deep_copy(device_transforms_, host_transforms); + Kokkos::deep_copy(device_fixed_values_, host_fixed); +#else + device_transforms_ = component_transforms_; + device_fixed_values_ = std::move(fixed); +#endif + } + + detail::HyperbolicBoundaryTableView table_view() const { + return { + device_transforms_.data(), + device_fixed_values_.data(), + ncomp(), + }; + } +}; + +/// Sole built-in parser from the installed Python/native table into the typed hyperbolic plan. +template +PreparedHyperbolicBoundary prepare_hyperbolic_boundary( + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, bool explicit_periodic_identifications = false, + const std::vector& face_representations = {}, + const std::vector& face_converter_identities = {}, + const std::vector>& face_analytic_opcodes = {}, + const std::vector>& face_analytic_literals = {}, + const std::vector& face_analytic_clocks = {}) { + if (face_types.size() != static_cast(2 * Dim) || + face_identities.size() != static_cast(2 * Dim)) + throw std::invalid_argument( + "prepared hyperbolic boundary requires one type and identity per oriented face"); + if (component_roles.empty() || + face_values.size() != component_roles.size() * static_cast(2 * Dim)) + throw std::invalid_argument( + "prepared hyperbolic boundary values must be component-major and total"); + if ((!face_representations.empty() && + face_representations.size() != static_cast(2 * Dim)) || + (!face_converter_identities.empty() && + face_converter_identities.size() != static_cast(2 * Dim))) + throw std::invalid_argument( + "prepared hyperbolic boundary conversion metadata must cover every oriented face"); + const std::size_t analytic_rows = static_cast(2 * Dim) * component_roles.size(); + if (face_analytic_opcodes.empty() != face_analytic_literals.empty() || + (!face_analytic_opcodes.empty() && + (face_analytic_opcodes.size() != analytic_rows || + face_analytic_literals.size() != analytic_rows || + face_analytic_clocks.size() != static_cast(2 * Dim))) || + (face_analytic_opcodes.empty() && !face_analytic_clocks.empty())) + throw std::invalid_argument( + "prepared hyperbolic analytic tables must cover every face/component and Clock"); + + std::vector> transforms; + transforms.reserve(component_roles.size()); + for (const auto& role : component_roles) + transforms.push_back(detail::transform_from_role(role)); + + std::string plan_analytic_clock; + std::array faces; + for (int face = 0; face < 2 * Dim; ++face) { + auto& destination = faces[static_cast(face)]; + destination.law = detail::hyperbolic_law_from_token(face_types[static_cast(face)]); + destination.identity = face_identities[static_cast(face)]; + destination.identity_token = detail::stable_boundary_identity(destination.identity); + destination.authored_representation = detail::hyperbolic_representation_from_token( + face_representations.empty() + ? std::string_view("conservative") + : std::string_view(face_representations[static_cast(face)])); + destination.converter_identity = + face_converter_identities.empty() + ? std::string{} + : face_converter_identities[static_cast(face)]; + if (destination.law == HyperbolicBoundaryLaw::NoFlux) { + for (std::size_t component = 0; component < component_roles.size(); ++component) + if (face_values[component * static_cast(2 * Dim) + + static_cast(face)] != 0.0) + throw std::invalid_argument( + "a no-flux hyperbolic boundary cannot carry component values"); + } + if (destination.law == HyperbolicBoundaryLaw::FixedState || + destination.law == HyperbolicBoundaryLaw::CharacteristicNoInflow) { + destination.fixed_state.reserve(component_roles.size()); + for (std::size_t component = 0; component < component_roles.size(); ++component) + destination.fixed_state.push_back( + static_cast(face_values[component * static_cast(2 * Dim) + + static_cast(face)])); + destination.fixed_state_converted = + destination.authored_representation == HyperbolicStateRepresentation::Conservative; + } + if (!face_analytic_opcodes.empty()) { + bool any_program = false; + bool every_program = true; + bool reads_time = false; + destination.analytic_clock = face_analytic_clocks[static_cast(face)]; + for (std::size_t component = 0; component < component_roles.size(); ++component) { + const std::size_t row = static_cast(face) * component_roles.size() + component; + const auto& opcodes = face_analytic_opcodes[row]; + const auto& literals = face_analytic_literals[row]; + any_program = any_program || !opcodes.empty(); + every_program = every_program && !opcodes.empty(); + if (opcodes.empty() && literals.empty()) + continue; + if (opcodes.empty() || opcodes.size() != literals.size()) + throw std::invalid_argument( + "prepared hyperbolic analytic opcode/literal rows must be non-empty and aligned"); + std::vector tokens; + tokens.reserve(opcodes.size()); + for (std::size_t index = 0; index < opcodes.size(); ++index) { + const analytic::AnalyticOp op = analytic::analytic_op_from_name(opcodes[index]); + const double raw = literals[index]; + if (!std::isfinite(raw)) + throw std::invalid_argument( + "prepared hyperbolic analytic token literal must be finite"); + if (op == analytic::AnalyticOp::Input) { + if (raw != 0.0) + throw std::invalid_argument( + "prepared hyperbolic analytic input is reserved for physical time slot zero"); + reads_time = true; + } + tokens.push_back({op, static_cast(raw)}); + } + destination.analytic_state.push_back(analytic::compile_analytic_postfix(tokens)); + } + if (any_program != every_program) + throw std::invalid_argument( + "prepared hyperbolic analytic face must cover every state component"); + if (!any_program) { + if (!destination.analytic_clock.empty()) + throw std::invalid_argument( + "prepared hyperbolic analytic Clock requires a program on the same face"); + destination.analytic_clock.clear(); + } else if (reads_time != !destination.analytic_clock.empty()) { + throw std::invalid_argument( + "prepared hyperbolic analytic physical-time input requires one exact logical Clock"); + } else if (reads_time) { + if (plan_analytic_clock.empty()) + plan_analytic_clock = destination.analytic_clock; + else if (plan_analytic_clock != destination.analytic_clock) + throw std::invalid_argument( + "prepared hyperbolic analytic plan cannot mix logical Clocks"); + } + } + } + return PreparedHyperbolicBoundary(std::move(faces), std::move(transforms), + HyperbolicCornerPolicy::NotRequired, + explicit_periodic_identifications); +} + +} // namespace pops diff --git a/include/pops/mesh/execution/for_each.hpp b/include/pops/mesh/execution/for_each.hpp index 397de925d..f93755801 100644 --- a/include/pops/mesh/execution/for_each.hpp +++ b/include/pops/mesh/execution/for_each.hpp @@ -21,7 +21,9 @@ #include // detail::ensure_kokkos_initialized + device_fence (life cycle) #include #include +#include #include +#include #include // std::int64_t: cell counts (LLP64 portability, no-op on LP64) #include // getenv / strtol: overridable serial fallback threshold (#165) @@ -93,6 +95,16 @@ inline std::int64_t foreach_serial_threshold() { }(); return thr; } + +/// True only when the product is strictly below the threshold, without forming a potentially +/// overflowing product. Large iterable boxes therefore take the Kokkos path rather than failing +/// while merely deciding the host fallback. +inline bool foreach_small_box(std::int64_t nx, std::int64_t ny, std::int64_t threshold) noexcept { + if (nx <= 0 || ny <= 0 || threshold <= 0) + return false; + const std::int64_t remaining = threshold - 1; + return nx <= remaining && ny <= remaining / nx; +} } // namespace detail // --------------------------------------------------------------------------- @@ -146,6 +158,281 @@ inline void sync_host() { /// deep_copy host->device on a non-unified path. inline void sync_device() {} +namespace detail { + +template +inline void require_iterable_box(const Box& box) { + if (box.empty()) + return; + for (int axis = 0; axis < Dim; ++axis) { + if (box.length(axis) > std::numeric_limits::max() || + box.hi[axis] == std::numeric_limits::max()) + throw std::overflow_error( + "PoPS Kokkos iteration requires int-addressable extents and an inclusive high index " + "below " + "INT_MAX"); + } +} + +template +inline bool foreach_small_box(const Box& box, std::int64_t threshold) noexcept { + if (box.empty() || threshold <= 0) + return false; + std::int64_t remaining = threshold - 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t extent = box.length(axis); + if (extent <= 0 || extent > remaining) + return false; + remaining /= extent; + } + return true; +} + +template +void launch_index_space(const ExecutionSpace& execution, const Box& box, const char* label, + F f) { + static_assert(Kokkos::is_execution_space::value, + "PoPS iteration requires a Kokkos execution-space instance"); + if constexpr (Dim == 1) { + Kokkos::parallel_for( + label, + Kokkos::RangePolicy>(execution, box.lo[0], + box.hi[0] + 1), + KOKKOS_LAMBDA(const int i) { f(CellIndex<1>{i}); }); + } else if constexpr (Dim == 2) { + Kokkos::parallel_for( + label, + Kokkos::MDRangePolicy, Kokkos::IndexType>( + execution, {box.lo[0], box.lo[1]}, {box.hi[0] + 1, box.hi[1] + 1}), + KOKKOS_LAMBDA(const int i, const int j) { f(CellIndex<2>{i, j}); }); + } else { + Kokkos::parallel_for( + label, + Kokkos::MDRangePolicy, Kokkos::IndexType>( + execution, {box.lo[0], box.lo[1], box.lo[2]}, + {box.hi[0] + 1, box.hi[1] + 1, box.hi[2] + 1}), + KOKKOS_LAMBDA(const int i, const int j, const int k) { f(CellIndex<3>{i, j, k}); }); + } +} + +template +struct FaceKernelAdapter { + F functor; + + POPS_HD void operator()(const CellIndex& index) const { + functor(FaceIndex{index}); + } +}; + +} // namespace detail + +/// Return the face-index box normal to compile-time @p Axis. A cell box contains one more face +/// than cells along its normal axis and retains the cell extents along every tangent axis. +template +Box face_box(const Box& cells) { + static_assert(Axis >= 0 && Axis < Dim, + "pops::face_box axis must lie inside the compile-time rank"); + if (cells.empty()) + return cells; + detail::require_iterable_box(cells); + Box faces = cells; + ++faces.hi[Axis]; + return faces; +} + +/// Submit a cell kernel to an explicit Kokkos execution-space instance. Unlike the default host +/// convenience overload, this path never substitutes a synchronous small-box loop, so task-graph +/// and accelerator-stream ordering remain owned by the supplied instance. +template +void for_each_cell(const ExecutionSpace& execution, const Box& b, F f) { + if (b.empty()) + return; + detail::require_iterable_box(b); + detail::ensure_kokkos_initialized(); + if constexpr (Dim == 1) + detail::launch_index_space(execution, b, "pops_for_each_cell_1d", f); + else if constexpr (Dim == 2) + detail::launch_index_space(execution, b, "pops_for_each_cell_2d", f); + else + detail::launch_index_space(execution, b, "pops_for_each_cell_3d", f); +} + +/// Applies @p f to every index of a compile-time-ranked box. The functor is passed by value and +/// receives CellIndex; the selected Kokkos policy has the same static rank as the box. +template +void for_each_cell(const Box& b, F f) { + if (b.empty()) + return; + detail::require_iterable_box(b); + if constexpr (std::is_same_v) { + if (detail::foreach_small_box(b, detail::foreach_serial_threshold())) { + record_fallback(FallbackCounter::kForeachSerialSmallBox); + if constexpr (Dim == 1) { + for (int i = b.lo[0]; i <= b.hi[0]; ++i) + f(CellIndex<1>{i}); + } else if constexpr (Dim == 2) { + for (int j = b.lo[1]; j <= b.hi[1]; ++j) + for (int i = b.lo[0]; i <= b.hi[0]; ++i) + f(CellIndex<2>{i, j}); + } else { + for (int k = b.lo[2]; k <= b.hi[2]; ++k) + for (int j = b.lo[1]; j <= b.hi[1]; ++j) + for (int i = b.lo[0]; i <= b.hi[0]; ++i) + f(CellIndex<3>{i, j, k}); + } + return; + } + } + detail::ensure_kokkos_initialized(); + const Kokkos::DefaultExecutionSpace execution{}; + for_each_cell(execution, b, f); +} + +/// Submit the product of a compile-time-ranked integer box. This is the non-cell semantic facade +/// used by topology, pack/unpack, and task-graph work while sharing the same static Kokkos policies. +template +void for_each_product(const ExecutionSpace& execution, const Box& product, F f) { + for_each_cell(execution, product, f); +} + +template +void for_each_product(const Box& product, F f) { + for_each_cell(product, f); +} + +/// Submit faces normal to compile-time @p Axis. Axis is a type property of every FaceIndex passed +/// to the functor, so flux and metric kernels do not branch on direction in their inner loop. +template +void for_each_face(const ExecutionSpace& execution, const Box& cells, F f) { + const Box faces = face_box(cells); + for_each_cell(execution, faces, detail::FaceKernelAdapter{f}); +} + +template +void for_each_face(const Box& cells, F f) { + const Box faces = face_box(cells); + for_each_cell(faces, detail::FaceKernelAdapter{f}); +} + +/// SUM reduction on an explicit execution-space instance. The returned scalar establishes the +/// completion dependency for this reduction only; unrelated submitted work remains unfenced. +template +Real for_each_cell_reduce_sum(const ExecutionSpace& execution, const Box& b, F f) { + static_assert(Kokkos::is_execution_space::value, + "PoPS reduction requires a Kokkos execution-space instance"); + if (b.empty()) + return Real(0); + detail::require_iterable_box(b); + detail::ensure_kokkos_initialized(); + Real result = 0; + if constexpr (Dim == 1) { + Kokkos::parallel_reduce( + "pops_reduce_sum_index_1d", + Kokkos::RangePolicy>(execution, b.lo[0], + b.hi[0] + 1), + KOKKOS_LAMBDA(const int i, Real& accumulator) { accumulator += f(Index<1>{i}); }, + Kokkos::Sum{result}); + } else if constexpr (Dim == 2) { + Kokkos::parallel_reduce( + "pops_reduce_sum_index_2d", + Kokkos::MDRangePolicy, Kokkos::IndexType>( + execution, {b.lo[0], b.lo[1]}, {b.hi[0] + 1, b.hi[1] + 1}), + KOKKOS_LAMBDA(const int i, const int j, Real& accumulator) { + accumulator += f(Index<2>{i, j}); + }, + Kokkos::Sum{result}); + } else { + Kokkos::parallel_reduce( + "pops_reduce_sum_index_3d", + Kokkos::MDRangePolicy, Kokkos::IndexType>( + execution, {b.lo[0], b.lo[1], b.lo[2]}, + {b.hi[0] + 1, b.hi[1] + 1, b.hi[2] + 1}), + KOKKOS_LAMBDA(const int i, const int j, const int k, Real& accumulator) { + accumulator += f(Index<3>{i, j, k}); + }, + Kokkos::Sum{result}); + } + return result; +} + +/// SUM reduction over a compile-time-ranked box on the default execution-space instance. +template +Real for_each_cell_reduce_sum(const Box& b, F f) { + if (b.empty()) + return Real(0); + detail::ensure_kokkos_initialized(); + const Kokkos::DefaultExecutionSpace execution{}; + return for_each_cell_reduce_sum(execution, b, f); +} + +/// MAX reduction on an explicit execution-space instance. +template +Real for_each_cell_reduce_max(const ExecutionSpace& execution, const Box& b, F f) { + static_assert(Kokkos::is_execution_space::value, + "PoPS reduction requires a Kokkos execution-space instance"); + if (b.empty()) + return Real(0); + detail::require_iterable_box(b); + detail::ensure_kokkos_initialized(); + Real result = std::numeric_limits::lowest(); + if constexpr (Dim == 1) { + Kokkos::parallel_reduce( + "pops_reduce_max_index_1d", + Kokkos::RangePolicy>(execution, b.lo[0], + b.hi[0] + 1), + KOKKOS_LAMBDA(const int i, Real& accumulator) { + const Real value = f(Index<1>{i}); + if (value > accumulator) + accumulator = value; + }, + Kokkos::Max{result}); + } else if constexpr (Dim == 2) { + Kokkos::parallel_reduce( + "pops_reduce_max_index_2d", + Kokkos::MDRangePolicy, Kokkos::IndexType>( + execution, {b.lo[0], b.lo[1]}, {b.hi[0] + 1, b.hi[1] + 1}), + KOKKOS_LAMBDA(const int i, const int j, Real& accumulator) { + const Real value = f(Index<2>{i, j}); + if (value > accumulator) + accumulator = value; + }, + Kokkos::Max{result}); + } else { + Kokkos::parallel_reduce( + "pops_reduce_max_index_3d", + Kokkos::MDRangePolicy, Kokkos::IndexType>( + execution, {b.lo[0], b.lo[1], b.lo[2]}, + {b.hi[0] + 1, b.hi[1] + 1, b.hi[2] + 1}), + KOKKOS_LAMBDA(const int i, const int j, const int k, Real& accumulator) { + const Real value = f(Index<3>{i, j, k}); + if (value > accumulator) + accumulator = value; + }, + Kokkos::Max{result}); + } + return result; +} + +/// MAX reduction over a compile-time-ranked box on the default execution-space instance. +template +Real for_each_cell_reduce_max(const Box& b, F f) { + if (b.empty()) + return Real(0); + detail::ensure_kokkos_initialized(); + const Kokkos::DefaultExecutionSpace execution{}; + return for_each_cell_reduce_max(execution, b, f); +} + +template +Real for_each_product_reduce_sum(const ExecutionSpace& execution, const Box& product, F f) { + return for_each_cell_reduce_sum(execution, product, f); +} + +template +Real for_each_product_reduce_sum(const Box& product, F f) { + return for_each_cell_reduce_sum(product, f); +} + /// Applies @p f to EACH cell (i, j) of box @p b (bounds inclusive), via Kokkos::parallel_for /// (Serial / OpenMP / Cuda depending on the Kokkos install). @p f is taken by value and MUST be /// device-callable (annotated POPS_HD, captures POD by value). No order guarantee. @@ -172,8 +459,7 @@ void for_each_cell(const Box2D& b, F f) { if constexpr (std::is_same_v) { const std::int64_t nx = static_cast(b.hi[0]) - b.lo[0] + 1; const std::int64_t ny = static_cast(b.hi[1]) - b.lo[1] + 1; - const std::int64_t n_cells = nx * ny; - if (n_cells < detail::foreach_serial_threshold()) { + if (detail::foreach_small_box(nx, ny, detail::foreach_serial_threshold())) { record_fallback(FallbackCounter::kForeachSerialSmallBox); for (int j = b.lo[1]; j <= b.hi[1]; ++j) for (int i = b.lo[0]; i <= b.hi[0]; ++i) diff --git a/include/pops/mesh/geometry/coordinate_map.hpp b/include/pops/mesh/geometry/coordinate_map.hpp new file mode 100644 index 000000000..0bdcdbc1b --- /dev/null +++ b/include/pops/mesh/geometry/coordinate_map.hpp @@ -0,0 +1,450 @@ +/// @file +/// @brief Allocation-free coordinate-map contract for compile-time spatial dimensions. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { + +enum class CoordinateMapKind : std::uint8_t { + Cartesian = 0, + PlanarPolar = 1, +}; + +enum class MetricFaceSide : std::int8_t { + Lower = -1, + Upper = 1, +}; + +enum class InverseMapStatus : std::uint8_t { + Success = 0, + NonFinitePoint = 1, + OffEmbeddedManifold = 2, + SingularPoint = 3, + OutsidePatch = 4, +}; + +template +struct InverseMapResult { + RealVector reference{}; + InverseMapStatus status = InverseMapStatus::NonFinitePoint; + + POPS_HD constexpr bool succeeded() const { return status == InverseMapStatus::Success; } +}; + +template +using CoordinateJacobian = std::array, EmbedDim>; + +/// Exact structural identity. Every parameter that changes physical coordinates is represented; +/// no pointer, cache address or rounded hash participates in equality. +template +struct CoordinateMapIdentity { + CoordinateMapKind kind = CoordinateMapKind::Cartesian; + RealVector origin{}; + RealVector lower{}; + RealVector upper{}; + std::array embedded_axis{}; + std::array orientation{}; + + constexpr bool operator==(const CoordinateMapIdentity&) const = default; +}; + +struct CoordinateMapCapabilities { + int logical_dimension = 0; + int embedding_dimension = 0; + CoordinateMapKind kind = CoordinateMapKind::Cartesian; + bool affine = false; + bool cell_centers = false; + bool face_centers = false; + bool jacobian = false; + bool exact_cell_measure = false; + bool exact_oriented_face_area = false; + bool inverse_map = false; + bool compile_time_axes = false; + bool device_callable = false; + + constexpr bool operator==(const CoordinateMapCapabilities&) const = default; +}; + +namespace coordinate_map_detail { + +POPS_HD constexpr bool finite(Real value) { + return value == value && value != std::numeric_limits::infinity() && + value != -std::numeric_limits::infinity(); +} + +POPS_HD constexpr Real abs(Real value) { + return value < Real(0) ? -value : value; +} + +POPS_HD constexpr Real canonical_zero(Real value) { + return value == Real(0) ? Real(0) : value; +} + +POPS_HD constexpr Real inverse_tolerance(Real left, Real right = Real(0)) { + return Real(64) * std::numeric_limits::epsilon() * (Real(1) + abs(left) + abs(right)); +} + +template +constexpr std::array identity_axes() { + std::array result{}; + for (int axis = 0; axis < Dim; ++axis) + result[axis] = axis; + return result; +} + +template +constexpr std::array positive_orientations() { + std::array result{}; + for (int axis = 0; axis < Dim; ++axis) + result[axis] = 1; + return result; +} + +template +concept CoordinateMapAxis = + requires(const Map& map, const RealVector& lower, const RealVector& upper) { + { + map.template oriented_face_area_vector(lower, upper) + } -> std::same_as>; + { + map.template oriented_face_area_vector(lower, upper) + } -> std::same_as>; + }; + +template +consteval bool coordinate_map_axes(std::integer_sequence) { + return (CoordinateMapAxis && ...); +} + +} // namespace coordinate_map_detail + +/// Static coordinate-map contract. The concrete map type remains visible to the compiler: this +/// concept introduces no virtual dispatch and no run-time geometry switch. +template +concept CoordinateMap = + Dim >= 1 && Dim <= 3 && EmbedDim >= Dim && EmbedDim <= 3 && std::is_trivially_copyable_v && + requires(const Map& map, const RealVector& reference, const RealVector& physical, + const RealVector& lower, const RealVector& upper) { + { Map::logical_dimension } -> std::convertible_to; + { Map::embedding_dimension } -> std::convertible_to; + requires Map::logical_dimension == Dim; + requires Map::embedding_dimension == EmbedDim; + { Map::capabilities() } -> std::same_as; + { map.identity() } -> std::same_as>; + { map.map(reference) } -> std::same_as>; + { map.jacobian(reference) } -> std::same_as>; + { map.inverse_map(physical) } -> std::same_as>; + { map.cell_measure(lower, upper) } -> std::same_as; + } && + coordinate_map_detail::coordinate_map_axes( + std::make_integer_sequence{}); + +/// Orthogonal Cartesian map with compile-time rank and an exact signed axis embedding. Logical +/// coordinates are normalized: reference=(0,...,0) maps to origin and each reference axis spans +/// its positive length in the selected signed physical direction. +template +class CartesianCoordinateMap { + public: + static_assert(Dim >= 1 && Dim <= 3, + "CartesianCoordinateMap supports logical dimensions 1, 2, and 3"); + static_assert(EmbedDim >= Dim && EmbedDim <= 3, + "CartesianCoordinateMap embedding rank must be between Dim and 3"); + + static constexpr int logical_dimension = Dim; + static constexpr int embedding_dimension = EmbedDim; + + static CartesianCoordinateMap make( + RealVector origin, RealVector lengths, + std::array embedded_axis = coordinate_map_detail::identity_axes(), + std::array orientation = coordinate_map_detail::positive_orientations()) { + std::array occupied{}; + for (int physical_axis = 0; physical_axis < EmbedDim; ++physical_axis) { + if (!coordinate_map_detail::finite(origin[physical_axis])) + throw std::invalid_argument("Cartesian coordinate-map origin must be finite"); + origin[physical_axis] = coordinate_map_detail::canonical_zero(origin[physical_axis]); + } + for (int axis = 0; axis < Dim; ++axis) { + if (!coordinate_map_detail::finite(lengths[axis]) || !(lengths[axis] > Real(0))) + throw std::invalid_argument("Cartesian coordinate-map lengths must be finite and positive"); + if (embedded_axis[axis] < 0 || embedded_axis[axis] >= EmbedDim || + occupied[static_cast(embedded_axis[axis])]) + throw std::invalid_argument( + "Cartesian coordinate-map embedded axes must be unique and in range"); + if (orientation[axis] != -1 && orientation[axis] != 1) + throw std::invalid_argument("Cartesian coordinate-map orientations must be -1 or +1"); + occupied[static_cast(embedded_axis[axis])] = true; + lengths[axis] = coordinate_map_detail::canonical_zero(lengths[axis]); + } + return CartesianCoordinateMap(origin, lengths, embedded_axis, orientation); + } + + static constexpr CoordinateMapCapabilities capabilities() { + return {Dim, EmbedDim, CoordinateMapKind::Cartesian, true, true, true, true, true, true, true, + true, true}; + } + + POPS_HD CoordinateMapIdentity identity() const { + CoordinateMapIdentity result{}; + result.kind = CoordinateMapKind::Cartesian; + result.origin = origin_; + result.upper = lengths_; + result.embedded_axis = embedded_axis_; + result.orientation = orientation_; + return result; + } + + POPS_HD RealVector map(const RealVector& reference) const { + RealVector result = origin_; + for (int axis = 0; axis < Dim; ++axis) + result[embedded_axis_[axis]] += Real(orientation_[axis]) * lengths_[axis] * reference[axis]; + return result; + } + + POPS_HD CoordinateJacobian jacobian(const RealVector&) const { + CoordinateJacobian result{}; + for (int axis = 0; axis < Dim; ++axis) + result[embedded_axis_[axis]][axis] = Real(orientation_[axis]) * lengths_[axis]; + return result; + } + + POPS_HD InverseMapResult inverse_map(const RealVector& physical) const { + InverseMapResult result{}; + std::array occupied{}; + for (int axis = 0; axis < Dim; ++axis) + occupied[static_cast(embedded_axis_[axis])] = true; + for (int physical_axis = 0; physical_axis < EmbedDim; ++physical_axis) { + if (!coordinate_map_detail::finite(physical[physical_axis])) { + result.status = InverseMapStatus::NonFinitePoint; + return result; + } + if (!occupied[static_cast(physical_axis)] && + coordinate_map_detail::abs(physical[physical_axis] - origin_[physical_axis]) > + coordinate_map_detail::inverse_tolerance(physical[physical_axis], + origin_[physical_axis])) { + result.status = InverseMapStatus::OffEmbeddedManifold; + return result; + } + } + for (int axis = 0; axis < Dim; ++axis) { + const int physical_axis = embedded_axis_[axis]; + result.reference[axis] = Real(orientation_[axis]) * + (physical[physical_axis] - origin_[physical_axis]) / lengths_[axis]; + } + result.status = InverseMapStatus::Success; + return result; + } + + POPS_HD Real cell_measure(const RealVector& lower, const RealVector& upper) const { + Real measure = Real(1); + for (int axis = 0; axis < Dim; ++axis) + measure *= lengths_[axis] * coordinate_map_detail::abs(upper[axis] - lower[axis]); + return measure; + } + + template + POPS_HD RealVector oriented_face_area_vector(const RealVector& lower, + const RealVector& upper) const { + static_assert(Axis >= 0 && Axis < Dim, "Cartesian metric face axis is outside the map rank"); + Real magnitude = Real(1); + for (int axis = 0; axis < Dim; ++axis) + if (axis != Axis) + magnitude *= lengths_[axis] * coordinate_map_detail::abs(upper[axis] - lower[axis]); + RealVector result{}; + constexpr int side = Side == MetricFaceSide::Upper ? 1 : -1; + result[embedded_axis_[Axis]] = + Real(side * orientation_[Axis]) * coordinate_map_detail::abs(magnitude); + return result; + } + + private: + POPS_HD constexpr CartesianCoordinateMap(RealVector origin, RealVector lengths, + std::array embedded_axis, + std::array orientation) + : origin_(origin), + lengths_(lengths), + embedded_axis_(embedded_axis), + orientation_(orientation) {} + + RealVector origin_{}; + RealVector lengths_{}; + std::array embedded_axis_{}; + std::array orientation_{}; +}; + +/// Exact finite-volume map for an annular sector embedded in the Cartesian plane. Reference axis +/// 0 is radial and axis 1 is azimuthal. Cell measures and integrated face vectors use analytic +/// sector integrals, rather than center-point quadrature. +class PlanarPolarCoordinateMap { + public: + static constexpr int logical_dimension = 2; + static constexpr int embedding_dimension = 2; + static constexpr Real kTwoPi = Real(6.2831853071795864769252867665590057683943387987502); + + static PlanarPolarCoordinateMap make(RealVector<2> center, Real radial_lower, Real radial_upper, + Real angle_lower = Real(0), Real angle_upper = kTwoPi) { + for (int axis = 0; axis < 2; ++axis) { + if (!coordinate_map_detail::finite(center[axis])) + throw std::invalid_argument("planar-polar coordinate-map center must be finite"); + center[axis] = coordinate_map_detail::canonical_zero(center[axis]); + } + if (!coordinate_map_detail::finite(radial_lower) || + !coordinate_map_detail::finite(radial_upper) || !(radial_lower > Real(0)) || + !(radial_upper > radial_lower)) + throw std::invalid_argument( + "planar-polar coordinate-map radial bounds must be finite, positive and ordered"); + if (!coordinate_map_detail::finite(angle_lower) || + !coordinate_map_detail::finite(angle_upper) || !(angle_upper > angle_lower) || + angle_upper - angle_lower > kTwoPi) + throw std::invalid_argument( + "planar-polar coordinate-map angular span must be finite, positive and at most 2*pi"); + return PlanarPolarCoordinateMap( + center, coordinate_map_detail::canonical_zero(radial_lower), radial_upper, + coordinate_map_detail::canonical_zero(angle_lower), angle_upper); + } + + static constexpr CoordinateMapCapabilities capabilities() { + return {2, 2, CoordinateMapKind::PlanarPolar, false, true, true, true, true, true, true, + true, true}; + } + + POPS_HD CoordinateMapIdentity<2, 2> identity() const { + CoordinateMapIdentity<2, 2> result{}; + result.kind = CoordinateMapKind::PlanarPolar; + result.origin = center_; + result.lower = RealVector<2>{radial_lower_, angle_lower_}; + result.upper = RealVector<2>{radial_upper_, angle_upper_}; + result.embedded_axis = {0, 1}; + result.orientation = {1, 1}; + return result; + } + + POPS_HD RealVector<2> map(const RealVector<2>& reference) const { + const Real radius = radius_(reference[0]); + const Real angle = angle_(reference[1]); + return RealVector<2>{center_[0] + radius * std::cos(angle), + center_[1] + radius * std::sin(angle)}; + } + + POPS_HD CoordinateJacobian<2, 2> jacobian(const RealVector<2>& reference) const { + const Real radius = radius_(reference[0]); + const Real angle = angle_(reference[1]); + const Real radial_span = radial_upper_ - radial_lower_; + const Real angular_span = angle_upper_ - angle_lower_; + return {{{radial_span * std::cos(angle), -radius * angular_span * std::sin(angle)}, + {radial_span * std::sin(angle), radius * angular_span * std::cos(angle)}}}; + } + + POPS_HD InverseMapResult<2> inverse_map(const RealVector<2>& physical) const { + InverseMapResult<2> result{}; + if (!coordinate_map_detail::finite(physical[0]) || + !coordinate_map_detail::finite(physical[1])) { + result.status = InverseMapStatus::NonFinitePoint; + return result; + } + const Real x = physical[0] - center_[0]; + const Real y = physical[1] - center_[1]; + const Real radius = std::sqrt(x * x + y * y); + if (!(radius > Real(0))) { + result.status = InverseMapStatus::SingularPoint; + return result; + } + + Real angle = std::atan2(y, x); + angle += std::floor((angle_lower_ - angle) / kTwoPi) * kTwoPi; + if (angle < angle_lower_) + angle += kTwoPi; + if (angle >= angle_lower_ + kTwoPi) + angle -= kTwoPi; + + const Real radial_span = radial_upper_ - radial_lower_; + const Real angular_span = angle_upper_ - angle_lower_; + result.reference = RealVector<2>{(radius - radial_lower_) / radial_span, + (angle - angle_lower_) / angular_span}; + const Real tolerance = Real(64) * std::numeric_limits::epsilon(); + if (result.reference[0] < -tolerance || result.reference[0] > Real(1) + tolerance || + result.reference[1] < -tolerance || result.reference[1] > Real(1) + tolerance) { + result.status = InverseMapStatus::OutsidePatch; + return result; + } + result.reference[0] = clamp_unit_(result.reference[0]); + result.reference[1] = clamp_unit_(result.reference[1]); + result.status = InverseMapStatus::Success; + return result; + } + + POPS_HD Real cell_measure(const RealVector<2>& lower, const RealVector<2>& upper) const { + const Real radial_lower = radius_(lower[0]); + const Real radial_upper = radius_(upper[0]); + const Real angle_span = (angle_upper_ - angle_lower_) * (upper[1] - lower[1]); + return coordinate_map_detail::abs( + Real(0.5) * (radial_upper * radial_upper - radial_lower * radial_lower) * angle_span); + } + + template + POPS_HD RealVector<2> oriented_face_area_vector(const RealVector<2>& lower, + const RealVector<2>& upper) const { + static_assert(Axis == 0 || Axis == 1, "planar-polar metric face axis must be 0 or 1"); + constexpr Real side = Side == MetricFaceSide::Upper ? Real(1) : Real(-1); + if constexpr (Axis == 0) { + const Real reference_radius = Side == MetricFaceSide::Upper ? upper[0] : lower[0]; + const Real radius = radius_(reference_radius); + const Real angle_lower = angle_(lower[1]); + const Real angle_upper = angle_(upper[1]); + return RealVector<2>{side * radius * (std::sin(angle_upper) - std::sin(angle_lower)), + side * radius * (-std::cos(angle_upper) + std::cos(angle_lower))}; + } else { + const Real reference_angle = Side == MetricFaceSide::Upper ? upper[1] : lower[1]; + const Real angle = angle_(reference_angle); + const Real radial_span = radius_(upper[0]) - radius_(lower[0]); + return RealVector<2>{side * radial_span * -std::sin(angle), + side * radial_span * std::cos(angle)}; + } + } + + private: + POPS_HD constexpr PlanarPolarCoordinateMap(RealVector<2> center, Real radial_lower, + Real radial_upper, Real angle_lower, Real angle_upper) + : center_(center), + radial_lower_(radial_lower), + radial_upper_(radial_upper), + angle_lower_(angle_lower), + angle_upper_(angle_upper) {} + + POPS_HD Real radius_(Real reference_radius) const { + return radial_lower_ + reference_radius * (radial_upper_ - radial_lower_); + } + + POPS_HD Real angle_(Real reference_angle) const { + return angle_lower_ + reference_angle * (angle_upper_ - angle_lower_); + } + + POPS_HD static constexpr Real clamp_unit_(Real value) { + return value < Real(0) ? Real(0) : (value > Real(1) ? Real(1) : value); + } + + RealVector<2> center_{}; + Real radial_lower_ = Real(1); + Real radial_upper_ = Real(2); + Real angle_lower_ = Real(0); + Real angle_upper_ = kTwoPi; +}; + +static_assert(CoordinateMap<1, 1, CartesianCoordinateMap<1>>); +static_assert(CoordinateMap<2, 2, CartesianCoordinateMap<2>>); +static_assert(CoordinateMap<3, 3, CartesianCoordinateMap<3>>); +static_assert(CoordinateMap<1, 3, CartesianCoordinateMap<1, 3>>); +static_assert(CoordinateMap<2, 2, PlanarPolarCoordinateMap>); + +} // namespace pops diff --git a/include/pops/mesh/geometry/prepared_metric_provider.hpp b/include/pops/mesh/geometry/prepared_metric_provider.hpp new file mode 100644 index 000000000..97ae81e9b --- /dev/null +++ b/include/pops/mesh/geometry/prepared_metric_provider.hpp @@ -0,0 +1,195 @@ +/// @file +/// @brief Prepared cell/face metrics over a compile-time coordinate map. + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +namespace pops { + +template +struct PreparedMetricIdentity { + CoordinateMapIdentity coordinate_map{}; + Box domain{}; + + constexpr bool operator==(const PreparedMetricIdentity&) const = default; +}; + +struct PreparedMetricCapabilities { + CoordinateMapCapabilities coordinate_map{}; + bool exact_domain_identity = false; + bool ghost_coordinates = false; + bool allocation_free_queries = false; + + constexpr bool operator==(const PreparedMetricCapabilities&) const = default; +}; + +template +struct ReferenceCell { + RealVector lower{}; + RealVector upper{}; + RealVector center{}; +}; + +namespace prepared_metric_detail { + +template +concept PreparedMetricAxis = requires(const Provider& provider, const Index& index) { + { + provider.template face_center(index) + } -> std::same_as; + { + provider.template face_center(index) + } -> std::same_as; + { + provider.template oriented_face_area_vector(index) + } -> std::same_as; + { + provider.template oriented_face_area_vector(index) + } -> std::same_as; +}; + +template +consteval bool prepared_metric_axes(std::integer_sequence) { + return (PreparedMetricAxis && ...); +} + +} // namespace prepared_metric_detail + +/// Prepared metric-provider contract. Axis and face side are compile-time values; providers are +/// trivially copyable values suitable for direct capture in Kokkos kernels. +template +concept PreparedMetricProvider = + Dim >= 1 && Dim <= 3 && std::is_trivially_copyable_v && + requires(const Provider& provider, const Index& index, + const typename Provider::PhysicalPoint& physical) { + { Provider::logical_dimension } -> std::convertible_to; + requires Provider::logical_dimension == Dim; + { Provider::embedding_dimension } -> std::convertible_to; + { Provider::capabilities() } -> std::same_as; + { + provider.identity() + } -> std::same_as>; + { provider.reference_cell(index) } -> std::same_as>; + { provider.cell_center(index) } -> std::same_as; + { + provider.jacobian(index) + } -> std::same_as>; + { provider.cell_measure(index) } -> std::same_as; + { provider.inverse_map(physical) } -> std::same_as>; + } && + prepared_metric_detail::prepared_metric_axes( + std::make_integer_sequence{}); + +/// Validated, allocation-free binding of a coordinate map to an inclusive integer domain. The +/// map type is retained in the provider type, so Cartesian and polar queries never share a run-time +/// dispatch path. Coordinates outside the domain remain defined for ghost-cell kernels. +template +class PreparedMappedMetricProvider { + public: + static constexpr int logical_dimension = Map::logical_dimension; + static constexpr int embedding_dimension = Map::embedding_dimension; + using PhysicalPoint = RealVector; + using Identity = PreparedMetricIdentity; + + static_assert(CoordinateMap, + "PreparedMappedMetricProvider requires the complete CoordinateMap contract"); + + static PreparedMappedMetricProvider prepare(const Box& domain, Map map) { + if (domain.empty()) + throw std::invalid_argument("prepared metric provider requires a non-empty index domain"); + RealVector inverse_extent{}; + for (int axis = 0; axis < logical_dimension; ++axis) { + const std::int64_t extent = domain.length(axis); + if (extent <= 0) + throw std::invalid_argument("prepared metric provider requires positive axis extents"); + inverse_extent[axis] = Real(1) / static_cast(extent); + } + return PreparedMappedMetricProvider(domain, map, inverse_extent); + } + + static constexpr PreparedMetricCapabilities capabilities() { + return {Map::capabilities(), true, true, true}; + } + + POPS_HD Identity identity() const { return Identity{map_.identity(), domain_}; } + + POPS_HD ReferenceCell reference_cell( + const Index& index) const { + ReferenceCell result{}; + for (int axis = 0; axis < logical_dimension; ++axis) { + const Real offset = static_cast(index[axis]) - static_cast(domain_.lo[axis]); + result.lower[axis] = offset * inverse_extent_[axis]; + result.upper[axis] = (offset + Real(1)) * inverse_extent_[axis]; + result.center[axis] = (offset + Real(0.5)) * inverse_extent_[axis]; + } + return result; + } + + POPS_HD PhysicalPoint cell_center(const Index& index) const { + return map_.map(reference_cell(index).center); + } + + template + POPS_HD PhysicalPoint face_center(const Index& index) const { + static_assert(Axis >= 0 && Axis < logical_dimension, + "prepared metric face axis is outside the provider rank"); + auto reference = reference_cell(index); + reference.center[Axis] = + Side == MetricFaceSide::Upper ? reference.upper[Axis] : reference.lower[Axis]; + return map_.map(reference.center); + } + + POPS_HD CoordinateJacobian jacobian( + const Index& index) const { + return map_.jacobian(reference_cell(index).center); + } + + POPS_HD Real cell_measure(const Index& index) const { + const auto reference = reference_cell(index); + return map_.cell_measure(reference.lower, reference.upper); + } + + template + POPS_HD PhysicalPoint oriented_face_area_vector(const Index& index) const { + static_assert(Axis >= 0 && Axis < logical_dimension, + "prepared metric face axis is outside the provider rank"); + const auto reference = reference_cell(index); + return map_.template oriented_face_area_vector(reference.lower, reference.upper); + } + + POPS_HD InverseMapResult inverse_map(const PhysicalPoint& physical) const { + return map_.inverse_map(physical); + } + + POPS_HD const Box& domain() const { return domain_; } + POPS_HD const Map& coordinate_map() const { return map_; } + + private: + POPS_HD constexpr PreparedMappedMetricProvider(Box domain, Map map, + RealVector inverse_extent) + : domain_(domain), map_(map), inverse_extent_(inverse_extent) {} + + Box domain_{}; + Map map_; + RealVector inverse_extent_{}; +}; + +template +[[nodiscard]] auto prepare_metric_provider(const Box& domain, Map map) { + return PreparedMappedMetricProvider::prepare(domain, map); +} + +static_assert(PreparedMetricProvider<1, PreparedMappedMetricProvider>>); +static_assert(PreparedMetricProvider<2, PreparedMappedMetricProvider>>); +static_assert(PreparedMetricProvider<3, PreparedMappedMetricProvider>>); +static_assert(PreparedMetricProvider<2, PreparedMappedMetricProvider>); + +} // namespace pops diff --git a/include/pops/mesh/index/box.hpp b/include/pops/mesh/index/box.hpp new file mode 100644 index 000000000..8cfb39e66 --- /dev/null +++ b/include/pops/mesh/index/box.hpp @@ -0,0 +1,199 @@ +/// @file +/// @brief Compile-time-ranked inclusive integer index boxes. + +#pragma once + +#include +#include + +#include +#include +#include + +namespace pops { + +namespace detail { + +inline int checked_box_index(std::int64_t value, const char* operation) { + if (value < std::numeric_limits::min() || value > std::numeric_limits::max()) + throw std::overflow_error(operation); + return static_cast(value); +} + +inline int floor_div_index(int numerator, int denominator) { + if (denominator <= 0) + throw std::invalid_argument("pops::Box::coarsen: ratio must be strictly positive"); + if (numerator == std::numeric_limits::min() && denominator == -1) + throw std::overflow_error("pops::Box::coarsen: quotient is outside the signed index range"); + const int quotient = numerator / denominator; + const int remainder = numerator % denominator; + return remainder < 0 ? quotient - 1 : quotient; +} + +} // namespace detail + +/// Inclusive integer box over a compile-time spatial rank. A box is empty when any upper bound +/// is below its lower bound; empty boxes are preserved by geometric transforms. +template +struct Box { + static_assert(Dim >= 1 && Dim <= 3, "pops::Box only supports dimensions 1, 2, and 3"); + + static constexpr int rank = Dim; + Index lo; + Index hi; + + POPS_HD constexpr Box() : lo{}, hi{} { + for (int axis = 0; axis < Dim; ++axis) + hi[axis] = -1; + } + + POPS_HD constexpr Box(Index lower, Index upper) : lo(lower), hi(upper) {} + + /// Box covering the half-open extent [0, extents) with inclusive upper bounds. + static Box from_extents(const Extent& extents) { + Box result; + for (int axis = 0; axis < Dim; ++axis) { + if (extents[axis] < 0) + throw std::invalid_argument("pops::Box::from_extents: extents must be non-negative"); + result.lo[axis] = 0; + result.hi[axis] = detail::checked_box_index( + extents[axis] - 1, "pops::Box::from_extents: extent exceeds signed index range"); + } + return result; + } + + POPS_HD constexpr bool empty() const { + for (int axis = 0; axis < Dim; ++axis) + if (hi[axis] < lo[axis]) + return true; + return false; + } + + /// Exact extent along an axis; empty boxes report zero along every axis. + POPS_HD constexpr std::int64_t length(int axis) const { + return empty() ? 0 : static_cast(hi[axis]) - lo[axis] + 1; + } + + POPS_HD constexpr Extent extent() const { + Extent result{}; + for (int axis = 0; axis < Dim; ++axis) + result[axis] = length(axis); + return result; + } + + /// Number of points with host-side overflow detection. + std::int64_t numPts() const { + if (empty()) + return 0; + std::int64_t count = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t axis_extent = length(axis); + if (count > std::numeric_limits::max() / axis_extent) + throw std::overflow_error("pops::Box::numPts: point count exceeds int64_t"); + count *= axis_extent; + } + return count; + } + + POPS_HD constexpr bool contains(const Index& index) const { + if (empty()) + return false; + for (int axis = 0; axis < Dim; ++axis) + if (index[axis] < lo[axis] || index[axis] > hi[axis]) + return false; + return true; + } + + POPS_HD constexpr bool contains(const Box& other) const { + if (other.empty()) + return false; + for (int axis = 0; axis < Dim; ++axis) + if (other.lo[axis] < lo[axis] || other.hi[axis] > hi[axis]) + return false; + return true; + } + + POPS_HD constexpr Box intersect(const Box& other) const { + Box result{}; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = lo[axis] < other.lo[axis] ? other.lo[axis] : lo[axis]; + result.hi[axis] = hi[axis] < other.hi[axis] ? hi[axis] : other.hi[axis]; + } + return result; + } + + Box grow(int amount) const { + if (empty()) + return *this; + Box result = *this; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = detail::checked_box_index(static_cast(lo[axis]) - amount, + "pops::Box::grow: lower bound overflow"); + result.hi[axis] = detail::checked_box_index(static_cast(hi[axis]) + amount, + "pops::Box::grow: upper bound overflow"); + } + return result; + } + + Box grow(int axis, int amount) const { + if (axis < 0 || axis >= Dim) + throw std::invalid_argument("pops::Box::grow: axis is outside the compile-time rank"); + if (empty()) + return *this; + Box result = *this; + result.lo[axis] = detail::checked_box_index(static_cast(lo[axis]) - amount, + "pops::Box::grow: lower bound overflow"); + result.hi[axis] = detail::checked_box_index(static_cast(hi[axis]) + amount, + "pops::Box::grow: upper bound overflow"); + return result; + } + + /// Checked host-side translation used by future periodic-image construction. + Box shift(const Index& offset) const { + if (empty()) + return *this; + Box result = *this; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = + detail::checked_box_index(static_cast(lo[axis]) + offset[axis], + "pops::Box::shift: lower bound overflow"); + result.hi[axis] = + detail::checked_box_index(static_cast(hi[axis]) + offset[axis], + "pops::Box::shift: upper bound overflow"); + } + return result; + } + + Box refine(int ratio) const { + if (ratio <= 0) + throw std::invalid_argument("pops::Box::refine: ratio must be strictly positive"); + if (empty()) + return *this; + Box result{}; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = detail::checked_box_index(static_cast(lo[axis]) * ratio, + "pops::Box::refine: lower bound overflow"); + result.hi[axis] = + detail::checked_box_index(static_cast(hi[axis]) * ratio + ratio - 1, + "pops::Box::refine: upper bound overflow"); + } + return result; + } + + Box coarsen(int ratio) const { + if (ratio <= 0) + throw std::invalid_argument("pops::Box::coarsen: ratio must be strictly positive"); + if (empty()) + return *this; + Box result{}; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = detail::floor_div_index(lo[axis], ratio); + result.hi[axis] = detail::floor_div_index(hi[axis], ratio); + } + return result; + } + + POPS_HD constexpr bool operator==(const Box&) const = default; +}; + +} // namespace pops diff --git a/include/pops/mesh/index/entity_index.hpp b/include/pops/mesh/index/entity_index.hpp new file mode 100644 index 000000000..261c14a09 --- /dev/null +++ b/include/pops/mesh/index/entity_index.hpp @@ -0,0 +1,36 @@ +/// @file +/// @brief Typed compile-time-ranked coordinates for cell and face kernels. + +#pragma once + +#include + +namespace pops { + +/// Cell kernels use the canonical signed compile-time-ranked coordinate. +template +using CellIndex = Index; + +/// Coordinate of a face whose normal axis is selected at compile time. Keeping Axis in the type +/// lets a numerical functor specialize flux/metric access without a per-face direction branch. +template +struct FaceIndex { + static_assert(Dim >= 1 && Dim <= 3, "pops::FaceIndex only supports dimensions 1, 2, and 3"); + static_assert(Axis >= 0 && Axis < Dim, + "pops::FaceIndex normal axis must lie inside the compile-time rank"); + + static constexpr int rank = Dim; + static constexpr int normal_axis = Axis; + + Index coordinate{}; + + POPS_HD constexpr FaceIndex() = default; + POPS_HD constexpr explicit FaceIndex(Index value) : coordinate(value) {} + + POPS_HD constexpr int& operator[](int axis) { return coordinate[axis]; } + POPS_HD constexpr int operator[](int axis) const { return coordinate[axis]; } + + POPS_HD constexpr bool operator==(const FaceIndex&) const = default; +}; + +} // namespace pops diff --git a/include/pops/mesh/index/extent.hpp b/include/pops/mesh/index/extent.hpp new file mode 100644 index 000000000..d9c7f18b7 --- /dev/null +++ b/include/pops/mesh/index/extent.hpp @@ -0,0 +1,57 @@ +/// @file +/// @brief Compile-time-ranked non-negative box extents. + +#pragma once + +#include + +#include +#include +#include + +namespace pops { + +namespace extent_detail { + +template && !std::is_same_v> +struct lossless_extent_scalar_impl : std::false_type {}; + +template +struct lossless_extent_scalar_impl + : std::bool_constant< + std::numeric_limits::lowest() >= std::numeric_limits::lowest() && + std::numeric_limits::max() <= std::numeric_limits::max()> {}; + +template +inline constexpr bool lossless_extent_scalar = lossless_extent_scalar_impl>::value; + +} // namespace extent_detail + +/// Non-negative extent per spatial axis. Construction and validation belong to the owning box. +template +struct Extent { + static_assert(Dim >= 1 && Dim <= 3, "pops::Extent only supports dimensions 1, 2, and 3"); + + static constexpr int rank = Dim; + std::int64_t values[Dim]{}; + + POPS_HD constexpr Extent() = default; + + template && ...), + int> = 0> + POPS_HD constexpr explicit Extent(Sizes... sizes) : values{static_cast(sizes)...} {} + + POPS_HD constexpr std::int64_t& operator[](int axis) { return values[axis]; } + POPS_HD constexpr std::int64_t operator[](int axis) const { return values[axis]; } + + POPS_HD constexpr bool operator==(const Extent& other) const { + for (int axis = 0; axis < Dim; ++axis) + if (values[axis] != other.values[axis]) + return false; + return true; + } +}; + +} // namespace pops diff --git a/include/pops/mesh/index/index.hpp b/include/pops/mesh/index/index.hpp new file mode 100644 index 000000000..6433ea13f --- /dev/null +++ b/include/pops/mesh/index/index.hpp @@ -0,0 +1,56 @@ +/// @file +/// @brief Compile-time-ranked integer cell coordinates. + +#pragma once + +#include + +#include +#include + +namespace pops { + +namespace index_detail { + +template && !std::is_same_v> +struct lossless_index_scalar_impl : std::false_type {}; + +template +struct lossless_index_scalar_impl + : std::bool_constant::lowest() >= std::numeric_limits::lowest() && + std::numeric_limits::max() <= std::numeric_limits::max()> {}; + +template +inline constexpr bool lossless_index_scalar = lossless_index_scalar_impl>::value; + +} // namespace index_detail + +/// Signed cell coordinate with a compile-time spatial rank. +template +struct Index { + static_assert(Dim >= 1 && Dim <= 3, "pops::Index only supports dimensions 1, 2, and 3"); + + static constexpr int rank = Dim; + int values[Dim]{}; + + POPS_HD constexpr Index() = default; + + template && ...), + int> = 0> + POPS_HD constexpr explicit Index(Coordinates... coordinates) + : values{static_cast(coordinates)...} {} + + POPS_HD constexpr int& operator[](int axis) { return values[axis]; } + POPS_HD constexpr int operator[](int axis) const { return values[axis]; } + + POPS_HD constexpr bool operator==(const Index& other) const { + for (int axis = 0; axis < Dim; ++axis) + if (values[axis] != other.values[axis]) + return false; + return true; + } +}; + +} // namespace pops diff --git a/include/pops/mesh/index/real_vector.hpp b/include/pops/mesh/index/real_vector.hpp new file mode 100644 index 000000000..5a3d28b6a --- /dev/null +++ b/include/pops/mesh/index/real_vector.hpp @@ -0,0 +1,63 @@ +/// @file +/// @brief Compile-time-ranked real Cartesian coordinates. + +#pragma once + +#include + +#include +#include + +namespace pops { + +namespace real_vector_detail { + +template && !std::is_same_v, + bool IsFloating = std::is_floating_point_v> +struct lossless_real_scalar_impl : std::false_type {}; + +template +struct lossless_real_scalar_impl + : std::bool_constant::digits <= std::numeric_limits::digits> {}; + +template +struct lossless_real_scalar_impl + : std::bool_constant< + std::numeric_limits::digits <= std::numeric_limits::digits && + std::numeric_limits::max_exponent <= std::numeric_limits::max_exponent && + std::numeric_limits::min_exponent >= std::numeric_limits::min_exponent> {}; + +template +inline constexpr bool lossless_real_scalar = lossless_real_scalar_impl>::value; + +} // namespace real_vector_detail + +/// Double-precision Cartesian coordinate with a compile-time spatial rank. +template +struct RealVector { + static_assert(Dim >= 1 && Dim <= 3, "pops::RealVector only supports dimensions 1, 2, and 3"); + + static constexpr int rank = Dim; + double values[Dim]{}; + + POPS_HD constexpr RealVector() = default; + + template && ...), + int> = 0> + POPS_HD constexpr explicit RealVector(Coordinates... coordinates) + : values{static_cast(coordinates)...} {} + + POPS_HD constexpr double& operator[](int axis) { return values[axis]; } + POPS_HD constexpr double operator[](int axis) const { return values[axis]; } + + POPS_HD constexpr bool operator==(const RealVector& other) const { + for (int axis = 0; axis < Dim; ++axis) + if (values[axis] != other.values[axis]) + return false; + return true; + } +}; + +} // namespace pops diff --git a/include/pops/mesh/layout/nd/box_array.hpp b/include/pops/mesh/layout/nd/box_array.hpp new file mode 100644 index 000000000..119c5a0de --- /dev/null +++ b/include/pops/mesh/layout/nd/box_array.hpp @@ -0,0 +1,203 @@ +/// @file +/// @brief Ordered production ND patch layout with bounded exact validation. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace pops::mesh { + +/// Explicit finite budget for validation whose cost is quadratic in the number of patches. +struct BoxArrayValidationBudget { + std::size_t boxes = 0; + std::size_t overlap_pairs = 0; + + bool operator==(const BoxArrayValidationBudget&) const = default; +}; + +/// Portable unsigned four-limb count used to compare exact ND cell volumes without narrowing. +class ExactCellCount { + public: + constexpr ExactCellCount() = default; + constexpr bool operator==(const ExactCellCount&) const = default; + + static ExactCellCount from_uint64(std::uint64_t value) { + ExactCellCount result; + result.limbs_[0] = static_cast(value); + result.limbs_[1] = static_cast(value >> 32); + return result; + } + + template + static ExactCellCount from_box(const Box& box) { + if (box.empty()) + return {}; + ExactCellCount result = from_uint64(1); + for (int axis = 0; axis < Dim; ++axis) + result.multiply_(static_cast(box.length(axis))); + return result; + } + + bool add(const ExactCellCount& other) noexcept { + std::uint64_t carry = 0; + for (std::size_t limb = 0; limb < limbs_.size(); ++limb) { + const std::uint64_t sum = + static_cast(limbs_[limb]) + other.limbs_[limb] + carry; + limbs_[limb] = static_cast(sum); + carry = sum >> 32; + } + return carry == 0; + } + + private: + void multiply_(std::uint64_t factor) { + ExactCellCount result; + const std::uint32_t low = static_cast(factor); + const std::uint32_t high = static_cast(factor >> 32); + for (std::size_t limb = 0; limb < limbs_.size(); ++limb) { + if (low != 0) + result.add_product_(limb, limbs_[limb], low); + if (high != 0) + result.add_product_(limb + 1, limbs_[limb], high); + } + *this = result; + } + + void add_product_(std::size_t offset, std::uint32_t left, std::uint32_t right) { + const std::uint64_t product = static_cast(left) * right; + add_word_(offset, static_cast(product)); + add_word_(offset + 1, static_cast(product >> 32)); + } + + void add_word_(std::size_t offset, std::uint32_t word) { + while (word != 0) { + if (offset >= limbs_.size()) + throw std::overflow_error("ExactCellCount exceeds four limbs"); + const std::uint64_t sum = static_cast(limbs_[offset]) + word; + limbs_[offset] = static_cast(sum); + word = static_cast(sum >> 32); + ++offset; + } + } + + std::array limbs_{}; +}; + +/// Ordered collection of disjoint candidate patches in a compile-time spatial rank. +template +class BoxArray { + static_assert(Dim >= 1 && Dim <= 3, "BoxArray only supports dimensions 1, 2, and 3"); + + public: + using box_type = Box; + + BoxArray() = default; + explicit BoxArray(std::vector boxes) : boxes_(std::move(boxes)) {} + + /// Tile a domain deterministically. Axis 0 is the contiguous ordering axis. + static BoxArray from_domain(const box_type& domain, const std::array& max_grid_size) { + for (int axis = 0; axis < Dim; ++axis) + if (max_grid_size[axis] <= 0) + throw std::invalid_argument("BoxArray max grid sizes must be strictly positive"); + if (domain.empty()) + return {}; + + std::array segments{}; + std::size_t tile_count = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::uint64_t length = static_cast(domain.length(axis)); + const std::uint64_t limit = static_cast(max_grid_size[axis]); + segments[axis] = 1 + (length - 1) / limit; + if (segments[axis] > std::numeric_limits::max() / tile_count) + throw std::length_error("BoxArray tile count exceeds size_t"); + tile_count *= static_cast(segments[axis]); + } + if (tile_count > std::vector{}.max_size()) + throw std::length_error("BoxArray tile count exceeds vector capacity"); + + std::vector boxes; + boxes.reserve(tile_count); + for (std::size_t ordinal = 0; ordinal < tile_count; ++ordinal) { + box_type tile{}; + std::size_t quotient = ordinal; + for (int axis = 0; axis < Dim; ++axis) { + const std::uint64_t segment = quotient % segments[axis]; + quotient /= segments[axis]; + const std::uint64_t length = static_cast(domain.length(axis)); + const std::uint64_t base = length / segments[axis]; + const std::uint64_t remainder = length % segments[axis]; + const std::uint64_t offset = segment * base + (segment < remainder ? segment : remainder); + const std::uint64_t width = base + (segment < remainder ? 1 : 0); + const std::int64_t lower = + static_cast(domain.lo[axis]) + static_cast(offset); + tile.lo[axis] = static_cast(lower); + tile.hi[axis] = static_cast(lower + static_cast(width) - 1); + } + boxes.push_back(tile); + } + return BoxArray{std::move(boxes)}; + } + + std::size_t size() const noexcept { return boxes_.size(); } + bool empty() const noexcept { return boxes_.empty(); } + const box_type& operator[](std::size_t index) const { return boxes_.at(index); } + const std::vector& boxes() const noexcept { return boxes_; } + + bool operator==(const BoxArray&) const = default; + + ExactCellCount exact_cell_count() const { + ExactCellCount total; + for (const box_type& box : boxes_) + if (!total.add(ExactCellCount::from_box(box))) + throw std::overflow_error("BoxArray exact cell count exceeds four limbs"); + return total; + } + + /// Validate that every patch is non-empty, inside domain and pairwise disjoint. + bool is_disjoint_within(const box_type& domain, BoxArrayValidationBudget budget) const { + require_budget_(budget); + for (std::size_t left = 0; left < boxes_.size(); ++left) { + const box_type& box = boxes_[left]; + if (box.empty() || !domain.contains(box)) + return false; + for (std::size_t right = 0; right < left; ++right) + if (!box.intersect(boxes_[right]).empty()) + return false; + } + return true; + } + + bool tiles_exactly(const box_type& domain, BoxArrayValidationBudget budget) const { + if (domain.empty()) + return boxes_.empty(); + if (!is_disjoint_within(domain, budget)) + return false; + return exact_cell_count() == ExactCellCount::from_box(domain); + } + + private: + void require_budget_(BoxArrayValidationBudget budget) const { + if (boxes_.size() > budget.boxes) + throw std::length_error("BoxArray validation exceeds the explicit patch budget"); + std::size_t pairs = 0; + if (boxes_.size() > 1) { + if (boxes_.size() - 1 > std::numeric_limits::max() / boxes_.size()) + throw std::length_error("BoxArray overlap count exceeds size_t"); + pairs = boxes_.size() * (boxes_.size() - 1) / 2; + } + if (pairs > budget.overlap_pairs) + throw std::length_error("BoxArray validation exceeds the explicit overlap budget"); + } + + std::vector boxes_{}; +}; + +} // namespace pops::mesh diff --git a/include/pops/mesh/layout/nd/distribution.hpp b/include/pops/mesh/layout/nd/distribution.hpp new file mode 100644 index 000000000..0b259c4a4 --- /dev/null +++ b/include/pops/mesh/layout/nd/distribution.hpp @@ -0,0 +1,111 @@ +/// @file +/// @brief Exact ND patch ownership over an explicit process-coordinate space. + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace pops::mesh { + +enum class DistributionMode { partitioned, replicated }; + +/// Ordered ownership of a BoxArray. Replicated layouts intentionally have no unique owner vector. +template +class Distribution { + static_assert(Dim >= 1 && Dim <= 3, "Distribution only supports dimensions 1, 2, and 3"); + + public: + using rank_type = Index; + + Distribution() = default; + + Distribution(const BoxArray& boxes, RankSpace rank_space, DistributionMode mode, + std::vector owners = {}) + : layout_(boxes), + rank_space_(std::move(rank_space)), + mode_(mode), + owners_(std::move(owners)) { + validate_(); + } + + static Distribution partitioned(const BoxArray& boxes, RankSpace rank_space, + std::vector owners) { + return Distribution(boxes, std::move(rank_space), DistributionMode::partitioned, + std::move(owners)); + } + + static Distribution replicated(const BoxArray& boxes, RankSpace rank_space) { + return Distribution(boxes, std::move(rank_space), DistributionMode::replicated); + } + + std::size_t box_count() const noexcept { return layout_.size(); } + const BoxArray& layout() const noexcept { return layout_; } + bool matches_layout(const BoxArray& layout) const noexcept { return layout_ == layout; } + const RankSpace& rank_space() const noexcept { return rank_space_; } + DistributionMode mode() const noexcept { return mode_; } + bool replicated() const noexcept { return mode_ == DistributionMode::replicated; } + const std::vector& owners() const noexcept { return owners_; } + + const rank_type& owner(std::size_t global_box) const { + require_global_box_(global_box); + if (replicated()) + throw std::logic_error("replicated Distribution layouts have no unique owner"); + return owners_[global_box]; + } + + bool is_local(std::size_t global_box, const rank_type& rank) const { + require_global_box_(global_box); + if (!rank_space_.contains(rank)) + throw std::out_of_range("Distribution rank coordinate is outside the process space"); + return replicated() || owners_[global_box] == rank; + } + + std::vector local_box_indices(const rank_type& rank) const { + if (!rank_space_.contains(rank)) + throw std::out_of_range("Distribution rank coordinate is outside the process space"); + std::vector result; + result.reserve(replicated() ? layout_.size() : owners_.size()); + for (std::size_t global_box = 0; global_box < layout_.size(); ++global_box) + if (replicated() || owners_[global_box] == rank) + result.push_back(global_box); + return result; + } + + bool operator==(const Distribution&) const = default; + + private: + void validate_() const { + if (mode_ != DistributionMode::partitioned && mode_ != DistributionMode::replicated) + throw std::invalid_argument("Distribution mode is invalid"); + if (!layout_.empty() && rank_space_.empty()) + throw std::invalid_argument("a non-empty Distribution requires a non-empty rank space"); + if (replicated()) { + if (!owners_.empty()) + throw std::invalid_argument("a replicated Distribution must not store unique owners"); + return; + } + if (owners_.size() != layout_.size()) + throw std::invalid_argument("Distribution owner count must equal its patch count"); + for (const rank_type& owner_coordinate : owners_) + if (!rank_space_.contains(owner_coordinate)) + throw std::out_of_range("Distribution owner is outside the process space"); + } + + void require_global_box_(std::size_t global_box) const { + if (global_box >= layout_.size()) + throw std::out_of_range("Distribution global patch index is outside the layout"); + } + + BoxArray layout_{}; + RankSpace rank_space_{}; + DistributionMode mode_ = DistributionMode::replicated; + std::vector owners_{}; +}; + +} // namespace pops::mesh diff --git a/include/pops/mesh/layout/nd/rank_space.hpp b/include/pops/mesh/layout/nd/rank_space.hpp new file mode 100644 index 000000000..51622dda3 --- /dev/null +++ b/include/pops/mesh/layout/nd/rank_space.hpp @@ -0,0 +1,107 @@ +/// @file +/// @brief Compile-time-ranked process-coordinate space for production ND layouts. + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace pops::mesh { + +/// Half-open Cartesian process-coordinate space with axis 0 contiguous in linear rank order. +template +class RankSpace { + static_assert(Dim >= 1 && Dim <= 3, "RankSpace only supports dimensions 1, 2, and 3"); + + public: + RankSpace() = default; + + RankSpace(Index origin, Extent extent) : origin_(origin), extent_(extent) { + size_ = checked_size_(); + } + + constexpr const Index& origin() const noexcept { return origin_; } + constexpr const Extent& extent() const noexcept { return extent_; } + constexpr std::size_t size() const noexcept { return size_; } + constexpr bool empty() const noexcept { return size_ == 0; } + + bool contains(const Index& coordinate) const noexcept { + if (empty()) + return false; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t offset = static_cast(coordinate[axis]) - origin_[axis]; + if (offset < 0 || offset >= extent_[axis]) + return false; + } + return true; + } + + std::size_t linear_rank(const Index& coordinate) const { + if (!contains(coordinate)) + throw std::out_of_range("RankSpace coordinate is outside the process space"); + std::size_t rank = 0; + std::size_t stride = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t offset = + static_cast(static_cast(coordinate[axis]) - origin_[axis]); + rank += offset * stride; + stride *= static_cast(extent_[axis]); + } + return rank; + } + + Index coordinate(std::size_t rank) const { + if (rank >= size_) + throw std::out_of_range("RankSpace linear rank is outside the process space"); + Index result{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t axis_extent = static_cast(extent_[axis]); + const std::size_t offset = rank % axis_extent; + rank /= axis_extent; + result[axis] = static_cast(static_cast(origin_[axis]) + offset); + } + return result; + } + + bool operator==(const RankSpace&) const = default; + + private: + std::size_t checked_size_() const { + bool has_empty_axis = false; + for (int axis = 0; axis < Dim; ++axis) { + if (extent_[axis] < 0) + throw std::invalid_argument("RankSpace extents must be non-negative"); + if (extent_[axis] == 0) { + has_empty_axis = true; + continue; + } + const std::int64_t available = + static_cast(std::numeric_limits::max()) - origin_[axis]; + if (extent_[axis] - 1 > available) + throw std::overflow_error("RankSpace coordinate extent exceeds signed indices"); + } + if (has_empty_axis) + return 0; + + std::size_t result = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::uint64_t axis_extent = static_cast(extent_[axis]); + if (axis_extent > std::numeric_limits::max() || + result > std::numeric_limits::max() / axis_extent) + throw std::overflow_error("RankSpace size exceeds size_t"); + result *= static_cast(axis_extent); + } + return result; + } + + Index origin_{}; + Extent extent_{}; + std::size_t size_ = 0; +}; + +} // namespace pops::mesh diff --git a/include/pops/mesh/nd_proof/box_array.hpp b/include/pops/mesh/nd_proof/box_array.hpp new file mode 100644 index 000000000..c4823e064 --- /dev/null +++ b/include/pops/mesh/nd_proof/box_array.hpp @@ -0,0 +1,222 @@ +/// @file +/// @brief Private ordered ND box-layout proof with portable exact cell counts. +/// +/// Non-installed proof scaffolding. It is promoted or deleted in the one-shot ND cutover. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +/// Explicit finite proof budget for exact layout validation. +struct BoxArrayValidationBudget { + std::size_t boxes; + std::size_t overlap_pairs; +}; + +/// Unsigned four-limb count. It represents the exact 2^96 cell count of a full 3D signed-index +/// box without compiler-specific wide integers. +class ExactCellCount { + public: + constexpr ExactCellCount() = default; + + constexpr bool operator==(const ExactCellCount&) const = default; + + static ExactCellCount from_uint64(std::uint64_t value) { + ExactCellCount result; + result.limbs_[0] = static_cast(value); + result.limbs_[1] = static_cast(value >> 32); + return result; + } + + static ExactCellCount power_of_two(unsigned int bit) { + if (bit >= 128) + throw std::overflow_error("nd_proof::ExactCellCount bit is outside four limbs"); + ExactCellCount result; + result.limbs_[bit / 32] = std::uint32_t{1} << (bit % 32); + return result; + } + + bool add(const ExactCellCount& other) { + std::uint64_t carry = 0; + for (std::size_t limb = 0; limb < limbs_.size(); ++limb) { + const std::uint64_t sum = + static_cast(limbs_[limb]) + other.limbs_[limb] + carry; + limbs_[limb] = static_cast(sum); + carry = sum >> 32; + } + return carry == 0; + } + + template + static ExactCellCount from_box(const Box& box) { + static_assert(Dim >= 1 && Dim <= 3, "nd_proof only supports dimensions 1, 2, and 3"); + ExactCellCount result = from_uint64(1); + if (box.empty()) + return ExactCellCount{}; + for (int axis = 0; axis < Dim; ++axis) + result.multiply(static_cast(box.length(axis))); + return result; + } + + private: + void multiply(std::uint64_t factor) { + ExactCellCount result; + const std::uint32_t low = static_cast(factor); + const std::uint32_t high = static_cast(factor >> 32); + for (std::size_t limb = 0; limb < limbs_.size(); ++limb) { + if (low != 0) + result.add_product(limb, limbs_[limb], low); + if (high != 0) + result.add_product(limb + 1, limbs_[limb], high); + } + *this = result; + } + + void add_product(std::size_t offset, std::uint32_t left, std::uint32_t right) { + const std::uint64_t product = static_cast(left) * right; + add_word(offset, static_cast(product)); + add_word(offset + 1, static_cast(product >> 32)); + } + + void add_word(std::size_t offset, std::uint32_t word) { + while (word != 0) { + if (offset >= limbs_.size()) + throw std::overflow_error("nd_proof::ExactCellCount exceeds four limbs"); + const std::uint64_t sum = static_cast(limbs_[offset]) + word; + limbs_[offset] = static_cast(sum); + word = static_cast(sum >> 32); + ++offset; + } + } + + std::array limbs_{}; +}; + +template +class BoxArray { + static_assert(Dim >= 1 && Dim <= 3, "nd_proof::BoxArray only supports dimensions 1, 2, and 3"); + + public: + using box_type = Box; + + BoxArray() = default; + explicit BoxArray(std::vector boxes) : boxes_(std::move(boxes)) {} + + static BoxArray from_domain(const box_type& domain, const std::array& max_grid_size) { + for (int axis = 0; axis < Dim; ++axis) + if (max_grid_size[axis] <= 0) + throw std::invalid_argument("nd_proof::BoxArray max grid sizes must be positive"); + if (domain.empty()) + return BoxArray{}; + + std::array segments{}; + std::size_t tile_count = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::uint64_t length = static_cast(domain.length(axis)); + const std::uint64_t limit = static_cast(max_grid_size[axis]); + segments[axis] = 1 + (length - 1) / limit; + if (segments[axis] > std::numeric_limits::max() / tile_count) + throw std::length_error("nd_proof::BoxArray tile count exceeds size_t"); + tile_count *= static_cast(segments[axis]); + } + if (tile_count > std::vector{}.max_size()) + throw std::length_error("nd_proof::BoxArray tile count exceeds vector capacity"); + + std::vector boxes; + boxes.reserve(tile_count); + for (std::size_t ordinal = 0; ordinal < tile_count; ++ordinal) { + box_type tile{}; + std::size_t quotient = ordinal; + for (int axis = 0; axis < Dim; ++axis) { + const std::uint64_t segment = quotient % segments[axis]; + quotient /= segments[axis]; // Axis 0 is the contiguous ordering axis. + const std::uint64_t length = static_cast(domain.length(axis)); + const std::uint64_t base = length / segments[axis]; + const std::uint64_t remainder = length % segments[axis]; + const std::uint64_t offset = segment * base + (segment < remainder ? segment : remainder); + const std::uint64_t width = base + (segment < remainder ? 1 : 0); + const std::int64_t lower = + static_cast(domain.lo[axis]) + static_cast(offset); + tile.lo[axis] = static_cast(lower); + tile.hi[axis] = static_cast(lower + static_cast(width) - 1); + } + boxes.push_back(tile); + } + return BoxArray{std::move(boxes)}; + } + + std::size_t size() const noexcept { return boxes_.size(); } + bool empty() const noexcept { return boxes_.empty(); } + const box_type& operator[](std::size_t index) const { return boxes_.at(index); } + const std::vector& boxes() const noexcept { return boxes_; } + + bool operator==(const BoxArray&) const = default; + + box_type bounding_box() const { + box_type result{}; + bool found = false; + for (const box_type& box : boxes_) { + if (box.empty()) + continue; + if (!found) { + result = box; + found = true; + continue; + } + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = result.lo[axis] < box.lo[axis] ? result.lo[axis] : box.lo[axis]; + result.hi[axis] = result.hi[axis] < box.hi[axis] ? box.hi[axis] : result.hi[axis]; + } + } + return result; + } + + ExactCellCount exact_cell_count() const { + ExactCellCount total; + for (const box_type& box : boxes_) + if (!total.add(ExactCellCount::from_box(box))) + throw std::overflow_error("nd_proof::BoxArray cell count exceeds four limbs"); + return total; + } + + bool tiles_exactly(const box_type& domain, BoxArrayValidationBudget budget) const { + if (boxes_.size() > budget.boxes) + throw std::length_error("nd_proof::BoxArray tiling box checks exceed explicit budget"); + if (domain.empty()) + return boxes_.empty(); + std::size_t overlap_pairs = 0; + if (boxes_.size() > 1) { + if (boxes_.size() - 1 > std::numeric_limits::max() / boxes_.size()) + throw std::length_error("nd_proof::BoxArray tiling overlap count overflows size_t"); + overlap_pairs = boxes_.size() * (boxes_.size() - 1) / 2; + } + if (overlap_pairs > budget.overlap_pairs) + throw std::length_error("nd_proof::BoxArray tiling overlap checks exceed explicit budget"); + + ExactCellCount total; + for (std::size_t left = 0; left < boxes_.size(); ++left) { + const box_type& box = boxes_[left]; + if (box.empty() || !domain.contains(box) || !total.add(ExactCellCount::from_box(box))) + return false; + for (std::size_t right = 0; right < left; ++right) + if (!box.intersect(boxes_[right]).empty()) + return false; + } + return total == ExactCellCount::from_box(domain); + } + + private: + std::vector boxes_; +}; + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/box_hash.hpp b/include/pops/mesh/nd_proof/box_hash.hpp new file mode 100644 index 000000000..ffab65b4e --- /dev/null +++ b/include/pops/mesh/nd_proof/box_hash.hpp @@ -0,0 +1,195 @@ +/// @file +/// @brief Private structural ND spatial hash proof for ordered box layouts. +/// +/// Non-installed proof scaffolding. It is promoted or deleted in the one-shot ND cutover. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +template +struct BinCoordinate { + static_assert(Dim >= 1 && Dim <= 3, + "nd_proof::BinCoordinate only supports dimensions 1, 2, and 3"); + + std::array axes{}; + + constexpr bool operator==(const BinCoordinate&) const = default; +}; + +template +struct BinCoordinateHash { + std::size_t operator()(const BinCoordinate& coordinate) const noexcept { + std::size_t hash = 1469598103934665603ULL; + for (int axis = 0; axis < Dim; ++axis) { + hash ^= std::hash{}(coordinate.axes[axis]); + hash *= 1099511628211ULL; + } + return hash; + } +}; + +/// Explicit proof-work budgets. Callers must select all three limits; none is silently inferred. +struct BoxHashBudget { + std::size_t build_bin_visits; + std::size_t query_bin_visits; + std::size_t candidate_references; +}; + +/// Remaining cumulative query work for a sequence of hash queries. +struct BoxHashQueryBudget { + std::size_t bin_visits; + std::size_t candidate_references; +}; + +template +class BoxHash { + static_assert(Dim >= 1 && Dim <= 3, "nd_proof::BoxHash only supports dimensions 1, 2, and 3"); + + public: + using box_type = Box; + + BoxHash(const BoxArray& boxes, const std::array& bin_extent, BoxHashBudget budget) + : bin_extent_(bin_extent), budget_(budget) { + for (int axis = 0; axis < Dim; ++axis) + if (bin_extent_[axis] <= 0) + throw std::invalid_argument("nd_proof::BoxHash bin extents must be positive"); + + std::size_t total_visits = 0; + for (std::size_t index = 0; index < boxes.size(); ++index) { + if (boxes[index].empty()) + continue; + const std::size_t visits = checked_bin_visits(boxes[index]); + if (total_visits > budget_.build_bin_visits || + visits > budget_.build_bin_visits - total_visits || total_visits > bins_.max_size() || + visits > bins_.max_size() - total_visits) + throw std::length_error("nd_proof::BoxHash bin enumeration exceeds proof capacity"); + total_visits += visits; + } + + for (std::size_t index = 0; index < boxes.size(); ++index) { + if (!boxes[index].empty()) + for_each_bin(boxes[index], + [this, index](const BinCoordinate& key) { bins_[key].push_back(index); }); + } + } + + std::vector query(const box_type& query_box, + BoxHashQueryBudget* cumulative_budget = nullptr) const { + std::vector candidates; + if (query_box.empty()) + return candidates; + const std::size_t query_visits = checked_bin_visits(query_box); + if (query_visits > budget_.query_bin_visits) + throw std::length_error("nd_proof::BoxHash query enumeration exceeds its explicit budget"); + if (cumulative_budget != nullptr) { + if (query_visits > cumulative_budget->bin_visits) + throw std::length_error("nd_proof::BoxHash cumulative query bins exceed explicit budget"); + cumulative_budget->bin_visits -= query_visits; + } + std::size_t references = 0; + for_each_bin(query_box, [this, &candidates, &references, + cumulative_budget](const BinCoordinate& key) { + const auto found = bins_.find(key); + if (found != bins_.end()) { + if (references > budget_.candidate_references || + found->second.size() > budget_.candidate_references - references || + candidates.size() > candidates.max_size() - found->second.size()) + throw std::length_error( + "nd_proof::BoxHash candidate references exceed their explicit budget"); + references += found->second.size(); + if (cumulative_budget != nullptr) { + if (found->second.size() > cumulative_budget->candidate_references) + throw std::length_error( + "nd_proof::BoxHash cumulative candidate references exceed explicit budget"); + cumulative_budget->candidate_references -= found->second.size(); + } + candidates.insert(candidates.end(), found->second.begin(), found->second.end()); + } + }); + std::sort(candidates.begin(), candidates.end()); + candidates.erase(std::unique(candidates.begin(), candidates.end()), candidates.end()); + return candidates; + } + + private: + static std::int64_t floor_div(int numerator, int denominator) { + const std::int64_t quotient = static_cast(numerator) / denominator; + const std::int64_t remainder = static_cast(numerator) % denominator; + return remainder < 0 ? quotient - 1 : quotient; + } + + std::size_t checked_bin_visits(const box_type& box) const { + std::size_t visits = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t lower = floor_div(box.lo[axis], bin_extent_[axis]); + const std::int64_t upper = floor_div(box.hi[axis], bin_extent_[axis]); + const std::uint64_t axis_visits = static_cast(upper - lower) + 1; + if (axis_visits > std::numeric_limits::max() / visits) + throw std::length_error("nd_proof::BoxHash bin enumeration exceeds proof capacity"); + visits *= static_cast(axis_visits); + } + return visits; + } + + template + void for_each_bin(const box_type& box, Callback&& callback) const { + BinCoordinate lower{}; + BinCoordinate upper{}; + for (int axis = 0; axis < Dim; ++axis) { + lower.axes[axis] = floor_div(box.lo[axis], bin_extent_[axis]); + upper.axes[axis] = floor_div(box.hi[axis], bin_extent_[axis]); + } + + BinCoordinate current = lower; + for (;;) { + callback(current); + int axis = 0; + for (; axis < Dim; ++axis) { + if (current.axes[axis] != upper.axes[axis]) { + ++current.axes[axis]; + break; + } + current.axes[axis] = lower.axes[axis]; + } + if (axis == Dim) + return; + } + } + + std::array bin_extent_; + BoxHashBudget budget_; + std::unordered_map, std::vector, BinCoordinateHash> bins_; +}; + +template +std::array suggest_bin(const BoxArray& boxes) { + static_assert(Dim >= 1 && Dim <= 3, "nd_proof::suggest_bin only supports dimensions 1, 2, and 3"); + std::array result{}; + result.fill(1); + for (const Box& box : boxes.boxes()) { + if (box.empty()) + continue; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t extent = box.length(axis); + const int bounded = extent > std::numeric_limits::max() ? std::numeric_limits::max() + : static_cast(extent); + result[axis] = result[axis] < bounded ? bounded : result[axis]; + } + } + return result; +} + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/distribution.hpp b/include/pops/mesh/nd_proof/distribution.hpp new file mode 100644 index 000000000..c683ea4ab --- /dev/null +++ b/include/pops/mesh/nd_proof/distribution.hpp @@ -0,0 +1,123 @@ +/// @file +/// @brief Private explicit ND layout-to-rank ownership proof. +/// +/// Non-installed proof scaffolding. It represents ownership only; it has no communication or +/// process-global semantics and is promoted or deleted in the one-shot ND cutover. + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +enum class DistributionMode { partitioned, replicated }; + +/// Ordered ownership of a BoxArray over an explicit rank-coordinate space. +/// +/// A partitioned layout stores exactly one authenticated coordinate per global box. A replicated +/// layout deliberately stores no owners: every valid rank has every global box. +template +class Distribution { + static_assert(Dim >= 1 && Dim <= 3, + "nd_proof::Distribution only supports dimensions 1, 2, and 3"); + + public: + using rank_type = Index; + + Distribution() = default; + + Distribution(const BoxArray& boxes, RankSpace rank_space, DistributionMode mode, + std::vector owners = {}) + : layout_(boxes), + rank_space_(std::move(rank_space)), + mode_(mode), + owners_(std::move(owners)) { + validate(); + } + + static Distribution partitioned(const BoxArray& boxes, RankSpace rank_space, + std::vector owners) { + return Distribution(boxes, std::move(rank_space), DistributionMode::partitioned, + std::move(owners)); + } + + static Distribution replicated(const BoxArray& boxes, RankSpace rank_space) { + return Distribution(boxes, std::move(rank_space), DistributionMode::replicated); + } + + std::size_t box_count() const noexcept { return layout_.size(); } + bool matches_layout(const BoxArray& layout) const noexcept { return layout_ == layout; } + const RankSpace& rank_space() const noexcept { return rank_space_; } + DistributionMode mode() const noexcept { return mode_; } + bool replicated() const noexcept { return mode_ == DistributionMode::replicated; } + + const rank_type& owner(std::size_t global_box) const { + require_global_box(global_box); + if (mode_ != DistributionMode::partitioned) + throw std::logic_error("nd_proof::Distribution replicated layouts have no unique owner"); + return owners_[global_box]; + } + + bool is_local(std::size_t global_box, const rank_type& rank) const { + require_global_box(global_box); + if (!rank_space_.contains(rank)) + throw std::out_of_range("nd_proof::Distribution rank coordinate is outside the rank space"); + return mode_ == DistributionMode::replicated || owners_[global_box] == rank; + } + + std::vector local_box_indices(const rank_type& rank) const { + if (!rank_space_.contains(rank)) + throw std::out_of_range("nd_proof::Distribution rank coordinate is outside the rank space"); + std::vector result; + result.reserve(mode_ == DistributionMode::replicated ? layout_.size() : owners_.size()); + for (std::size_t global_box = 0; global_box < layout_.size(); ++global_box) + if (mode_ == DistributionMode::replicated || owners_[global_box] == rank) + result.push_back(global_box); + return result; + } + + bool operator==(const Distribution& other) const noexcept { + return layout_ == other.layout_ && mode_ == other.mode_ && owners_ == other.owners_ && + rank_space_.origin() == other.rank_space_.origin() && + rank_space_.extent() == other.rank_space_.extent(); + } + + private: + void validate() const { + if (mode_ != DistributionMode::partitioned && mode_ != DistributionMode::replicated) + throw std::invalid_argument("nd_proof::Distribution mode is invalid"); + if (!layout_.empty() && rank_space_.empty()) + throw std::invalid_argument( + "nd_proof::Distribution non-empty layout requires a non-empty rank space"); + if (mode_ == DistributionMode::replicated) { + if (!owners_.empty()) + throw std::invalid_argument( + "nd_proof::Distribution replicated layouts must not store owners"); + return; + } + if (owners_.size() != layout_.size()) + throw std::invalid_argument( + "nd_proof::Distribution partitioned owner count must equal box count"); + for (const rank_type& owner_coordinate : owners_) + if (!rank_space_.contains(owner_coordinate)) + throw std::out_of_range("nd_proof::Distribution owner is outside the rank space"); + } + + void require_global_box(std::size_t global_box) const { + if (global_box >= layout_.size()) + throw std::out_of_range("nd_proof::Distribution global box index is outside the layout"); + } + + BoxArray layout_{}; + RankSpace rank_space_{Index{}, Extent{}}; + DistributionMode mode_ = DistributionMode::replicated; + std::vector owners_{}; +}; + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/local_neighbors.hpp b/include/pops/mesh/nd_proof/local_neighbors.hpp new file mode 100644 index 000000000..8262c2d20 --- /dev/null +++ b/include/pops/mesh/nd_proof/local_neighbors.hpp @@ -0,0 +1,107 @@ +/// @file +/// @brief Private exact local neighbor enumeration over ND box layouts. +/// +/// Non-installed proof scaffolding. It handles only ordinary axis translations; mapped periodic +/// identifications remain an affine-topology concern until a dedicated mapped job representation +/// exists. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +/// One local copy candidate. ``destination_region`` is in destination coordinates and source +/// coordinates are ``destination + source_from_destination_translation``. +template +struct LocalNeighborJob { + std::size_t source_box = 0; + std::size_t destination_box = 0; + Box destination_region{}; + std::array source_from_destination_translation{}; + + bool operator==(const LocalNeighborJob&) const = default; +}; + +/// Explicit caps for the image catalogue and the result job vector. Hash work is controlled by +/// the separate caller-supplied BoxHashBudget. +struct LocalNeighborWorkBudget { + std::size_t images; + std::size_t jobs; + BoxArrayValidationBudget tiling; + BoxHashQueryBudget queries; +}; + +namespace local_neighbors_detail { + +template +std::array inverse_translation(const AxisTranslationImage& image) { + std::array result{}; + for (int axis = 0; axis < Dim; ++axis) + result[axis] = periodicity_detail::checked_negate( + image.translation[axis], "nd_proof local neighbor inverse translation overflows int64_t"); + return result; +} + +} // namespace local_neighbors_detail + +/// Enumerates zero-shift seams and ordinary axis-translation images. The output order is +/// destination-box order, then enumerate_axis_translation_images order, then sorted source index. +/// The zero-shift self job is omitted; nonzero periodic self images are retained. +template +std::vector> enumerate_local_translation_neighbors( + const BoxArray& boxes, const Box& domain, const Extent& destination_ghosts, + const PeriodicTopology& topology, + const std::array(Dim)>& hash_bin_extent, + BoxHashBudget hash_budget, LocalNeighborWorkBudget work_budget) { + if (domain.empty()) + throw std::invalid_argument("nd_proof local neighbors require a non-empty domain"); + if (!boxes.tiles_exactly(domain, work_budget.tiling)) + throw std::invalid_argument("nd_proof local neighbors require an exact domain tiling"); + topology.validate(domain); + if (!topology.is_axis_translation_only()) + throw std::invalid_argument( + "nd_proof local translation neighbors do not support mapped periodic identifications"); + + const std::vector> images = enumerate_axis_translation_images( + domain, destination_ghosts, topology, AxisTranslationImageBudget{work_budget.images}); + const BoxHash hash(boxes, hash_bin_extent, hash_budget); + BoxHashQueryBudget remaining_queries = work_budget.queries; + + std::vector> jobs; + for (std::size_t destination = 0; destination < boxes.size(); ++destination) { + const Box destination_grown = + periodicity_detail::grow_box(boxes[destination], destination_ghosts); + for (const AxisTranslationImage& image : images) { + const std::array source_from_destination = + local_neighbors_detail::inverse_translation(image); + const Box source_query = periodicity_detail::translate_box( + destination_grown, source_from_destination, + "nd_proof local neighbor query translation overflow"); + const std::vector candidates = hash.query(source_query, &remaining_queries); + for (const std::size_t source : candidates) { + if (image.is_zero() && source == destination) + continue; + const Box source_image = image.apply(boxes[source]); + const Box destination_region = destination_grown.intersect(source_image); + if (destination_region.empty()) + continue; + if (jobs.size() >= work_budget.jobs || jobs.size() >= jobs.max_size()) + throw std::length_error("nd_proof local neighbor jobs exceed their explicit budget"); + jobs.push_back(LocalNeighborJob{source, destination, destination_region, + source_from_destination}); + } + } + } + return jobs; +} + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/multifab.hpp b/include/pops/mesh/nd_proof/multifab.hpp new file mode 100644 index 000000000..f1d81c249 --- /dev/null +++ b/include/pops/mesh/nd_proof/multifab.hpp @@ -0,0 +1,129 @@ +/// @file +/// @brief Private local-storage proof over explicit ND distribution metadata. +/// +/// This has no halo, copy schedule, staging, or communication semantics. + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +/// Local Fab collection selected by explicit coordinate ownership. +template +class MultiFab { + static_assert(Dim >= 1 && Dim <= 3, "nd_proof::MultiFab only supports dimensions 1, 2, and 3"); + + public: + using fab_type = Fab; + using rank_type = Index; + + MultiFab() = default; + + MultiFab(const BoxArray& layout, const Distribution& distribution, + const rank_type& local_rank, int ncomp, Extent ghosts) + : layout_(layout), + distribution_(distribution), + local_rank_(local_rank), + ncomp_(ncomp), + ghosts_(ghosts) { + validate_metadata(); + local_global_indices_ = distribution_.local_box_indices(local_rank_); + + std::vector allocated; + allocated.reserve(local_global_indices_.size()); + for (const std::size_t global_box : local_global_indices_) + allocated.emplace_back(layout_[global_box], ncomp_, ghosts_); + fabs_ = std::move(allocated); + } + + MultiFab(const MultiFab&) = default; + MultiFab& operator=(const MultiFab&) = default; + + MultiFab(MultiFab&& other) noexcept { move_from(std::move(other)); } + MultiFab& operator=(MultiFab&& other) noexcept { + if (this != &other) { + reset_moved_from(); + move_from(std::move(other)); + } + return *this; + } + + const BoxArray& layout() const noexcept { return layout_; } + const Distribution& distribution() const noexcept { return distribution_; } + const rank_type& local_rank() const noexcept { return local_rank_; } + int ncomp() const noexcept { return ncomp_; } + const Extent& ghosts() const noexcept { return ghosts_; } + const std::vector& local_global_indices() const noexcept { + return local_global_indices_; + } + std::size_t local_size() const noexcept { return fabs_.size(); } + + bool contains_local(std::size_t global_box) const noexcept { + for (const std::size_t local_global : local_global_indices_) + if (local_global == global_box) + return true; + return false; + } + + fab_type& fab(std::size_t global_box) { return fabs_.at(local_offset(global_box)); } + const fab_type& fab(std::size_t global_box) const { return fabs_.at(local_offset(global_box)); } + + private: + void validate_metadata() const { + if (distribution_.box_count() != layout_.size() || !distribution_.matches_layout(layout_)) + throw std::invalid_argument( + "nd_proof::MultiFab distribution layout does not structurally match layout"); + if (!distribution_.rank_space().contains(local_rank_)) + throw std::out_of_range("nd_proof::MultiFab local rank is outside the rank space"); + if (ncomp_ < 1) + throw std::invalid_argument("nd_proof::MultiFab ncomp must be positive"); + for (int axis = 0; axis < Dim; ++axis) + if (ghosts_[axis] < 0) + throw std::invalid_argument("nd_proof::MultiFab ghost extents must be non-negative"); + } + + std::size_t local_offset(std::size_t global_box) const { + for (std::size_t local = 0; local < local_global_indices_.size(); ++local) + if (local_global_indices_[local] == global_box) + return local; + throw std::out_of_range("nd_proof::MultiFab global box is not local to this rank"); + } + + void reset_moved_from() noexcept { + layout_ = BoxArray{}; + distribution_ = Distribution{}; + local_rank_ = rank_type{}; + ncomp_ = 0; + ghosts_ = Extent{}; + local_global_indices_.clear(); + fabs_.clear(); + } + + void move_from(MultiFab&& other) noexcept { + layout_ = std::move(other.layout_); + distribution_ = std::move(other.distribution_); + local_rank_ = other.local_rank_; + ncomp_ = other.ncomp_; + ghosts_ = other.ghosts_; + local_global_indices_ = std::move(other.local_global_indices_); + fabs_ = std::move(other.fabs_); + other.reset_moved_from(); + } + + BoxArray layout_{}; + Distribution distribution_{}; + rank_type local_rank_{}; + int ncomp_ = 0; + Extent ghosts_{}; + std::vector local_global_indices_{}; + std::vector fabs_{}; +}; + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/periodicity.hpp b/include/pops/mesh/nd_proof/periodicity.hpp new file mode 100644 index 000000000..116103b63 --- /dev/null +++ b/include/pops/mesh/nd_proof/periodicity.hpp @@ -0,0 +1,500 @@ +/// @file +/// @brief Private compile-time-ranked periodic topology and axis-translation image proof. +/// +/// Non-installed proof scaffolding. Mapped identifications are topology/affine values only; +/// axis-translation images deliberately reject them rather than approximating them as wraps. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +namespace periodicity_detail { + +inline int checked_index(std::int64_t value, const char* operation) { + if (value < std::numeric_limits::min() || value > std::numeric_limits::max()) + throw std::overflow_error(operation); + return static_cast(value); +} + +inline std::int64_t checked_add(std::int64_t left, std::int64_t right, const char* operation) { + if ((right > 0 && left > std::numeric_limits::max() - right) || + (right < 0 && left < std::numeric_limits::min() - right)) + throw std::overflow_error(operation); + return left + right; +} + +inline std::int64_t checked_negate(std::int64_t value, const char* operation) { + if (value == std::numeric_limits::min()) + throw std::overflow_error(operation); + return -value; +} + +inline std::int64_t checked_multiple(std::int64_t multiple, std::int64_t extent, + const char* operation) { + if (extent <= 0) + throw std::invalid_argument("nd_proof periodic translation requires a positive extent"); + if ((multiple > 0 && multiple > std::numeric_limits::max() / extent) || + (multiple < 0 && multiple < std::numeric_limits::min() / extent)) + throw std::overflow_error(operation); + return multiple * extent; +} + +template +Box translate_box(const Box& source, const std::array& translation, + const char* operation) { + if (source.empty()) + return source; + Box result; + for (int axis = 0; axis < Dim; ++axis) { + result.lo[axis] = + checked_index(checked_add(source.lo[axis], translation[axis], operation), operation); + result.hi[axis] = + checked_index(checked_add(source.hi[axis], translation[axis], operation), operation); + } + return result; +} + +template +Box grow_box(const Box& source, const Extent& ghosts) { + if (source.empty()) + return source; + Box result; + for (int axis = 0; axis < Dim; ++axis) { + if (ghosts[axis] < 0) + throw std::invalid_argument("nd_proof destination ghost depths must be non-negative"); + result.lo[axis] = checked_index( + checked_add(source.lo[axis], checked_negate(ghosts[axis], "nd_proof ghost lower overflow"), + "nd_proof ghost lower overflow"), + "nd_proof ghost lower bound exceeds native index range"); + result.hi[axis] = + checked_index(checked_add(source.hi[axis], ghosts[axis], "nd_proof ghost upper overflow"), + "nd_proof ghost upper bound exceeds native index range"); + } + return result; +} + +} // namespace periodicity_detail + +using Side = ::pops::BoundarySide; + +using ::pops::Face; +using ::pops::face_less; + +/// A signed source-axis -> target-axis permutation. +template +class SignedPermutation { + static_assert(Dim >= 1 && Dim <= 3, + "nd_proof::SignedPermutation only supports dimensions 1, 2, and 3"); + + public: + SignedPermutation() { + for (int axis = 0; axis < Dim; ++axis) { + target_axis_[axis] = axis; + sign_[axis] = 1; + } + } + + SignedPermutation(std::array target_axis, std::array sign) + : target_axis_(target_axis), sign_(sign) { + validate(); + } + + const std::array& target_axes() const noexcept { return target_axis_; } + const std::array& signs() const noexcept { return sign_; } + + bool is_identity() const noexcept { + for (int axis = 0; axis < Dim; ++axis) + if (target_axis_[axis] != axis || sign_[axis] != 1) + return false; + return true; + } + + SignedPermutation inverse() const { + std::array inverse_axis{}; + std::array inverse_sign{}; + for (int source = 0; source < Dim; ++source) { + const int target = target_axis_[source]; + inverse_axis[target] = source; + inverse_sign[target] = sign_[source]; + } + return SignedPermutation{inverse_axis, inverse_sign}; + } + + /// Returns @p after composed after this map: ``after(this(source))``. + SignedPermutation compose(const SignedPermutation& after) const { + std::array composed_axis{}; + std::array composed_sign{}; + for (int source = 0; source < Dim; ++source) { + const int intermediate = target_axis_[source]; + composed_axis[source] = after.target_axis_[intermediate]; + composed_sign[source] = sign_[source] * after.sign_[intermediate]; + } + return SignedPermutation{composed_axis, composed_sign}; + } + + bool operator==(const SignedPermutation&) const = default; + + private: + void validate() const { + std::array seen{}; + for (int source = 0; source < Dim; ++source) { + const int target = target_axis_[source]; + if (target < 0 || target >= Dim || seen[target]) + throw std::invalid_argument("nd_proof::SignedPermutation must be a bijection"); + if (sign_[source] != -1 && sign_[source] != 1) + throw std::invalid_argument("nd_proof::SignedPermutation signs must be -1 or +1"); + seen[target] = true; + } + } + + std::array target_axis_{}; + std::array sign_{}; +}; + +/// Checked affine source-index -> target-index map. Offset components are indexed by target axis. +template +class AffineIndexTransform { + public: + AffineIndexTransform() = default; + AffineIndexTransform(SignedPermutation source_to_target, + std::array target_offset) + : source_to_target_(std::move(source_to_target)), target_offset_(target_offset) {} + + const SignedPermutation& signed_permutation() const noexcept { return source_to_target_; } + const std::array& target_offsets() const noexcept { return target_offset_; } + + Index apply(const Index& source) const { + Index result; + for (int source_axis = 0; source_axis < Dim; ++source_axis) { + const int target_axis = source_to_target_.target_axes()[source_axis]; + const std::int64_t signed_source = + static_cast(source_to_target_.signs()[source_axis]) * source[source_axis]; + result[target_axis] = periodicity_detail::checked_index( + periodicity_detail::checked_add(signed_source, target_offset_[target_axis], + "nd_proof affine index transform overflow"), + "nd_proof affine index transform exceeds native index range"); + } + return result; + } + + Box apply(const Box& source) const { + if (source.empty()) + return source; + Box result; + for (int source_axis = 0; source_axis < Dim; ++source_axis) { + const int target_axis = source_to_target_.target_axes()[source_axis]; + const std::int64_t first = periodicity_detail::checked_add( + static_cast(source_to_target_.signs()[source_axis]) * + source.lo[source_axis], + target_offset_[target_axis], "nd_proof affine box transform overflow"); + const std::int64_t second = periodicity_detail::checked_add( + static_cast(source_to_target_.signs()[source_axis]) * + source.hi[source_axis], + target_offset_[target_axis], "nd_proof affine box transform overflow"); + result.lo[target_axis] = periodicity_detail::checked_index( + std::min(first, second), "nd_proof affine box transform exceeds native index range"); + result.hi[target_axis] = periodicity_detail::checked_index( + std::max(first, second), "nd_proof affine box transform exceeds native index range"); + } + return result; + } + + AffineIndexTransform inverse() const { + const SignedPermutation inverse_permutation = source_to_target_.inverse(); + std::array inverse_offset{}; + for (int source_axis = 0; source_axis < Dim; ++source_axis) { + const int target_axis = source_to_target_.target_axes()[source_axis]; + inverse_offset[source_axis] = + source_to_target_.signs()[source_axis] == 1 + ? periodicity_detail::checked_negate(target_offset_[target_axis], + "nd_proof affine inverse overflow") + : target_offset_[target_axis]; + } + return AffineIndexTransform{inverse_permutation, inverse_offset}; + } + + bool operator==(const AffineIndexTransform&) const = default; + + private: + SignedPermutation source_to_target_; + std::array target_offset_{}; +}; + +/// One signed/permuted identification from a source face interior to a target face exterior. +template +class PeriodicIdentification { + public: + PeriodicIdentification(Face source, Face target, + SignedPermutation source_to_target = {}) + : source_(source), target_(target), source_to_target_(std::move(source_to_target)) { + validate_structure(); + } + + const Face& source() const noexcept { return source_; } + const Face& target() const noexcept { return target_; } + const SignedPermutation& signed_permutation() const noexcept { return source_to_target_; } + + bool is_axis_translation() const noexcept { + return source_.axis == target_.axis && source_to_target_.is_identity(); + } + + PeriodicIdentification canonical() const { + if (!face_less(target_, source_)) + return *this; + return PeriodicIdentification{target_, source_, source_to_target_.inverse()}; + } + + void validate(const Box& domain) const { + validate_structure(); + if (domain.empty()) + throw std::invalid_argument("nd_proof periodic topology requires a non-empty domain"); + for (int source_axis = 0; source_axis < Dim; ++source_axis) { + if (source_axis == source_.axis) + continue; + const int target_axis = source_to_target_.target_axes()[source_axis]; + if (domain.length(source_axis) != domain.length(target_axis)) + throw std::invalid_argument( + "nd_proof mapped periodic tangential extents must agree under the signed permutation"); + } + } + + AffineIndexTransform source_interior_to_target_exterior(const Box& domain) const { + validate(domain); + std::array target_offset{}; + for (int source_axis = 0; source_axis < Dim; ++source_axis) { + const int target_axis = source_to_target_.target_axes()[source_axis]; + const std::int64_t sign = source_to_target_.signs()[source_axis]; + if (source_axis == source_.axis) { + const std::int64_t source_adjacent = + source_.side == Side::lower ? domain.lo[source_axis] : domain.hi[source_axis]; + const std::int64_t target_first_exterior = + target_.side == Side::lower ? static_cast(domain.lo[target_axis]) - 1 + : static_cast(domain.hi[target_axis]) + 1; + target_offset[target_axis] = + periodicity_detail::checked_add(target_first_exterior, -sign * source_adjacent, + "nd_proof periodic normal affine offset overflow"); + } else if (sign == 1) { + target_offset[target_axis] = static_cast(domain.lo[target_axis]) - + static_cast(domain.lo[source_axis]); + } else { + target_offset[target_axis] = + periodicity_detail::checked_add(domain.hi[target_axis], domain.lo[source_axis], + "nd_proof periodic tangential affine offset overflow"); + } + } + return AffineIndexTransform{source_to_target_, target_offset}; + } + + AffineIndexTransform target_exterior_to_source_interior(const Box& domain) const { + return source_interior_to_target_exterior(domain).inverse(); + } + + bool operator==(const PeriodicIdentification&) const = default; + + private: + void validate_structure() const { + if (source_ == target_) + throw std::invalid_argument("nd_proof periodic identification requires distinct faces"); + if (source_to_target_.target_axes()[source_.axis] != target_.axis) + throw std::invalid_argument( + "nd_proof periodic normal axis does not map to the target normal"); + const int source_outward = source_.side == Side::lower ? -1 : 1; + const int target_outward = target_.side == Side::lower ? -1 : 1; + const int required_sign = -source_outward * target_outward; + if (source_to_target_.signs()[source_.axis] != required_sign) + throw std::invalid_argument( + "nd_proof periodic normal sign does not map source interior to target exterior"); + } + + Face source_; + Face target_; + SignedPermutation source_to_target_; +}; + +/// Canonical topology identity. It stores no domain-derived translation offsets. +template +class PeriodicTopology { + public: + PeriodicTopology() = default; + explicit PeriodicTopology(std::vector> identifications) { + for (PeriodicIdentification& identification : identifications) + identification = identification.canonical(); + std::sort( + identifications.begin(), identifications.end(), + [](const PeriodicIdentification& left, const PeriodicIdentification& right) { + if (left.source().ordinal() != right.source().ordinal()) + return left.source().ordinal() < right.source().ordinal(); + if (left.target().ordinal() != right.target().ordinal()) + return left.target().ordinal() < right.target().ordinal(); + if (left.signed_permutation().target_axes() != right.signed_permutation().target_axes()) + return left.signed_permutation().target_axes() < + right.signed_permutation().target_axes(); + return left.signed_permutation().signs() < right.signed_permutation().signs(); + }); + + std::array assigned{}; + for (const PeriodicIdentification& identification : identifications) { + const int source = identification.source().ordinal(); + const int target = identification.target().ordinal(); + if (assigned[source] || assigned[target]) + throw std::invalid_argument("nd_proof periodic topology assigns one face more than once"); + assigned[source] = true; + assigned[target] = true; + } + identifications_ = std::move(identifications); + } + + static PeriodicTopology axis_translations(const std::array& periodic_axes) { + std::vector> identifications; + for (int axis = 0; axis < Dim; ++axis) + if (periodic_axes[axis]) + identifications.emplace_back(Face{axis, Side::lower}, Face{axis, Side::upper}); + return PeriodicTopology{std::move(identifications)}; + } + + const std::vector>& identifications() const noexcept { + return identifications_; + } + + bool is_axis_translation_only() const noexcept { + for (const PeriodicIdentification& identification : identifications_) + if (!identification.is_axis_translation()) + return false; + return true; + } + + bool axis_is_translation_periodic(int axis) const { + if (axis < 0 || axis >= Dim) + throw std::invalid_argument("nd_proof periodic axis is outside the compile-time rank"); + for (const PeriodicIdentification& identification : identifications_) + if (identification.is_axis_translation() && identification.source().axis == axis) + return true; + return false; + } + + void validate(const Box& domain) const { + for (const PeriodicIdentification& identification : identifications_) + identification.validate(domain); + } + + bool operator==(const PeriodicTopology&) const = default; + + private: + std::vector> identifications_; +}; + +/// Explicit cap for the finite catalogue of ordinary axis-translation images. +struct AxisTranslationImageBudget { + std::size_t images; +}; + +template +struct AxisTranslationImage { + std::array multiples{}; + std::array translation{}; + + bool is_zero() const noexcept { + for (const std::int64_t value : multiples) + if (value != 0) + return false; + return true; + } + + Index apply(const Index& source) const { + Index result; + for (int axis = 0; axis < Dim; ++axis) + result[axis] = periodicity_detail::checked_index( + periodicity_detail::checked_add(source[axis], translation[axis], + "nd_proof periodic index translation overflow"), + "nd_proof periodic index translation exceeds native index range"); + return result; + } + + Box apply(const Box& source) const { + return periodicity_detail::translate_box(source, translation, + "nd_proof periodic box translation overflow"); + } + + bool operator==(const AxisTranslationImage&) const = default; +}; + +/// Enumerates ordinary axis-translation images only. For each axis the multiplier order is +/// ``0, -1, +1, -2, +2, ...``; Cartesian combinations use axis 0 as the fastest coordinate. +template +std::vector> enumerate_axis_translation_images( + const Box& domain, const Extent& ghosts, const PeriodicTopology& topology, + AxisTranslationImageBudget budget) { + if (domain.empty()) + throw std::invalid_argument("nd_proof axis-translation images require a non-empty domain"); + topology.validate(domain); + if (!topology.is_axis_translation_only()) + throw std::invalid_argument( + "nd_proof axis-translation images do not support mapped periodic identifications"); + + std::array, Dim> axis_multiples; + std::size_t image_count = 1; + for (int axis = 0; axis < Dim; ++axis) { + if (ghosts[axis] < 0) + throw std::invalid_argument("nd_proof periodic ghost depths must be non-negative"); + std::int64_t maximum_multiple = 0; + const std::int64_t extent = domain.length(axis); + if (topology.axis_is_translation_periodic(axis)) { + maximum_multiple = ghosts[axis] / extent + (ghosts[axis] % extent == 0 ? 0 : 1); + (void)periodicity_detail::checked_multiple( + maximum_multiple, extent, "nd_proof periodic image translation overflows int64_t"); + } + if (maximum_multiple > + static_cast((std::numeric_limits::max() - 1) / 2)) + throw std::length_error("nd_proof periodic image count exceeds size_t"); + const std::size_t axis_count = 1 + 2 * static_cast(maximum_multiple); + if (axis_count > budget.images || image_count > budget.images / axis_count) + throw std::length_error("nd_proof periodic image count exceeds its explicit budget"); + image_count *= axis_count; + + std::vector& values = axis_multiples[axis]; + if (axis_count > values.max_size()) + throw std::length_error("nd_proof periodic image axis count exceeds vector capacity"); + values.reserve(axis_count); + values.push_back(0); + for (std::int64_t magnitude = 1; magnitude <= maximum_multiple;) { + values.push_back(-magnitude); + values.push_back(magnitude); + if (magnitude == maximum_multiple) + break; + ++magnitude; + } + } + + std::vector> images; + if (image_count > images.max_size()) + throw std::length_error("nd_proof periodic image count exceeds vector capacity"); + images.reserve(image_count); + for (std::size_t ordinal = 0; ordinal < image_count; ++ordinal) { + AxisTranslationImage image; + std::size_t quotient = ordinal; + for (int axis = 0; axis < Dim; ++axis) { + const std::vector& values = axis_multiples[axis]; + const std::int64_t multiple = values[quotient % values.size()]; + quotient /= values.size(); + image.multiples[axis] = multiple; + image.translation[axis] = periodicity_detail::checked_multiple( + multiple, domain.length(axis), "nd_proof periodic image translation overflows int64_t"); + } + images.push_back(image); + } + return images; +} + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/rank_space.hpp b/include/pops/mesh/nd_proof/rank_space.hpp new file mode 100644 index 000000000..f276bec4f --- /dev/null +++ b/include/pops/mesh/nd_proof/rank_space.hpp @@ -0,0 +1,103 @@ +/// @file +/// @brief Private compile-time-ranked process-coordinate layout proof. +/// +/// Non-installed proof scaffolding. It is promoted or deleted in the one-shot ND cutover. + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +/// Half-open rank-coordinate box with axis 0 contiguous linearization. +template +class RankSpace { + static_assert(Dim >= 1 && Dim <= 3, "nd_proof::RankSpace only supports dimensions 1, 2, and 3"); + + public: + RankSpace(Index origin, Extent extent) : origin_(origin), extent_(extent) { + size_ = checked_size(); + } + + constexpr const Index& origin() const noexcept { return origin_; } + constexpr const Extent& extent() const noexcept { return extent_; } + constexpr std::size_t size() const noexcept { return size_; } + constexpr bool empty() const noexcept { return size_ == 0; } + + bool contains(const Index& coordinate) const noexcept { + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t offset = static_cast(coordinate[axis]) - origin_[axis]; + if (offset < 0 || offset >= extent_[axis]) + return false; + } + return !empty(); + } + + std::size_t linear_rank(const Index& coordinate) const { + if (!contains(coordinate)) + throw std::out_of_range("nd_proof::RankSpace coordinate is outside the rank space"); + std::size_t rank = 0; + std::size_t stride = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t offset = + static_cast(static_cast(coordinate[axis]) - origin_[axis]); + rank += offset * stride; + stride *= static_cast(extent_[axis]); + } + return rank; + } + + Index coord_from_linear(std::size_t rank) const { + if (rank >= size_) + throw std::out_of_range("nd_proof::RankSpace rank is outside the rank space"); + Index coordinate{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t axis_extent = static_cast(extent_[axis]); + const std::size_t offset = rank % axis_extent; + rank /= axis_extent; + coordinate[axis] = static_cast(static_cast(origin_[axis]) + offset); + } + return coordinate; + } + + private: + std::size_t checked_size() const { + bool has_empty_axis = false; + for (int axis = 0; axis < Dim; ++axis) { + if (extent_[axis] < 0) + throw std::invalid_argument("nd_proof::RankSpace extents must be non-negative"); + if (extent_[axis] == 0) { + has_empty_axis = true; + continue; + } + const std::int64_t available = + static_cast(std::numeric_limits::max()) - origin_[axis]; + if (extent_[axis] - 1 > available) + throw std::overflow_error("nd_proof::RankSpace coordinate extent exceeds signed indices"); + } + if (has_empty_axis) + return 0; + + std::size_t result = 1; + for (int axis = 0; axis < Dim; ++axis) { + const std::uint64_t axis_extent = static_cast(extent_[axis]); + if (axis_extent > std::numeric_limits::max() || + result > std::numeric_limits::max() / axis_extent) + throw std::overflow_error("nd_proof::RankSpace size exceeds size_t"); + result *= static_cast(axis_extent); + } + return result; + } + + Index origin_; + Extent extent_; + std::size_t size_ = 0; +}; + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/translation_exchange.hpp b/include/pops/mesh/nd_proof/translation_exchange.hpp new file mode 100644 index 000000000..b99ee0a03 --- /dev/null +++ b/include/pops/mesh/nd_proof/translation_exchange.hpp @@ -0,0 +1,554 @@ +/// @file +/// @brief Private blocking MPI lease for one exact ND translation schedule. +/// +/// The borrowed ExecutionLane and TranslationSchedule must outlive this object. This proof is +/// deliberately blocking: it has no begin/end state, pooling, mapped topology, GPUDirect, or +/// payload chunking. An unsafe communication failure seals the lease permanently. + +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +struct TranslationExchangeContext { + std::uint64_t context_generation = 0; + std::uint64_t schedule_generation = 0; + int tag = ExecutionLane::translation_message_tag; + /// Private test seam. A non-negative rank makes that rank fail before any point-to-point post. + int fail_allocation_rank = -1; + int fail_receive_post_rank = -1; + int fail_send_post_rank = -1; + /// This injects after a real wait so ordinary failure tests never leave unmatched traffic. + int fail_wait_rank = -1; + /// Fail after real unpack/replay mutation but before completion publication. + int fail_completion_rank = -1; + /// Fail-stop-only seam: a selected rank makes cleanup unprovable and therefore terminates. + int fail_drain_rank = -1; +}; + +enum class TranslationExchangeDiagnosticStage : unsigned char { + none, + receive_post, + send_post, + wait, + completion, +}; + +template +class TranslationExchange { + public: + using schedule_type = TranslationSchedule; + using multifab_type = MultiFab; + using rank_type = Index; + using device_buffer_type = typename schedule_type::buffer_type; + using pinned_buffer_type = Kokkos::View; + + TranslationExchange(const schedule_type& schedule, const ExecutionLane& lane, + TranslationExchangeContext context) + : schedule_(&schedule), + lane_(&lane), + lane_borrow_(lane.borrow_immutably()), + context_(context) { +#ifdef POPS_HAS_MPI + validate_and_prepare_collectively_(); +#else + (void)schedule; + (void)lane; + (void)context; + throw std::logic_error( + "nd_proof::TranslationExchange requires an active owning MPI ExecutionLane"); +#endif + } + + TranslationExchange(const TranslationExchange&) = delete; + TranslationExchange& operator=(const TranslationExchange&) = delete; + TranslationExchange(TranslationExchange&&) = delete; + TranslationExchange& operator=(TranslationExchange&&) = delete; + + ~TranslationExchange() noexcept { +#ifdef POPS_HAS_MPI + drain_noexcept_(); +#endif + } + + [[nodiscard]] const schedule_type& schedule() const noexcept { return *schedule_; } + [[nodiscard]] const ExecutionLane& lane() const noexcept { return *lane_; } + [[nodiscard]] const TranslationExchangeContext& context() const noexcept { return context_; } + [[nodiscard]] bool sealed() const noexcept { return sealed_; } + [[nodiscard]] TranslationExchangeDiagnosticStage diagnostic_stage() const noexcept { + return diagnostic_stage_; + } + [[nodiscard]] std::size_t peer_count() const noexcept { return peers_.size(); } + [[nodiscard]] std::size_t send_buffer_elements() const noexcept { return send_elements_; } + [[nodiscard]] std::size_t receive_buffer_elements() const noexcept { return receive_elements_; } + [[nodiscard]] std::size_t live_request_count() const noexcept { +#ifdef POPS_HAS_MPI + std::size_t live = 0; + for (const MPI_Request request : receive_requests_) + if (request != MPI_REQUEST_NULL) + ++live; + for (const MPI_Request request : send_requests_) + if (request != MPI_REQUEST_NULL) + ++live; + return live; +#else + return 0; +#endif + } + + void execute(multifab_type& fields, const ExecutionLane& lane) { +#ifdef POPS_HAS_MPI + require_execute_lane_collectively_(lane); + if (sealed_) + throw std::runtime_error("nd_proof::TranslationExchange is sealed after an unsafe failure"); + if (live_request_count() != 0) + throw std::logic_error("nd_proof::TranslationExchange has live MPI requests before execute"); + + long prepost_failure = 0; + try { + schedule_->validate_fields(fields); + for (PeerStorage& peer : peers_) { + if (peer.send_elements != 0) { + schedule_->pack(fields, peer.coordinate, peer.device_send); + Kokkos::deep_copy(peer.host_send, peer.device_send); + } + } + Kokkos::fence(); + } catch (...) { + prepost_failure = 1; + } + if (all_reduce_max(prepost_failure, lane_->communicator()) != 0) + throw std::runtime_error( + "nd_proof::TranslationExchange pre-post validation, packing, or staging failed " + "collectively"); + + int receive_post_code = MPI_SUCCESS; + for (PeerStorage& peer : peers_) { + if (peer.receive_elements != 0 && receive_post_code == MPI_SUCCESS) { + if (context_.fail_receive_post_rank == lane_->rank()) { + receive_post_code = MPI_ERR_OTHER; + break; + } + MPI_Request request = MPI_REQUEST_NULL; + receive_post_code = + MPI_Irecv(peer.host_receive.data(), static_cast(peer.receive_elements), MPI_DOUBLE, + peer.mpi_rank, context_.tag, lane_->native_handle(), &request); + if (receive_post_code == MPI_SUCCESS) + receive_requests_.push_back(request); + } + } + if (!post_phase_gate_(receive_post_code == MPI_SUCCESS ? 0L : 1L, + TranslationExchangeDiagnosticStage::receive_post)) { + seal_(TranslationExchangeDiagnosticStage::receive_post); + require_proven_drain_(drain_receives_()); + throw std::runtime_error("nd_proof::TranslationExchange receive posting failed collectively"); + } + + int send_post_code = MPI_SUCCESS; + for (PeerStorage& peer : peers_) { + if (peer.send_elements != 0 && send_post_code == MPI_SUCCESS) { + if (context_.fail_send_post_rank == lane_->rank()) { + send_post_code = MPI_ERR_OTHER; + break; + } + MPI_Request request = MPI_REQUEST_NULL; + send_post_code = + MPI_Isend(peer.host_send.data(), static_cast(peer.send_elements), MPI_DOUBLE, + peer.mpi_rank, context_.tag, lane_->native_handle(), &request); + if (send_post_code == MPI_SUCCESS) + send_requests_.push_back(request); + } + } + if (!post_phase_gate_(send_post_code == MPI_SUCCESS ? 0L : 1L, + TranslationExchangeDiagnosticStage::send_post)) { + seal_(TranslationExchangeDiagnosticStage::send_post); + require_proven_drain_(drain_after_send_failure_()); + throw std::runtime_error("nd_proof::TranslationExchange send posting failed collectively"); + } + + int wait_code = wait_all_(send_requests_); + if (wait_code == MPI_SUCCESS) + wait_code = wait_all_(receive_requests_); + if (wait_code == MPI_SUCCESS && context_.fail_wait_rank == lane_->rank()) + wait_code = MPI_ERR_OTHER; + if (!post_phase_gate_(wait_code == MPI_SUCCESS ? 0L : 1L, + TranslationExchangeDiagnosticStage::wait)) { + seal_(TranslationExchangeDiagnosticStage::wait); + require_proven_drain_(drain_after_wait_failure_()); + throw std::runtime_error("nd_proof::TranslationExchange MPI_Waitall failed collectively"); + } + receive_requests_.clear(); + send_requests_.clear(); + + long completion_failure = 0; + try { + for (PeerStorage& peer : peers_) + if (peer.receive_elements != 0) { + Kokkos::deep_copy(peer.device_receive, peer.host_receive); + Kokkos::fence(); + schedule_->unpack(fields, peer.coordinate, peer.device_receive); + } + schedule_->replay(fields); + Kokkos::fence(); + if (context_.fail_completion_rank == lane_->rank()) + throw std::runtime_error("nd_proof::TranslationExchange injected completion failure"); + } catch (...) { + completion_failure = 1; + } + if (!completion_phase_gate_(completion_failure)) { + seal_(TranslationExchangeDiagnosticStage::completion); + std::terminate(); + } +#else + (void)fields; + (void)lane; + throw std::logic_error( + "nd_proof::TranslationExchange requires an active owning MPI ExecutionLane"); +#endif + } + + private: + struct PeerStorage { + rank_type coordinate{}; + int mpi_rank = 0; + std::size_t send_elements = 0; + std::size_t receive_elements = 0; + device_buffer_type device_send{}; + device_buffer_type device_receive{}; + pinned_buffer_type host_send{}; + pinned_buffer_type host_receive{}; + }; + + static void append_u64_(std::string& bytes, std::uint64_t value) { + for (int shift = 56; shift >= 0; shift -= 8) + bytes.push_back(static_cast((value >> shift) & 0xffu)); + } + + static void append_i64_(std::string& bytes, std::int64_t value) { + append_u64_(bytes, static_cast(value)); + } + + static void append_string_(std::string& bytes, std::string_view value) { + append_u64_(bytes, value.size()); + bytes.append(value.data(), value.size()); + } + + static void append_index_(std::string& bytes, const Index& index) { + for (int axis = 0; axis < Dim; ++axis) + append_i64_(bytes, index.values[axis]); + } + + static void append_extent_(std::string& bytes, const Extent& extent) { + for (int axis = 0; axis < Dim; ++axis) + append_i64_(bytes, extent.values[axis]); + } + + static void append_box_(std::string& bytes, const Box& box) { + append_index_(bytes, box.lo); + append_index_(bytes, box.hi); + } + + std::string canonical_contract_() const { + std::string bytes; + append_string_(bytes, "nd-translation-v1"); + append_i64_(bytes, Dim); + append_string_(bytes, lane_->identity()); + append_u64_(bytes, context_.context_generation); + append_u64_(bytes, context_.schedule_generation); + append_i64_(bytes, context_.tag); + const BoxArray& layout = schedule_->layout(); + append_u64_(bytes, layout.size()); + for (const Box& box : layout.boxes()) + append_box_(bytes, box); + const Distribution& distribution = schedule_->distribution(); + append_i64_(bytes, static_cast(distribution.mode())); + append_index_(bytes, distribution.rank_space().origin()); + append_extent_(bytes, distribution.rank_space().extent()); + append_u64_(bytes, distribution.box_count()); + if (!distribution.replicated()) + for (std::size_t box = 0; box < distribution.box_count(); ++box) + append_index_(bytes, distribution.owner(box)); + append_box_(bytes, schedule_->domain()); + const PeriodicTopology& topology = schedule_->topology(); + append_u64_(bytes, topology.identifications().size()); + for (const PeriodicIdentification& identification : topology.identifications()) { + append_i64_(bytes, identification.source().axis); + append_i64_(bytes, static_cast(identification.source().side)); + append_i64_(bytes, identification.target().axis); + append_i64_(bytes, static_cast(identification.target().side)); + for (int axis = 0; axis < Dim; ++axis) { + append_i64_(bytes, identification.signed_permutation().target_axes()[axis]); + append_i64_(bytes, identification.signed_permutation().signs()[axis]); + } + } + append_extent_(bytes, schedule_->ghosts()); + append_i64_(bytes, schedule_->ncomp()); + append_i64_(bytes, schedule_->first_component()); + append_i64_(bytes, schedule_->component_count()); + append_u64_(bytes, schedule_->canonical_global_jobs().size()); + for (const auto& job : schedule_->canonical_global_jobs()) { + append_u64_(bytes, job.ordinal); + append_u64_(bytes, job.source_box); + append_u64_(bytes, job.destination_box); + append_box_(bytes, job.destination_region); + for (int axis = 0; axis < Dim; ++axis) + append_i64_(bytes, job.source_from_destination[axis]); + append_u64_(bytes, job.elements); + } + return bytes; + } + +#ifdef POPS_HAS_MPI + void validate_and_prepare_collectively_() { + long invalid = 0; + try { + invalid = lane_ == nullptr || !lane_->active() || !lane_->owns_communicator() || + lane_->identity().empty() || context_.context_generation == 0 || + context_.schedule_generation == 0 || + context_.tag != ExecutionLane::translation_message_tag || + context_.tag != 2 || schedule_ == nullptr + ? 1L + : 0L; + if (invalid == 0) { + const RankSpace& ranks = schedule_->distribution().rank_space(); + if (ranks.size() > static_cast(std::numeric_limits::max()) || + lane_->size() != static_cast(ranks.size()) || + lane_->rank() != static_cast(ranks.linear_rank(schedule_->local_rank()))) + invalid = 1; + int* tag_upper_bound = nullptr; + int flag = 0; + if (MPI_Comm_get_attr(lane_->native_handle(), MPI_TAG_UB, &tag_upper_bound, &flag) != + MPI_SUCCESS || + flag == 0 || tag_upper_bound == nullptr || context_.tag > *tag_upper_bound) + invalid = 1; + } + } catch (...) { + invalid = 1; + } + if (all_reduce_max(invalid, lane_->communicator()) != 0) + throw std::invalid_argument( + "nd_proof::TranslationExchange lane or schedule binding is invalid"); + + std::string contract; + long serialization_failure = 0; + try { + contract = canonical_contract_(); + } catch (...) { + serialization_failure = 1; + } + if (all_reduce_max(serialization_failure, lane_->communicator()) != 0) + throw std::runtime_error( + "nd_proof::TranslationExchange canonical contract serialization failed collectively"); + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{std::string_view("nd-translation-v1"), std::string_view(contract)}}, + lane_->communicator())) + throw std::invalid_argument( + "nd_proof::TranslationExchange canonical schedule contract differs between ranks"); + + long allocation_failure = 0; + try { + initialize_peers_(); + if (context_.fail_allocation_rank >= 0 && lane_->rank() == context_.fail_allocation_rank) + throw std::bad_alloc(); + allocate_peer_storage_(); + } catch (...) { + allocation_failure = 1; + } + if (all_reduce_max(allocation_failure, lane_->communicator()) != 0) + throw std::runtime_error( + "nd_proof::TranslationExchange reusable buffer preparation failed collectively"); + } + + void initialize_peers_() { + const RankSpace& ranks = schedule_->distribution().rank_space(); + const auto add = [this, &ranks](const typename schedule_type::PeerPlan& plan, bool send) { + const std::size_t linear = ranks.linear_rank(plan.peer); + if (linear > static_cast(std::numeric_limits::max()) || + plan.elements > static_cast(std::numeric_limits::max())) + throw std::overflow_error( + "nd_proof::TranslationExchange peer payload exceeds MPI int range"); + auto found = std::find_if(peers_.begin(), peers_.end(), [linear](const PeerStorage& peer) { + return peer.mpi_rank == static_cast(linear); + }); + if (found == peers_.end()) { + peers_.push_back(PeerStorage{plan.peer, static_cast(linear)}); + found = std::prev(peers_.end()); + } + if (send) + found->send_elements = plan.elements; + else + found->receive_elements = plan.elements; + }; + peers_.clear(); + const std::size_t send_plans = schedule_->send_plan_count(); + const std::size_t receive_plans = schedule_->receive_plan_count(); + if (send_plans > std::numeric_limits::max() - receive_plans) + throw std::overflow_error("nd_proof::TranslationExchange request count overflows size_t"); + const std::size_t request_count = send_plans + receive_plans; + if (request_count > static_cast(std::numeric_limits::max())) + throw std::overflow_error( + "nd_proof::TranslationExchange request count exceeds MPI int range"); + if (request_count > peers_.max_size() || receive_plans > receive_requests_.max_size() || + send_plans > send_requests_.max_size()) + throw std::length_error("nd_proof::TranslationExchange request vector capacity is invalid"); + peers_.reserve(request_count); + for (const auto& plan : schedule_->send_plans()) + add(plan, true); + for (const auto& plan : schedule_->receive_plans()) + add(plan, false); + std::sort(peers_.begin(), peers_.end(), [](const PeerStorage& left, const PeerStorage& right) { + return left.mpi_rank < right.mpi_rank; + }); + send_elements_ = 0; + receive_elements_ = 0; + for (const PeerStorage& peer : peers_) { + if (peer.send_elements > std::numeric_limits::max() - send_elements_ || + peer.receive_elements > std::numeric_limits::max() - receive_elements_) + throw std::overflow_error( + "nd_proof::TranslationExchange aggregate payload overflows size_t"); + send_elements_ += peer.send_elements; + receive_elements_ += peer.receive_elements; + } + if (send_elements_ > static_cast(std::numeric_limits::max()) || + receive_elements_ > static_cast(std::numeric_limits::max())) + throw std::overflow_error( + "nd_proof::TranslationExchange aggregate payload exceeds MPI int range"); + } + + void allocate_peer_storage_() { + for (PeerStorage& peer : peers_) { + peer.device_send = device_buffer_type("pops_nd_translation_send", peer.send_elements); + peer.device_receive = + device_buffer_type("pops_nd_translation_receive", peer.receive_elements); + peer.host_send = pinned_buffer_type("pops_nd_translation_host_send", peer.send_elements); + peer.host_receive = + pinned_buffer_type("pops_nd_translation_host_receive", peer.receive_elements); + } + receive_requests_.reserve(schedule_->receive_plan_count()); + send_requests_.reserve(schedule_->send_plan_count()); + } + + void require_execute_lane_collectively_(const ExecutionLane& lane) const { + long invalid = &lane != lane_ || !lane.active() || !lane.owns_communicator() ? 1L : 0L; + if (all_reduce_max(invalid, lane_->communicator()) != 0) + throw std::invalid_argument( + "nd_proof::TranslationExchange execute requires its exact owning ExecutionLane object"); + } + + /// A phase consensus may itself fail while request handles are live. At that point no + /// cross-rank cleanup protocol can be trusted, so fail-stop preserves the buffers and handles. + bool post_phase_gate_(long local_failure, TranslationExchangeDiagnosticStage stage) noexcept { + try { + return all_reduce_max(local_failure, lane_->communicator()) == 0; + } catch (...) { + seal_(stage); + if (live_request_count() != 0) + std::terminate(); + return false; + } + } + + /// Completion consensus occurs after field mutation. If its collective transport is uncertain, + /// no rank can safely infer whether another rank published the same field state. + bool completion_phase_gate_(long local_failure) noexcept { + try { + return all_reduce_max(local_failure, lane_->communicator()) == 0; + } catch (...) { + seal_(TranslationExchangeDiagnosticStage::completion); + std::terminate(); + } + } + + static bool all_null_(const std::vector& requests) noexcept { + return std::all_of(requests.begin(), requests.end(), + [](MPI_Request request) { return request == MPI_REQUEST_NULL; }); + } + + static int wait_all_(std::vector& requests) noexcept { + if (requests.empty()) + return MPI_SUCCESS; + if (requests.size() > static_cast(std::numeric_limits::max())) + return MPI_ERR_COUNT; + return MPI_Waitall(static_cast(requests.size()), requests.data(), MPI_STATUSES_IGNORE); + } + + bool drain_receives_() noexcept { + bool drained = true; + for (MPI_Request& request : receive_requests_) + if (request != MPI_REQUEST_NULL && MPI_Cancel(&request) != MPI_SUCCESS) + drained = false; + if (wait_all_(receive_requests_) != MPI_SUCCESS) + drained = false; + if (context_.fail_drain_rank == lane_->rank()) + drained = false; + if (drained && all_null_(receive_requests_)) + receive_requests_.clear(); + return drained && all_null_(receive_requests_) && send_requests_.empty(); + } + + bool drain_after_send_failure_() noexcept { + if (wait_all_(send_requests_) != MPI_SUCCESS || !all_null_(send_requests_)) + return false; + send_requests_.clear(); + return drain_receives_(); + } + + bool drain_after_wait_failure_() noexcept { + if (!all_null_(send_requests_) && + (wait_all_(send_requests_) != MPI_SUCCESS || !all_null_(send_requests_))) + return false; + send_requests_.clear(); + return drain_receives_(); + } + + void seal_(TranslationExchangeDiagnosticStage stage) noexcept { + sealed_ = true; + diagnostic_stage_ = stage; + } + + static void require_proven_drain_(bool drained) { + if (!drained) + std::terminate(); + } + + void drain_noexcept_() noexcept { + if (live_request_count() == 0) + return; + if (!detail::comm_active_unlocked()) + std::terminate(); + require_proven_drain_(drain_after_wait_failure_()); + } +#endif + + const schedule_type* schedule_ = nullptr; + const ExecutionLane* lane_ = nullptr; + ExecutionLane::ImmutableBorrow lane_borrow_; + TranslationExchangeContext context_{}; + std::vector peers_{}; + std::size_t send_elements_ = 0; + std::size_t receive_elements_ = 0; + bool sealed_ = false; + TranslationExchangeDiagnosticStage diagnostic_stage_ = TranslationExchangeDiagnosticStage::none; +#ifdef POPS_HAS_MPI + std::vector receive_requests_{}; + std::vector send_requests_{}; +#endif +}; + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/nd_proof/translation_schedule.hpp b/include/pops/mesh/nd_proof/translation_schedule.hpp new file mode 100644 index 000000000..6b0e94a04 --- /dev/null +++ b/include/pops/mesh/nd_proof/translation_schedule.hpp @@ -0,0 +1,591 @@ +/// @file +/// @brief Private MPI-free translation schedule proof over authenticated ND MultiFab metadata. + +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::mesh::nd_proof { + +struct TranslationScheduleBudget { + std::size_t global_jobs; + std::size_t peer_plans; + std::size_t local_elements; + std::size_t send_elements; + std::size_t receive_elements; + LocalNeighborWorkBudget neighbor; +}; + +template +class TranslationSchedule { + static_assert(Kokkos::SpaceAccessibility::accessible, + "TranslationSchedule requires DefaultExecutionSpace access to MemorySpace"); + + public: + using execution_space = Kokkos::DefaultExecutionSpace; + using execution_index_type = std::int64_t; + using execution_policy = + Kokkos::RangePolicy>; + using rank_type = Index; + using multifab_type = MultiFab; + using buffer_type = Kokkos::View; + + struct Job { + std::size_t ordinal = 0; + std::size_t source_box = 0; + std::size_t destination_box = 0; + Box destination_region{}; + std::array source_from_destination{}; + std::size_t offset = 0; + std::size_t elements = 0; + + bool operator==(const Job&) const = default; + }; + + struct PeerPlan { + rank_type peer{}; + std::vector jobs{}; + std::size_t elements = 0; + + bool operator==(const PeerPlan&) const = default; + }; + + /// Offset-free identity of one job in the globally canonical neighbor sequence. + struct CanonicalJob { + std::size_t ordinal = 0; + std::size_t source_box = 0; + std::size_t destination_box = 0; + Box destination_region{}; + std::array source_from_destination{}; + std::size_t elements = 0; + + bool operator==(const CanonicalJob&) const = default; + }; + + TranslationSchedule(const BoxArray& layout, const Distribution& distribution, + const Box& domain, const PeriodicTopology& topology, + Extent ghosts, int ncomp, int first_component, int component_count, + rank_type local_rank, const std::array& hash_bins, + BoxHashBudget hash_budget, TranslationScheduleBudget budget) + : layout_(layout), + distribution_(distribution), + domain_(domain), + topology_(topology), + ghosts_(ghosts), + ncomp_(ncomp), + first_(first_component), + count_(component_count), + local_rank_(local_rank) { + validate_metadata(); + + LocalNeighborWorkBudget neighbor_budget = budget.neighbor; + neighbor_budget.jobs = std::min(neighbor_budget.jobs, budget.global_jobs); + const std::vector> neighbors = enumerate_local_translation_neighbors( + layout_, domain_, ghosts_, topology_, hash_bins, hash_budget, neighbor_budget); + if (neighbors.size() > budget.global_jobs) + throw std::length_error("nd_proof::TranslationSchedule global jobs exceed budget"); + + // Phase one: validate every applicable job and every aggregate before materializing state. + std::vector planned; + reserve_exact(planned, neighbors.size(), "nd_proof::TranslationSchedule planned jobs"); + std::vector planned_global_jobs; + reserve_exact(planned_global_jobs, neighbors.size(), + "nd_proof::TranslationSchedule canonical global jobs"); + std::vector planned_peers; + std::size_t planned_local_jobs = 0; + std::size_t planned_send_peers = 0; + std::size_t planned_receive_peers = 0; + std::size_t planned_local_elements = 0; + std::size_t planned_send_elements = 0; + std::size_t planned_receive_elements = 0; + + for (std::size_t ordinal = 0; ordinal < neighbors.size(); ++ordinal) { + Job job = make_validated_job(neighbors[ordinal], ordinal); + if (planned_global_jobs.size() >= planned_global_jobs.max_size()) + throw std::length_error( + "nd_proof::TranslationSchedule canonical global job capacity exceeded"); + planned_global_jobs.push_back(CanonicalJob{job.ordinal, job.source_box, job.destination_box, + job.destination_region, + job.source_from_destination, job.elements}); + const JobKind kind = classify(job); + if (kind == JobKind::irrelevant) + continue; + + PlannedJob entry{std::move(job), kind, rank_type{}, 0}; + if (kind == JobKind::local) { + checked_increment(planned_local_jobs, + "nd_proof::TranslationSchedule local job count overflows size_t"); + if (planned_local_jobs > planned.max_size()) + throw std::length_error("nd_proof::TranslationSchedule local job capacity exceeded"); + checked_add_into(planned_local_elements, entry.job.elements, budget.local_elements, + "nd_proof::TranslationSchedule local elements exceed budget"); + require_execution_count( + planned_local_elements, + "nd_proof::TranslationSchedule local prefix exceeds execution range"); + } else { + entry.peer = peer_for(entry.job, kind); + entry.peer_index = find_or_add_peer(entry.peer, kind, planned_peers, budget.peer_plans, + planned_send_peers, planned_receive_peers); + PlannedPeer& peer = planned_peers[entry.peer_index]; + checked_increment(peer.jobs, + "nd_proof::TranslationSchedule peer job count overflows size_t"); + checked_add_into(peer.elements, entry.job.elements, std::numeric_limits::max(), + "nd_proof::TranslationSchedule peer elements overflow size_t"); + require_execution_count( + peer.elements, "nd_proof::TranslationSchedule peer prefix exceeds execution range"); + if (kind == JobKind::send) + checked_add_into(planned_send_elements, entry.job.elements, budget.send_elements, + "nd_proof::TranslationSchedule send elements exceed budget"); + else + checked_add_into(planned_receive_elements, entry.job.elements, budget.receive_elements, + "nd_proof::TranslationSchedule receive elements exceed budget"); + } + if (planned.size() >= planned.max_size()) + throw std::length_error("nd_proof::TranslationSchedule planned job capacity exceeded"); + planned.push_back(std::move(entry)); + } + + // Phase two: reserve exact capacities, assign checked prefixes, then publish all state at once. + std::vector materialized_local; + reserve_exact(materialized_local, planned_local_jobs, + "nd_proof::TranslationSchedule local jobs"); + std::vector materialized_send; + std::vector materialized_receive; + reserve_exact(materialized_send, planned_send_peers, + "nd_proof::TranslationSchedule send plans"); + reserve_exact(materialized_receive, planned_receive_peers, + "nd_proof::TranslationSchedule receive plans"); + materialize_peers(planned_peers, materialized_send, materialized_receive); + + std::size_t local_offset = 0; + for (const PlannedJob& entry : planned) { + Job job = entry.job; + if (entry.kind == JobKind::local) { + job.offset = local_offset; + checked_add_into(local_offset, job.elements, planned_local_elements, + "nd_proof::TranslationSchedule local prefix exceeds plan"); + require_execution_count( + local_offset, "nd_proof::TranslationSchedule local prefix exceeds execution range"); + materialized_local.push_back(std::move(job)); + continue; + } + PlannedPeer& peer = planned_peers[entry.peer_index]; + std::vector& plans = + entry.kind == JobKind::send ? materialized_send : materialized_receive; + PeerPlan& plan = plans[peer.materialized_index]; + job.offset = plan.elements; + checked_add_into(plan.elements, job.elements, peer.elements, + "nd_proof::TranslationSchedule peer prefix exceeds plan"); + require_execution_count(plan.elements, + "nd_proof::TranslationSchedule peer prefix exceeds execution range"); + plan.jobs.push_back(std::move(job)); + } + if (local_offset != planned_local_elements) + throw std::logic_error("nd_proof::TranslationSchedule local materialization mismatch"); + validate_materialized_peers(materialized_send, planned_peers, JobKind::send); + validate_materialized_peers(materialized_receive, planned_peers, JobKind::receive); + sort_peers(materialized_send); + sort_peers(materialized_receive); + + local_ = std::move(materialized_local); + send_ = std::move(materialized_send); + receive_ = std::move(materialized_receive); + canonical_global_jobs_ = std::move(planned_global_jobs); + local_elements_ = planned_local_elements; + send_elements_ = planned_send_elements; + receive_elements_ = planned_receive_elements; + global_job_count_ = canonical_global_jobs_.size(); + } + + const BoxArray& layout() const noexcept { return layout_; } + const Distribution& distribution() const noexcept { return distribution_; } + const Box& domain() const noexcept { return domain_; } + const PeriodicTopology& topology() const noexcept { return topology_; } + const Extent& ghosts() const noexcept { return ghosts_; } + int ncomp() const noexcept { return ncomp_; } + int first_component() const noexcept { return first_; } + int component_count() const noexcept { return count_; } + const rank_type& local_rank() const noexcept { return local_rank_; } + + const std::vector& local_jobs() const noexcept { return local_; } + const std::vector& send_plans() const noexcept { return send_; } + const std::vector& receive_plans() const noexcept { return receive_; } + std::size_t global_job_count() const noexcept { return global_job_count_; } + const std::vector& canonical_global_jobs() const noexcept { + return canonical_global_jobs_; + } + std::size_t local_job_count() const noexcept { return local_.size(); } + std::size_t send_plan_count() const noexcept { return send_.size(); } + std::size_t receive_plan_count() const noexcept { return receive_.size(); } + std::size_t local_elements() const noexcept { return local_elements_; } + std::size_t send_elements() const noexcept { return send_elements_; } + std::size_t receive_elements() const noexcept { return receive_elements_; } + + const PeerPlan& send_plan(const rank_type& peer) const { return find_peer(send_, peer, "send"); } + const PeerPlan& receive_plan(const rank_type& peer) const { + return find_peer(receive_, peer, "receive"); + } + + /// Validates the exact MultiFab identity without launching a kernel or accessing field storage. + void validate_fields(const multifab_type& fields) const { authenticate(fields); } + + void replay(multifab_type& fields) const { + authenticate(fields); + for (const Job& job : local_) + copy(fields, job); + Kokkos::fence(); + } + + void pack(const multifab_type& fields, const rank_type& peer, buffer_type buffer) const { + authenticate(fields); + const PeerPlan& plan = send_plan(peer); + check_buffer(plan, buffer); + for (const Job& job : plan.jobs) + pack_job(fields, job, buffer); + Kokkos::fence(); + } + + void unpack(multifab_type& fields, const rank_type& peer, buffer_type buffer) const { + authenticate(fields); + const PeerPlan& plan = receive_plan(peer); + check_buffer(plan, buffer); + for (const Job& job : plan.jobs) + unpack_job(fields, job, buffer); + Kokkos::fence(); + } + + private: + enum class JobKind { irrelevant, local, send, receive }; + + struct PlannedPeer { + rank_type peer{}; + JobKind kind = JobKind::irrelevant; + std::size_t jobs = 0; + std::size_t elements = 0; + std::size_t materialized_index = 0; + }; + + struct PlannedJob { + Job job{}; + JobKind kind = JobKind::irrelevant; + rank_type peer{}; + std::size_t peer_index = 0; + }; + + void validate_metadata() const { + if (domain_.empty() || !distribution_.matches_layout(layout_)) + throw std::invalid_argument( + "nd_proof::TranslationSchedule requires an exact non-empty layout identity"); + if (!distribution_.rank_space().contains(local_rank_) || ncomp_ < 1 || first_ < 0 || + count_ < 1 || first_ > ncomp_ - count_) + throw std::invalid_argument("nd_proof::TranslationSchedule metadata is invalid"); + for (int axis = 0; axis < Dim; ++axis) + if (ghosts_[axis] < 0) + throw std::invalid_argument("nd_proof::TranslationSchedule ghosts must be non-negative"); + topology_.validate(domain_); + } + + static void checked_increment(std::size_t& total, const char* operation) { + if (total == std::numeric_limits::max()) + throw std::overflow_error(operation); + ++total; + } + + static void checked_add_into(std::size_t& total, std::size_t value, std::size_t limit, + const char* operation) { + if (total > limit || value > limit - total) + throw std::length_error(operation); + total += value; + } + + static void require_execution_count(std::size_t value, const char* operation) { + if (value > static_cast(std::numeric_limits::max())) + throw std::overflow_error(operation); + } + + template + static void reserve_exact(std::vector& values, std::size_t capacity, const char* operation) { + if (capacity > values.max_size()) + throw std::length_error(operation); + values.reserve(capacity); + } + + Job make_validated_job(const LocalNeighborJob& neighbor, std::size_t ordinal) const { + if (neighbor.source_box >= layout_.size() || neighbor.destination_box >= layout_.size() || + neighbor.destination_region.empty()) + throw std::invalid_argument("nd_proof::TranslationSchedule neighbor metadata is invalid"); + const Box grown_destination = + periodicity_detail::grow_box(layout_[neighbor.destination_box], ghosts_); + if (!grown_destination.contains(neighbor.destination_region)) + throw std::invalid_argument( + "nd_proof::TranslationSchedule destination region is outside destination ghosts"); + const Box source_region = periodicity_detail::translate_box( + neighbor.destination_region, neighbor.source_from_destination_translation, + "nd_proof::TranslationSchedule source translation overflows int64_t"); + if (!layout_[neighbor.source_box].contains(source_region)) + throw std::invalid_argument( + "nd_proof::TranslationSchedule translated source region is outside source valid box"); + return Job{ordinal, + neighbor.source_box, + neighbor.destination_box, + neighbor.destination_region, + neighbor.source_from_destination_translation, + 0, + checked_elements(neighbor.destination_region)}; + } + + std::size_t checked_elements(const Box& box) const { + const std::int64_t cells = box.numPts(); + if (cells <= 0 || static_cast(cells) > std::numeric_limits::max() / + static_cast(count_)) + throw std::overflow_error("nd_proof::TranslationSchedule element count overflows size_t"); + if (cells > std::numeric_limits::max() / count_) + throw std::overflow_error( + "nd_proof::TranslationSchedule element count exceeds execution index range"); + return static_cast(cells) * static_cast(count_); + } + + JobKind classify(const Job& job) const { + if (distribution_.replicated()) + return JobKind::local; + const bool source_local = distribution_.owner(job.source_box) == local_rank_; + const bool destination_local = distribution_.owner(job.destination_box) == local_rank_; + if (source_local && destination_local) + return JobKind::local; + if (source_local) + return JobKind::send; + if (destination_local) + return JobKind::receive; + return JobKind::irrelevant; + } + + rank_type peer_for(const Job& job, JobKind kind) const { + if (kind == JobKind::send) + return distribution_.owner(job.destination_box); + if (kind == JobKind::receive) + return distribution_.owner(job.source_box); + throw std::logic_error("nd_proof::TranslationSchedule local jobs have no peer"); + } + + static std::size_t find_or_add_peer(const rank_type& peer, JobKind kind, + std::vector& peers, std::size_t budget, + std::size_t& send_count, std::size_t& receive_count) { + for (std::size_t index = 0; index < peers.size(); ++index) + if (peers[index].kind == kind && peers[index].peer == peer) + return index; + if (peers.size() >= budget || peers.size() >= peers.max_size()) + throw std::length_error("nd_proof::TranslationSchedule peer plans exceed budget"); + if (kind == JobKind::send) + checked_increment(send_count, + "nd_proof::TranslationSchedule send peer count overflows size_t"); + else if (kind == JobKind::receive) + checked_increment(receive_count, + "nd_proof::TranslationSchedule receive peer count overflows size_t"); + else + throw std::logic_error("nd_proof::TranslationSchedule invalid peer plan kind"); + peers.push_back(PlannedPeer{peer, kind}); + return peers.size() - 1; + } + + static void materialize_peers(std::vector& peers, std::vector& send, + std::vector& receive) { + for (PlannedPeer& peer : peers) { + std::vector& plans = peer.kind == JobKind::send ? send : receive; + peer.materialized_index = plans.size(); + plans.push_back(PeerPlan{peer.peer}); + reserve_exact(plans.back().jobs, peer.jobs, + "nd_proof::TranslationSchedule peer job capacity exceeded"); + } + } + + static void validate_materialized_peers(const std::vector& plans, + const std::vector& peers, JobKind kind) { + for (const PlannedPeer& peer : peers) { + if (peer.kind != kind) + continue; + const PeerPlan& plan = plans[peer.materialized_index]; + if (plan.elements != peer.elements || plan.jobs.size() != peer.jobs) + throw std::logic_error("nd_proof::TranslationSchedule peer materialization mismatch"); + } + } + + void sort_peers(std::vector& plans) const { + std::sort(plans.begin(), plans.end(), [this](const PeerPlan& left, const PeerPlan& right) { + return distribution_.rank_space().linear_rank(left.peer) < + distribution_.rank_space().linear_rank(right.peer); + }); + } + + const PeerPlan& find_peer(const std::vector& plans, const rank_type& peer, + const char* direction) const { + const auto found = std::find_if(plans.begin(), plans.end(), + [&](const PeerPlan& plan) { return plan.peer == peer; }); + if (found == plans.end()) + throw std::invalid_argument(std::string("nd_proof::TranslationSchedule has no ") + direction + + " plan for peer coordinate"); + return *found; + } + + void authenticate(const multifab_type& fields) const { + if (!(fields.layout() == layout_) || !(fields.distribution() == distribution_) || + fields.local_rank() != local_rank_ || fields.ghosts() != ghosts_ || + fields.ncomp() != ncomp_) + throw std::invalid_argument("nd_proof::TranslationSchedule MultiFab identity is stale"); + } + + static void check_buffer(const PeerPlan& plan, const buffer_type& buffer) { + if (buffer.extent(0) != plan.elements) + throw std::invalid_argument( + "nd_proof::TranslationSchedule buffer size does not match peer plan"); + } + + struct KernelJob { + int destination_lower[Dim]{}; + execution_index_type destination_extent[Dim]{}; + std::int64_t source_translation[Dim]{}; + int first_component = 0; + int component_count = 0; + execution_index_type cells_per_component = 0; + execution_index_type offset = 0; + execution_index_type elements = 0; + }; + + struct CopyKernel { + FieldView destination{}; + FieldView source{}; + KernelJob job{}; + + KOKKOS_FUNCTION void operator()(execution_index_type element) const { + const int component = static_cast(element / job.cells_per_component); + execution_index_type cell = element % job.cells_per_component; + Index destination_index{}; + Index source_index{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t coordinate = + job.destination_lower[axis] + cell % job.destination_extent[axis]; + destination_index.values[axis] = static_cast(coordinate); + const std::int64_t translated = coordinate + job.source_translation[axis]; + source_index.values[axis] = static_cast(translated); + cell /= job.destination_extent[axis]; + } + destination(destination_index, job.first_component + component) = + source(source_index, job.first_component + component); + } + }; + + struct PackKernel { + buffer_type buffer{}; + FieldView source{}; + KernelJob job{}; + + KOKKOS_FUNCTION void operator()(execution_index_type element) const { + const int component = static_cast(element / job.cells_per_component); + execution_index_type cell = element % job.cells_per_component; + Index source_index{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t coordinate = + job.destination_lower[axis] + cell % job.destination_extent[axis]; + const std::int64_t translated = coordinate + job.source_translation[axis]; + source_index.values[axis] = static_cast(translated); + cell /= job.destination_extent[axis]; + } + buffer(job.offset + element) = source(source_index, job.first_component + component); + } + }; + + struct UnpackKernel { + buffer_type buffer{}; + FieldView destination{}; + KernelJob job{}; + + KOKKOS_FUNCTION void operator()(execution_index_type element) const { + const int component = static_cast(element / job.cells_per_component); + execution_index_type cell = element % job.cells_per_component; + Index destination_index{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::int64_t coordinate = + job.destination_lower[axis] + cell % job.destination_extent[axis]; + destination_index.values[axis] = static_cast(coordinate); + cell /= job.destination_extent[axis]; + } + destination(destination_index, job.first_component + component) = + buffer(job.offset + element); + } + }; + + KernelJob lower_kernel_job(const Job& job) const { + require_execution_count(job.elements, + "nd_proof::TranslationSchedule job exceeds execution index range"); + require_execution_count( + job.offset, "nd_proof::TranslationSchedule job offset exceeds execution index range"); + KernelJob result{}; + result.first_component = first_; + result.component_count = count_; + result.cells_per_component = + static_cast(job.elements / static_cast(count_)); + result.offset = static_cast(job.offset); + result.elements = static_cast(job.elements); + for (int axis = 0; axis < Dim; ++axis) { + result.destination_lower[axis] = job.destination_region.lo[axis]; + result.destination_extent[axis] = job.destination_region.length(axis); + result.source_translation[axis] = job.source_from_destination[axis]; + } + return result; + } + + void copy(multifab_type& fields, const Job& job) const { + const FieldView source = + static_cast(fields).fab(job.source_box).view(); + const FieldView destination = fields.fab(job.destination_box).view(); + const KernelJob kernel_job = lower_kernel_job(job); + Kokkos::parallel_for("pops_nd_translation_copy", execution_policy(0, kernel_job.elements), + CopyKernel{destination, source, kernel_job}); + } + + void pack_job(const multifab_type& fields, const Job& job, buffer_type buffer) const { + const FieldView source = fields.fab(job.source_box).view(); + const KernelJob kernel_job = lower_kernel_job(job); + Kokkos::parallel_for("pops_nd_translation_pack", execution_policy(0, kernel_job.elements), + PackKernel{buffer, source, kernel_job}); + } + + void unpack_job(multifab_type& fields, const Job& job, buffer_type buffer) const { + const FieldView destination = fields.fab(job.destination_box).view(); + const KernelJob kernel_job = lower_kernel_job(job); + Kokkos::parallel_for("pops_nd_translation_unpack", execution_policy(0, kernel_job.elements), + UnpackKernel{buffer, destination, kernel_job}); + } + + BoxArray layout_{}; + Distribution distribution_{}; + Box domain_{}; + PeriodicTopology topology_{}; + Extent ghosts_{}; + int ncomp_ = 0; + int first_ = 0; + int count_ = 0; + rank_type local_rank_{}; + std::vector local_{}; + std::vector send_{}; + std::vector receive_{}; + std::size_t local_elements_ = 0; + std::size_t send_elements_ = 0; + std::size_t receive_elements_ = 0; + std::vector canonical_global_jobs_{}; + std::size_t global_job_count_ = 0; +}; + +} // namespace pops::mesh::nd_proof diff --git a/include/pops/mesh/storage/fab.hpp b/include/pops/mesh/storage/fab.hpp new file mode 100644 index 000000000..e9257e055 --- /dev/null +++ b/include/pops/mesh/storage/fab.hpp @@ -0,0 +1,251 @@ +/// @file +/// @brief Owning compile-time-ranked field storage in a selected Kokkos memory space. + +#pragma once + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace pops { + +/// Component-slowest field storage. Host access is explicit through a Kokkos host mirror so a +/// device-only MemorySpace is never presented as directly host-accessible. +template +class Fab { + public: + static_assert(Dim >= 1 && Dim <= 3, "pops::Fab only supports dimensions 1, 2, and 3"); + + using value_type = Real; + using memory_space = MemorySpace; + using storage_type = Kokkos::View; + using raw_host_mirror_type = typename storage_type::host_mirror_type; + + /// Host view coupled to the Fab layout that created it. It deliberately does not expose a + /// rebindable raw view at the copy boundary. + class HostMirror { + public: + Real& operator()(std::size_t index) { return values_(index); } + const Real& operator()(std::size_t index) const { return values_(index); } + std::size_t size() const noexcept { return size_; } + + private: + friend class Fab; + + HostMirror(raw_host_mirror_type values, const Fab* source, std::size_t size, + std::uint64_t generation) + : values_(std::move(values)), source_(source), size_(size), generation_(generation) {} + + raw_host_mirror_type values_{}; + const Fab* source_ = nullptr; + std::size_t size_ = 0; + std::uint64_t generation_ = 0; + }; + + using host_mirror_type = HostMirror; + + Fab() = default; + + Fab(const Box& valid, int ncomp, Extent ghosts = {}) + : valid_(valid), ncomp_(ncomp), ghosts_(ghosts) { + if (ncomp < 1) + throw std::invalid_argument("pops::Fab: ncomp must be positive"); + grown_ = grown_with_ghosts(valid_, ghosts_); + initialize_layout(); + if (size_ == 0) + return; + detail::ensure_kokkos_initialized(); + data_ = storage_type("pops_fab", size_); + Kokkos::deep_copy(data_, Real{0}); + } + + Fab(const Fab& other) + : valid_(other.valid_), + grown_(other.grown_), + ncomp_(other.ncomp_), + ghosts_(other.ghosts_), + component_stride_(other.component_stride_), + size_(other.size_) { + for (int axis = 0; axis < Dim; ++axis) + strides_[axis] = other.strides_[axis]; + if (size_ == 0) + return; + detail::ensure_kokkos_initialized(); + data_ = storage_type("pops_fab_copy", size_); + Kokkos::deep_copy(data_, other.data_); + } + + Fab& operator=(const Fab& other) { + if (this != &other) { + Fab copy(other); + *this = std::move(copy); + } + return *this; + } + + Fab(Fab&& other) noexcept { move_from(std::move(other)); } + + Fab& operator=(Fab&& other) noexcept { + if (this != &other) { + reset_moved_from(); + move_from(std::move(other)); + } + return *this; + } + + const Box& box() const { return valid_; } + const Box& grown_box() const { return grown_; } + int ncomp() const { return ncomp_; } + const Extent& ghosts() const { return ghosts_; } + std::size_t size() const { return size_; } + + FieldView view() { + FieldView result{}; + result.data = data_.data(); + result.origin = grown_.lo; + result.extents = grown_.extent(); + for (int axis = 0; axis < Dim; ++axis) + result.strides[axis] = strides_[axis]; + result.ncomp = ncomp_; + result.component_stride = component_stride_; + return result; + } + + FieldView view() const { + FieldView result{}; + result.data = data_.data(); + result.origin = grown_.lo; + result.extents = grown_.extent(); + for (int axis = 0; axis < Dim; ++axis) + result.strides[axis] = strides_[axis]; + result.ncomp = ncomp_; + result.component_stride = component_stride_; + return result; + } + + const storage_type& storage() const { return data_; } + + host_mirror_type create_host_mirror() const { + return host_mirror_type(size_ == 0 ? raw_host_mirror_type{} : Kokkos::create_mirror_view(data_), + this, size_, generation_); + } + void copy_to_host(const host_mirror_type& host) const { + validate_mirror(host); + if (size_ != 0) + Kokkos::deep_copy(host.values_, data_); + } + void copy_from_host(const host_mirror_type& host) { + validate_mirror(host); + if (size_ != 0) + Kokkos::deep_copy(data_, host.values_); + } + void set_val(Real value) { + if (size_ != 0) + Kokkos::deep_copy(data_, value); + } + + private: + void validate_mirror(const host_mirror_type& host) const { + if (host.source_ != this || host.generation_ != generation_ || host.size_ != size_ || + host.values_.extent(0) != size_) + throw std::invalid_argument("pops::Fab host mirror does not match this Fab association"); + } + + void reset_moved_from() noexcept { + valid_ = Box{}; + grown_ = Box{}; + ncomp_ = 0; + ghosts_ = Extent{}; + for (int axis = 0; axis < Dim; ++axis) + strides_[axis] = 0; + component_stride_ = 0; + size_ = 0; + data_ = storage_type{}; + ++generation_; + } + + void move_from(Fab&& other) noexcept { + valid_ = other.valid_; + grown_ = other.grown_; + ncomp_ = other.ncomp_; + ghosts_ = other.ghosts_; + for (int axis = 0; axis < Dim; ++axis) + strides_[axis] = other.strides_[axis]; + component_stride_ = other.component_stride_; + size_ = other.size_; + data_ = std::move(other.data_); + ++generation_; + other.reset_moved_from(); + } + + void initialize_layout() { + const Extent extents = grown_.extent(); + std::int64_t cells = 1; + strides_[0] = 1; + for (int axis = 0; axis < Dim; ++axis) { + if (extents[axis] == 0) { + size_ = 0; + component_stride_ = 0; + return; + } + if (axis > 0) + strides_[axis] = cells; + if (cells > std::numeric_limits::max() / extents[axis]) + throw std::overflow_error("pops::Fab: cell count exceeds int64_t"); + cells *= extents[axis]; + } + component_stride_ = cells; + if (cells > std::numeric_limits::max() / ncomp_) + throw std::overflow_error("pops::Fab: element count exceeds int64_t"); + const std::int64_t elements = cells * ncomp_; + if (static_cast(elements) > std::numeric_limits::max()) + throw std::overflow_error("pops::Fab: element count exceeds size_t"); + size_ = static_cast(elements); + } + + static Box grown_with_ghosts(const Box& valid, const Extent& ghosts) { + for (int axis = 0; axis < Dim; ++axis) + if (ghosts[axis] < 0) + throw std::invalid_argument("pops::Fab: ghost extents must be non-negative"); + if (valid.empty()) + return valid; + + Box result = valid; + for (int axis = 0; axis < Dim; ++axis) { + if (ghosts[axis] > + static_cast(std::numeric_limits::max()) - valid.hi[axis]) + throw std::overflow_error("pops::Fab: ghost growth upper bound overflow"); + if (ghosts[axis] > + static_cast(valid.lo[axis]) - std::numeric_limits::min()) + throw std::overflow_error("pops::Fab: ghost growth lower bound overflow"); + result.lo[axis] = + detail::checked_box_index(static_cast(valid.lo[axis]) - ghosts[axis], + "pops::Fab: ghost growth lower bound overflow"); + result.hi[axis] = + detail::checked_box_index(static_cast(valid.hi[axis]) + ghosts[axis], + "pops::Fab: ghost growth upper bound overflow"); + } + return result; + } + + Box valid_{}; + Box grown_{}; + int ncomp_{0}; + Extent ghosts_{}; + std::int64_t strides_[Dim]{}; + std::int64_t component_stride_{0}; + std::size_t size_{0}; + storage_type data_{}; + std::uint64_t generation_{1}; +}; + +} // namespace pops diff --git a/include/pops/mesh/storage/field_view.hpp b/include/pops/mesh/storage/field_view.hpp new file mode 100644 index 000000000..9a2c5b506 --- /dev/null +++ b/include/pops/mesh/storage/field_view.hpp @@ -0,0 +1,34 @@ +/// @file +/// @brief Non-owning compile-time-ranked field descriptor for device kernels. + +#pragma once + +#include +#include + +#include + +namespace pops { + +/// Device-copyable non-owning view. Axis 0 is contiguous and components are slowest. +template +struct FieldView { + static_assert(Dim >= 1 && Dim <= 3, "pops::FieldView only supports dimensions 1, 2, and 3"); + + static constexpr int rank = Dim; + T* data{nullptr}; + Index origin{}; + Extent extents{}; + std::int64_t strides[Dim]{}; + int ncomp{0}; + std::int64_t component_stride{0}; + + POPS_HD T& operator()(const Index& index, int component = 0) const { + std::int64_t offset = static_cast(component) * component_stride; + for (int axis = 0; axis < Dim; ++axis) + offset += (static_cast(index[axis]) - origin[axis]) * strides[axis]; + return data[offset]; + } +}; + +} // namespace pops diff --git a/include/pops/mesh/topology/boundary_topology.hpp b/include/pops/mesh/topology/boundary_topology.hpp new file mode 100644 index 000000000..ab4be43c8 --- /dev/null +++ b/include/pops/mesh/topology/boundary_topology.hpp @@ -0,0 +1,174 @@ +/// @file +/// @brief Compile-time-ranked Cartesian boundary topology. + +#pragma once + +#include +#include +#include +#include + +namespace pops { + +enum class BoundarySide : unsigned char { lower, upper }; + +/// One oriented Cartesian domain face. Ordinals are stable and dimension independent: +/// axis 0 lower/upper, axis 1 lower/upper, and so on. +template +struct Face { + static_assert(Dim >= 1 && Dim <= 3, "pops::Face supports dimensions 1, 2, and 3"); + + int axis = 0; + BoundarySide side = BoundarySide::lower; + + constexpr Face() = default; + constexpr Face(int face_axis, BoundarySide face_side) : axis(face_axis), side(face_side) { + if (axis < 0 || axis >= Dim) + throw std::invalid_argument("pops::Face axis is outside the compile-time rank"); + } + + constexpr int ordinal() const noexcept { + return 2 * axis + (side == BoundarySide::upper ? 1 : 0); + } + + constexpr int outward_sign() const noexcept { return side == BoundarySide::lower ? -1 : 1; } + + constexpr Face opposite() const noexcept { + return Face{axis, side == BoundarySide::lower ? BoundarySide::upper : BoundarySide::lower}; + } + + constexpr bool operator==(const Face&) const = default; +}; + +template +constexpr bool face_less(Face left, Face right) noexcept { + return left.ordinal() < right.ordinal(); +} + +enum class BoundaryFaceKind : unsigned char { physical, periodic }; + +/// One ordinary axis-translation periodic pairing. Mapped/signed identifications deliberately +/// remain outside this value: a translation schedule must never silently approximate one. +template +struct PeriodicFacePair { + Face first{}; + Face second{}; + + PeriodicFacePair(Face left, Face right) : first(left), second(right) { + if (left.axis != right.axis || left.side == right.side) + throw std::invalid_argument("pops::PeriodicFacePair requires opposite sides of one axis"); + if (face_less(second, first)) { + const Face saved = first; + first = second; + second = saved; + } + } + + bool operator==(const PeriodicFacePair&) const = default; +}; + +template +struct BoundaryFaceRecord { + Face face{}; + BoundaryFaceKind kind = BoundaryFaceKind::physical; + Face partner{}; + + bool operator==(const BoundaryFaceRecord&) const = default; +}; + +/// Complete Cartesian topology: every one of the 2*Dim faces is classified exactly once. +/// Unpaired faces are physical. Periodic pairs are canonicalized and conflicting assignments are +/// rejected before any topology is published. +template +class BoundaryTopology { + static_assert(Dim >= 1 && Dim <= 3, "pops::BoundaryTopology supports dimensions 1, 2, and 3"); + + public: + static constexpr std::size_t face_count = static_cast(2 * Dim); + + BoundaryTopology() { initialize_physical_faces(); } + + explicit BoundaryTopology(const std::array& periodic_axes) { + initialize_physical_faces(); + for (int axis = 0; axis < Dim; ++axis) { + if (!periodic_axes[static_cast(axis)]) + continue; + assign_pair(PeriodicFacePair{Face{axis, BoundarySide::lower}, + Face{axis, BoundarySide::upper}}); + } + } + + template + explicit BoundaryTopology(const std::array, Count>& periodic_pairs) { + static_assert(Count <= static_cast(Dim), + "a Cartesian topology has at most one periodic pair per axis"); + initialize_physical_faces(); + for (const PeriodicFacePair& pair : periodic_pairs) + assign_pair(pair); + } + + static BoundaryTopology physical() { return BoundaryTopology{}; } + + static BoundaryTopology axis_periodic(const std::array& periodic_axes) { + return BoundaryTopology{periodic_axes}; + } + + const std::array, face_count>& faces() const noexcept { return faces_; } + + const BoundaryFaceRecord& at(Face face) const noexcept { + return faces_[static_cast(face.ordinal())]; + } + + BoundaryFaceKind kind(Face face) const noexcept { return at(face).kind; } + + bool is_physical(Face face) const noexcept { + return kind(face) == BoundaryFaceKind::physical; + } + + bool is_periodic(Face face) const noexcept { + return kind(face) == BoundaryFaceKind::periodic; + } + + Face partner(Face face) const { + if (!is_periodic(face)) + throw std::invalid_argument("pops::BoundaryTopology physical face has no partner"); + return at(face).partner; + } + + std::size_t periodic_pair_count() const noexcept { return periodic_pair_count_; } + + bool operator==(const BoundaryTopology&) const = default; + + private: + void initialize_physical_faces() noexcept { + for (int axis = 0; axis < Dim; ++axis) { + const Face lower{axis, BoundarySide::lower}; + const Face upper{axis, BoundarySide::upper}; + faces_[static_cast(lower.ordinal())] = + BoundaryFaceRecord{lower, BoundaryFaceKind::physical, lower}; + faces_[static_cast(upper.ordinal())] = + BoundaryFaceRecord{upper, BoundaryFaceKind::physical, upper}; + } + periodic_pair_count_ = 0; + } + + void assign_pair(PeriodicFacePair pair) { + const std::size_t first = static_cast(pair.first.ordinal()); + const std::size_t second = static_cast(pair.second.ordinal()); + if (faces_[first].kind == BoundaryFaceKind::periodic || + faces_[second].kind == BoundaryFaceKind::periodic) + throw std::invalid_argument( + "pops::BoundaryTopology assigns one face to multiple periodic pairs"); + faces_[first] = BoundaryFaceRecord{pair.first, BoundaryFaceKind::periodic, pair.second}; + faces_[second] = BoundaryFaceRecord{pair.second, BoundaryFaceKind::periodic, pair.first}; + ++periodic_pair_count_; + } + + std::array, face_count> faces_{}; + std::size_t periodic_pair_count_ = 0; +}; + +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); + +} // namespace pops diff --git a/include/pops/numerics/elliptic/interface/field_boundary_kernel.hpp b/include/pops/numerics/elliptic/interface/field_boundary_kernel.hpp index 0df2f2a7b..45844edaa 100644 --- a/include/pops/numerics/elliptic/interface/field_boundary_kernel.hpp +++ b/include/pops/numerics/elliptic/interface/field_boundary_kernel.hpp @@ -76,9 +76,14 @@ struct FieldBoundaryExecutionContext { FieldLogicalTimePoint point{}; const MultiFab* const* states = nullptr; const FieldDistribution* state_distributions = nullptr; + // Ordered owner-qualified identities travel beside the host pointer tables. They never enter a + // device kernel; collective prepared solvers use them to distinguish equal-layout dependencies + // and to reject a rank-local permutation before publishing a context. + const std::string* state_identities = nullptr; int state_count = 0; const MultiFab* const* fields = nullptr; const FieldDistribution* field_distributions = nullptr; + const std::string* field_identities = nullptr; int field_count = 0; // Host-owned carrier selected by the launcher before a device submission. Generated launchers // copy the exact scalars they use into their named POD functor; a std::vector pointer is therefore diff --git a/include/pops/numerics/elliptic/mg/composite_fac_nlevel.hpp b/include/pops/numerics/elliptic/mg/composite_fac_nlevel.hpp index fc5ca4b78..efc3f0ca3 100644 --- a/include/pops/numerics/elliptic/mg/composite_fac_nlevel.hpp +++ b/include/pops/numerics/elliptic/mg/composite_fac_nlevel.hpp @@ -466,7 +466,6 @@ inline void CompositeFacPoisson::finalize_hierarchy_metadata_() { correction_axy_replicated_[static_cast(m)] = MultiFab(rba, rdm, 1, 1); correction_ayx_replicated_[static_cast(m)] = MultiFab(rba, rdm, 1, 1); } - boundary_context_levels_.assign(static_cast(L), FieldBoundaryExecutionContext{}); prepare_fully_refined_solver_(); } @@ -485,7 +484,8 @@ inline void CompositeFacPoisson::prepare_fully_refined_solver_() { if (has_reaction_) fully_refined_solver_->set_reaction(constant_scalar_field_provider(reaction_)); if (has_boundary_kernel_) - fully_refined_solver_->set_boundary_kernel(boundary_kernel_, boundary_context_levels_.back()); + fully_refined_solver_->set_boundary_kernel(boundary_kernel_, + boundary_context_for_level_(finest)); } inline Real CompositeFacPoisson::solve_fully_refined_hierarchy_(int max_iters, Real rel_tol, @@ -501,7 +501,7 @@ inline Real CompositeFacPoisson::solve_fully_refined_hierarchy_(int max_iters, R if (has_cross_) solver.set_cross_terms(a_xy_level(finest), a_yx_level(finest)); if (has_boundary_kernel_) - solver.set_boundary_context(boundary_context_levels_.back()); + solver.set_boundary_context(boundary_context_for_level_(finest)); copy0_(solver.rhs(), rhs_level(finest)); copy0_(solver.phi(), phi_level(finest)); Real residual = Real(0); @@ -758,7 +758,7 @@ inline Real CompositeFacPoisson::composite_residual_(int m) { if (m == 0) { prepare_field_residual_view(phim, has_boundary_kernel_ ? &boundary_view_c_ : nullptr, gm, bc_, has_boundary_kernel_ ? &boundary_kernel_ : nullptr, - has_boundary_kernel_ ? &boundary_context_ : nullptr); + has_boundary_kernel_ ? &boundary_context_for_level_(0) : nullptr); } else { if (m - 1 == 0) fill_ghosts(phi_c_, geom_c_.domain, bc_); @@ -793,6 +793,9 @@ inline Real CompositeFacPoisson::composite_residual_(int m) { const Box2D b = resm.box(li); for_each_cell(b, detail::FacMaskedResidualKernel{R, FM, LAP, coverage}); } + if (m == 0 && has_boundary_kernel_) + for (int face = 0; face < 4; ++face) + boundary_kernel_.add_residual(face, phim, resm, gm, boundary_context_for_level_(0)); add_flux_correction_(m, resm); // += (coarse - fine) on the bordering cells Real nrm = Real(0); diff --git a/include/pops/numerics/elliptic/mg/composite_fac_poisson.hpp b/include/pops/numerics/elliptic/mg/composite_fac_poisson.hpp index d1efa1f67..d1841e0a6 100644 --- a/include/pops/numerics/elliptic/mg/composite_fac_poisson.hpp +++ b/include/pops/numerics/elliptic/mg/composite_fac_poisson.hpp @@ -19,8 +19,12 @@ #include #include +#include +#include #include #include +#include +#include #include #include @@ -464,6 +468,9 @@ class CompositeFacPoisson { /// k >= 2 the fields live in the per-level vectors allocated by the N-level ctor. MultiFab& rhs_level(int k) { return k == 0 ? f_c_ : (k == 1 ? f_f_ : f_lv_[k - 2]); } MultiFab& phi_level(int k) { return k == 0 ? phi_c_ : (k == 1 ? phi_f_ : phi_lv_[k - 2]); } + const MultiFab& phi_level(int k) const { + return k == 0 ? phi_c_ : (k == 1 ? phi_f_ : phi_lv_[k - 2]); + } MultiFab& eps_level(int k) { return k == 0 ? eps_c_ : (k == 1 ? eps_f_ : eps_lv_[k - 2]); } MultiFab& eps_y_level(int k) { return k == 0 ? eps_y_c_ : (k == 1 ? eps_y_f_ : eps_y_lv_[k - 2]); @@ -497,50 +504,177 @@ class CompositeFacPoisson { void set_boundary_kernel(const CompiledFieldBoundaryKernel& kernel, const FieldBoundaryExecutionContext& context = {}) { kernel.validate(); - if (boundary_context_levels_.size() != static_cast(n_levels_)) - throw std::logic_error( - "CompositeFacPoisson boundary contexts were not materialized with the hierarchy"); boundary_kernel_ = kernel; - for (int level = 0; level < n_levels_; ++level) { - auto& level_context = boundary_context_levels_[static_cast(level)]; - level_context = context; - level_context.point.level = level; - level_context.failure = &boundary_failure_; - } - boundary_context_ = boundary_context_levels_.front(); + boundary_context_ = context; + boundary_context_.failure = &boundary_failure_; + boundary_level_contexts_.assign(static_cast(n_levels_), {}); + boundary_level_context_present_.assign(static_cast(n_levels_), false); + pending_boundary_level_contexts_.assign(static_cast(n_levels_), {}); + pending_boundary_level_context_present_.assign(static_cast(n_levels_), false); + has_level_qualified_boundary_contexts_ = false; + has_pending_level_qualified_boundary_contexts_ = false; + level_qualified_boundary_contexts_required_ = false; has_boundary_kernel_ = true; - mg_.set_boundary_kernel(boundary_kernel_, boundary_context_levels_.front()); + mg_.set_boundary_kernel(boundary_kernel_, boundary_context_); if (fully_refined_solver_) - fully_refined_solver_->set_boundary_kernel(boundary_kernel_, boundary_context_levels_.back()); + fully_refined_solver_->set_boundary_kernel(boundary_kernel_, boundary_context_); } - /// Backward-compatible context broadcast for direct numerics callers whose dependencies are - /// level-independent. Runtime-backed AMR solves use set_boundary_context_for_level() so state and - /// field pointers are never reused across hierarchy layouts. void set_boundary_context(const FieldBoundaryExecutionContext& context) { if (!has_boundary_kernel_) throw std::runtime_error("CompositeFacPoisson boundary context has no installed kernel"); - for (int level = 0; level < n_levels_; ++level) - set_boundary_context_for_level(level, context); + boundary_context_ = context; + boundary_context_.failure = &boundary_failure_; + if (!boundary_kernel_.observes_iteration) + boundary_context_.point.iteration = 0; + std::fill(boundary_level_contexts_.begin(), boundary_level_contexts_.end(), + FieldBoundaryExecutionContext{}); + std::fill(boundary_level_context_present_.begin(), boundary_level_context_present_.end(), + false); + reset_pending_boundary_contexts_(); + has_level_qualified_boundary_contexts_ = false; + level_qualified_boundary_contexts_required_ = false; + mg_.set_boundary_context(boundary_context_); + if (fully_refined_solver_) + fully_refined_solver_->set_boundary_context(boundary_context_); } - void set_boundary_context_for_level(int level, const FieldBoundaryExecutionContext& context) { + /// Install the exact state/field dependency carrier for one physical AMR level. Calling this seam + /// opts the composite solve into a fail-closed level-qualified contract: every materialized level + /// must be installed before solve(), even when only the coarse or fully refined physical boundary + /// is active for a particular hierarchy shape. + void set_boundary_context_at_level(int level, const FieldBoundaryExecutionContext& context) { + const long minimum_level = all_reduce_min(static_cast(level)); + const long maximum_level = all_reduce_max(static_cast(level)); + const long minimum_level_count = all_reduce_min(static_cast(n_levels_)); + const long maximum_level_count = all_reduce_max(static_cast(n_levels_)); + long preflight_error = 0; if (!has_boundary_kernel_) - throw std::runtime_error("CompositeFacPoisson boundary context has no installed kernel"); + preflight_error = 1; if (level < 0 || level >= n_levels_) - throw std::out_of_range("CompositeFacPoisson boundary context level is out of range"); - auto& level_context = boundary_context_levels_.at(static_cast(level)); - level_context = context; - level_context.point.level = level; - level_context.failure = &boundary_failure_; + preflight_error = std::max(preflight_error, 2L); + if (minimum_level != maximum_level) + preflight_error = std::max(preflight_error, 3L); + if (minimum_level_count != maximum_level_count) + preflight_error = std::max(preflight_error, 4L); + preflight_error = all_reduce_max(preflight_error); + if (preflight_error != 0) { + reset_pending_boundary_contexts_(); + if (preflight_error == 1) + throw std::runtime_error( + "CompositeFacPoisson level boundary context has no installed kernel collectively"); + if (preflight_error == 2) + throw std::out_of_range( + "CompositeFacPoisson boundary context level is outside the prepared hierarchy " + "collectively"); + if (preflight_error == 4) + throw std::logic_error( + "CompositeFacPoisson prepared hierarchy depth differs between communicator ranks"); + throw std::invalid_argument( + "CompositeFacPoisson boundary context level differs between communicator ranks"); + } + + level_qualified_boundary_contexts_required_ = true; + long validation_error = all_reduce_max(validate_level_boundary_context_local_(level, context)); + if (validation_error != 0) { + reset_pending_boundary_contexts_(); + throw std::invalid_argument( + "CompositeFacPoisson rejected an invalid level-qualified boundary carrier collectively " + "(code " + + std::to_string(validation_error) + ")"); + } + + FieldBoundaryExecutionContext staged = context; + staged.failure = &boundary_failure_; + // The level argument is the prepared hierarchy authority. Callers may carry an unqualified + // authoring baseline (including the default level zero) while materializing the same boundary + // plan on every level; never let that baseline leak into a level-qualified residual/JVP. + staged.point.level = level; if (!boundary_kernel_.observes_iteration) - level_context.point.iteration = 0; - if (level == 0) { - boundary_context_ = level_context; - mg_.set_boundary_context(level_context); + staged.point.iteration = 0; + try { + require_exact_level_boundary_context_contract_(level, staged); + } catch (...) { + reset_pending_boundary_contexts_(); + throw; + } + pending_boundary_level_contexts_[static_cast(level)] = staged; + pending_boundary_level_context_present_[static_cast(level)] = true; + has_pending_level_qualified_boundary_contexts_ = true; + + bool candidate_complete = true; + long pending_mask_divergence = 0; + for (bool present : pending_boundary_level_context_present_) { + candidate_complete = candidate_complete && present; + const long minimum_present = all_reduce_min(present ? 1L : 0L); + const long maximum_present = all_reduce_max(present ? 1L : 0L); + if (minimum_present != maximum_present) + pending_mask_divergence = 1; + } + if (pending_mask_divergence != 0) { + reset_pending_boundary_contexts_(); + throw std::logic_error( + "CompositeFacPoisson pending boundary carrier mask differs between communicator ranks"); + } + if (!candidate_complete) + return; + + long candidate_error = 0; + for (int candidate_level = 0; candidate_level < n_levels_; ++candidate_level) + candidate_error = std::max( + candidate_error, + validate_level_boundary_context_local_( + candidate_level, + pending_boundary_level_contexts_[static_cast(candidate_level)])); + const long unsupported_geometry = unsupported_level_boundary_geometry_local_(); + if (unsupported_geometry != 0) + candidate_error = std::max(candidate_error, 100L + unsupported_geometry); + candidate_error = all_reduce_max(candidate_error); + if (candidate_error != 0) { + reset_pending_boundary_contexts_(); + throw std::invalid_argument( + "CompositeFacPoisson rejected the complete level-qualified boundary carrier batch " + "collectively (code " + + std::to_string(candidate_error) + ")"); } - if (fully_refined_solver_ && level + 1 == n_levels_) - fully_refined_solver_->set_boundary_context(level_context); + + const FieldBoundaryExecutionContext previous_coarse = + has_level_qualified_boundary_contexts_ ? boundary_context_for_level_(0) : boundary_context_; + const FieldBoundaryExecutionContext previous_finest = + has_level_qualified_boundary_contexts_ ? boundary_context_for_level_(n_levels_ - 1) + : boundary_context_; + bool finest_refresh_attempted = false; + bool coarse_refresh_attempted = false; + try { + // Refresh the two solvers only after every carrier has passed one immutable batch preflight. + // Mark a refresh before entering its setter: a late nonlinear-cache failure can occur after + // the setter has committed the new context, so rollback must not depend on normal return. + if (fully_refined_solver_) { + finest_refresh_attempted = true; + fully_refined_solver_->set_boundary_context( + pending_boundary_level_contexts_[static_cast(n_levels_ - 1)]); + } + coarse_refresh_attempted = true; + mg_.set_boundary_context(pending_boundary_level_contexts_.front()); + } catch (...) { + const std::exception_ptr refresh_error = std::current_exception(); + try { + if (coarse_refresh_attempted) + mg_.set_boundary_context(previous_coarse); + if (finest_refresh_attempted) + fully_refined_solver_->set_boundary_context(previous_finest); + } catch (...) { + std::terminate(); + } + reset_pending_boundary_contexts_(); + std::rethrow_exception(refresh_error); + } + + boundary_level_contexts_.swap(pending_boundary_level_contexts_); + boundary_level_context_present_.swap(pending_boundary_level_context_present_); + boundary_context_ = boundary_level_contexts_.front(); + has_level_qualified_boundary_contexts_ = true; + reset_pending_boundary_contexts_(); } void set_field_nonlinear_options(const FieldNewtonOptions& options) { @@ -596,6 +730,8 @@ class CompositeFacPoisson { if (abs_tol < Real(0) || !std::isfinite(static_cast(abs_tol))) throw std::invalid_argument("CompositeFacPoisson abs_tol must be finite and nonnegative"); + require_complete_level_boundary_contexts_(); + require_supported_level_boundary_geometry_(); last_solve_report_ = {}; diagnostics_.clear(); if (has_boundary_kernel_ && !fully_refined_solver_) @@ -866,32 +1002,36 @@ class CompositeFacPoisson { SolveReport solve_boundary_fas(const FieldNewtonOptions& nonlinear) { if (!has_boundary_kernel_ || !boundary_kernel_.observes_iteration) return SolveReport::capability_failure(); + require_complete_level_boundary_contexts_(); + require_supported_level_boundary_geometry_(); validate_field_newton_options(nonlinear); - if (fully_refined_solver_) { - const int finest = n_levels_ - 1; - GeometricMG& solver = *fully_refined_solver_; - if (has_eps_) { - if (has_eps_y_) - solver.set_epsilon_anisotropic(eps_level(finest), eps_y_level(finest)); - else - solver.set_epsilon(eps_level(finest)); - } - if (has_cross_) - solver.set_cross_terms(a_xy_level(finest), a_yx_level(finest)); - solver.set_boundary_context(boundary_context_levels_.back()); - copy0_(solver.rhs(), rhs_level(finest)); - copy0_(solver.phi(), phi_level(finest)); - last_solve_report_ = solver.solve_boundary_newton(nonlinear); - last_residual_ = last_solve_report_.solved() ? solver.current_residual() - : std::numeric_limits::infinity(); - record_residual(last_solve_report_.iters, last_residual_); - if (last_solve_report_.solved()) { - copy0_(phi_level(finest), solver.phi()); - cascade_avgdown_(); - } - return last_solve_report_; + if (!fully_refined_solver_) + return SolveReport::capability_failure(); + + const int finest = n_levels_ - 1; + GeometricMG& solver = *fully_refined_solver_; + if (has_eps_) { + if (has_eps_y_) + solver.set_epsilon_anisotropic(eps_level(finest), eps_y_level(finest)); + else + solver.set_epsilon(eps_level(finest)); + } + if (has_cross_) + solver.set_cross_terms(a_xy_level(finest), a_yx_level(finest)); + solver.set_boundary_context(boundary_context_for_level_(finest)); + copy0_(solver.rhs(), rhs_level(finest)); + copy0_(solver.phi(), phi_level(finest)); + last_solve_report_ = solver.solve_boundary_newton(nonlinear); + device_fence(); + last_residual_ = last_solve_report_.solved() ? solver.current_residual() + : std::numeric_limits::infinity(); + record_residual(last_solve_report_.iters, last_residual_); + if (last_solve_report_.solved()) { + copy0_(phi_level(finest), solver.phi()); + cascade_avgdown_(); + device_fence(); } - return SolveReport::capability_failure(); + return last_solve_report_; } private: @@ -1060,10 +1200,11 @@ class CompositeFacPoisson { /// Composite coarse residual: r_c = f_c - div(eps grad phi_c) (non covered), 0 (covered), + C-F /// FLUX correction on the cells bordering the patch. @return ||r_c||_inf (NON covered cells). Real composite_coarse_residual() { + const FieldBoundaryExecutionContext* boundary_context = + has_boundary_kernel_ ? &boundary_context_for_level_(0) : nullptr; MultiFab& operator_view = prepare_field_residual_view( phi_c_, has_boundary_kernel_ ? &boundary_view_c_ : nullptr, geom_c_, bc_, - has_boundary_kernel_ ? &boundary_kernel_ : nullptr, - has_boundary_kernel_ ? &boundary_context_ : nullptr); + has_boundary_kernel_ ? &boundary_kernel_ : nullptr, boundary_context); // r_c = f_c - div(A grad phi_c) (apply_laplacian reads the already-filled ghosts; eps + cross if active). // The cross terms are read also on the COVERED cells (= fine average after average_down) -> the // 9-point stencil stays consistent at the interface; only the NORMAL flux is explicitly joined C-F @@ -1079,6 +1220,9 @@ class CompositeFacPoisson { const Box2D b = res_c_.box(0); const CoverageMaskView coverage = cov_.view(); for_each_cell(b, detail::FacLegacyMaskedResidualKernel{R, FC, LAP, coverage}); + if (has_boundary_kernel_) + for (int face = 0; face < 4; ++face) + boundary_kernel_.add_residual(face, phi_c_, res_c_, geom_c_, *boundary_context); // C-F FLUX CORRECTION, PER FINE PATCH. On each coarse cell BORDERING a patch (non covered, // covered neighbor), we REPLACE the contribution of the C-F face in div(eps grad phi_c) by the @@ -1145,7 +1289,13 @@ class CompositeFacPoisson { bool has_boundary_kernel_ = false; CompiledFieldBoundaryKernel boundary_kernel_{}; FieldBoundaryExecutionContext boundary_context_{}; - std::vector boundary_context_levels_; + std::vector boundary_level_contexts_; + std::vector boundary_level_context_present_; + std::vector pending_boundary_level_contexts_; + std::vector pending_boundary_level_context_present_; + bool has_level_qualified_boundary_contexts_ = false; + bool has_pending_level_qualified_boundary_contexts_ = false; + bool level_qualified_boundary_contexts_required_ = false; FieldBoundaryFailure boundary_failure_{}; std::vector phi_probe_snapshot_; ///< persistent full-state snapshots for exact R(0) MultiFab boundary_probe_snapshot_; ///< persistent generated-boundary view snapshot @@ -1195,6 +1345,180 @@ class CompositeFacPoisson { std::vector correction_residual_replicated_, correction_eps_replicated_, correction_eps_y_replicated_, correction_axy_replicated_, correction_ayx_replicated_; + [[nodiscard]] const FieldBoundaryExecutionContext& boundary_context_for_level_(int level) const { + if (!has_level_qualified_boundary_contexts_) + return boundary_context_; + if (level < 0 || level >= n_levels_ || + boundary_level_contexts_.size() != static_cast(n_levels_) || + boundary_level_context_present_.size() != static_cast(n_levels_) || + !boundary_level_context_present_[static_cast(level)]) + throw std::runtime_error( + "CompositeFacPoisson is missing a level-qualified boundary carrier for level " + + std::to_string(level)); + return boundary_level_contexts_[static_cast(level)]; + } + + [[nodiscard]] long validate_level_boundary_context_local_( + int level, const FieldBoundaryExecutionContext& context) const { + long validation_error = 0; + const auto validate_dependency_pack = [&](const MultiFab* const* fields, + const FieldDistribution* distributions, + const std::string* identities, int count, + long incomplete_code, long layout_code, + long distribution_code, long identity_code) { + if (count < 0 || + (count > 0 && (fields == nullptr || distributions == nullptr || identities == nullptr))) { + validation_error = std::max(validation_error, incomplete_code); + return; + } + const MultiFab& layout = phi_level(level); + for (int index = 0; index < count; ++index) { + const MultiFab* dependency = fields[index]; + if (dependency == nullptr || + dependency->box_array().boxes() != layout.box_array().boxes() || + dependency->dmap().ranks() != layout.dmap().ranks()) + validation_error = std::max(validation_error, layout_code); + if (!field_distribution_is_valid(distributions[index])) + validation_error = std::max(validation_error, distribution_code); + if (identities[index].empty()) + validation_error = std::max(validation_error, identity_code); + } + }; + validate_dependency_pack(context.states, context.state_distributions, context.state_identities, + context.state_count, /*incomplete_code=*/1, /*layout_code=*/2, + /*distribution_code=*/3, /*identity_code=*/9); + validate_dependency_pack(context.fields, context.field_distributions, context.field_identities, + context.field_count, /*incomplete_code=*/4, /*layout_code=*/5, + /*distribution_code=*/6, /*identity_code=*/10); + if (context.parameter_count < 0 || + (context.parameter_count > 0 && context.parameters == nullptr) || + (context.parameters != nullptr && + static_cast(context.parameter_count) > context.parameters->size())) + validation_error = std::max(validation_error, 7L); + if (boundary_level_contexts_.size() != static_cast(n_levels_) || + boundary_level_context_present_.size() != static_cast(n_levels_) || + pending_boundary_level_contexts_.size() != static_cast(n_levels_) || + pending_boundary_level_context_present_.size() != static_cast(n_levels_)) + validation_error = std::max(validation_error, 8L); + return validation_error; + } + + void require_exact_level_boundary_context_contract_( + int level, const FieldBoundaryExecutionContext& context) const { + std::string contract; + long materialization_failure = 0; + try { + const auto append = [&contract](const auto& value) { + detail::append_exact_contract_value(contract, value); + }; + const auto append_text = [&contract, &append](std::string_view value) { + append(static_cast(value.size())); + contract.append(value.data(), value.size()); + }; + append(level); + append(context.point.time); + append(context.point.dt); + append(context.point.clock_slot); + append(context.point.partition_slot); + append(context.point.stage_slot); + append(context.point.step); + append(context.point.substep); + append(context.point.iteration); + append(context.state_count); + append(context.field_count); + append(context.parameter_count); + for (int index = 0; index < context.state_count; ++index) { + append_text(context.state_identities[index]); + append_text(detail::field_distribution_layout_contract(*context.states[index], + context.state_distributions[index])); + } + for (int index = 0; index < context.field_count; ++index) { + append_text(context.field_identities[index]); + append_text(detail::field_distribution_layout_contract(*context.fields[index], + context.field_distributions[index])); + } + for (int index = 0; index < context.parameter_count; ++index) + append((*context.parameters)[static_cast(index)]); + } catch (...) { + materialization_failure = 1; + } + if (all_reduce_max(materialization_failure) != 0) + throw std::runtime_error( + "CompositeFacPoisson level boundary carrier contract materialization failed " + "collectively"); + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{"composite-fac-level-boundary-context", std::string_view(contract)}})) + throw std::invalid_argument( + "CompositeFacPoisson level boundary carrier contract differs between communicator " + "ranks"); + } + + void reset_pending_boundary_contexts_() { + std::fill(pending_boundary_level_contexts_.begin(), pending_boundary_level_contexts_.end(), + FieldBoundaryExecutionContext{}); + std::fill(pending_boundary_level_context_present_.begin(), + pending_boundary_level_context_present_.end(), false); + has_pending_level_qualified_boundary_contexts_ = false; + } + + void require_complete_level_boundary_contexts_() const { + if (!level_qualified_boundary_contexts_required_) + return; + long missing_level = 0; + if (has_pending_level_qualified_boundary_contexts_) + for (int level = 0; level < n_levels_; ++level) + if (!pending_boundary_level_context_present_[static_cast(level)]) { + missing_level = level + 1; + break; + } + if (missing_level == 0 && !has_level_qualified_boundary_contexts_) + missing_level = n_levels_ + 1; + for (int level = 0; level < n_levels_ && missing_level == 0; ++level) + if (boundary_level_contexts_.size() != static_cast(n_levels_) || + boundary_level_context_present_.size() != static_cast(n_levels_) || + !boundary_level_context_present_[static_cast(level)]) { + missing_level = level + 1; + break; + } + missing_level = all_reduce_max(missing_level); + if (missing_level > n_levels_) + throw std::runtime_error( + "CompositeFacPoisson has no committed level-qualified boundary carrier batch"); + if (missing_level != 0) + throw std::runtime_error( + "CompositeFacPoisson is missing a level-qualified boundary carrier for level " + + std::to_string(missing_level - 1)); + } + + [[nodiscard]] long unsupported_level_boundary_geometry_local_() const { + if (fully_refined_solver_) + return 0; + for (int level = 1; level < n_levels_; ++level) { + const Box2D domain = geom_level(level).domain; + for (const Box2D& patch : phi_level(level).box_array().boxes()) { + const bool touches_physical_boundary = + (bc_.xlo != BCType::Periodic && patch.lo[0] <= domain.lo[0]) || + (bc_.xhi != BCType::Periodic && patch.hi[0] >= domain.hi[0]) || + (bc_.ylo != BCType::Periodic && patch.lo[1] <= domain.lo[1]) || + (bc_.yhi != BCType::Periodic && patch.hi[1] >= domain.hi[1]); + if (touches_physical_boundary) + return level + 1; + } + } + return 0; + } + + void require_supported_level_boundary_geometry_() const { + if (!level_qualified_boundary_contexts_required_) + return; + const long unsupported_level = all_reduce_max(unsupported_level_boundary_geometry_local_()); + if (unsupported_level != 0) + throw std::runtime_error( + "CompositeFacPoisson level-qualified dynamic boundaries require partially refined " + "patches to remain strictly inside each physical domain; unsupported level " + + std::to_string(unsupported_level - 1)); + } + // ADC-636: the general FAC (N levels / adjacent patches / MPI). Declared here; DEFINED out-of-line // in composite_fac_nlevel.hpp (tail-included below) so composite_fac_poisson.hpp keeps the legacy // body + dispatch and the general machinery lives in the mg/ layer per ADC-334. diff --git a/include/pops/numerics/fv/flux_failure.hpp b/include/pops/numerics/fv/flux_failure.hpp index 35c629142..cb7b1c1f3 100644 --- a/include/pops/numerics/fv/flux_failure.hpp +++ b/include/pops/numerics/fv/flux_failure.hpp @@ -1,16 +1,17 @@ #pragma once /// @file -/// @brief Device-to-host failure channel for pointwise numerical-flux evaluations. +/// @brief Device-to-host failure channel for pointwise face-state and numerical-flux evaluations. /// -/// Flux providers execute inside Kokkos kernels and therefore cannot throw. Every spatial -/// entrypoint owns one tracker, passes its trivially-copyable recorder to all of its kernels, and -/// consumes the collective report before publishing the computed field. The packed reduction is -/// ordered first by status severity (Ok < Retry < Reject < Failed), then by the unsigned reason -/// code. Consequently concurrent failures produce one deterministic result on every backend and, -/// after the world reduction, on every MPI rank. +/// Primitive recovery and flux providers execute inside Kokkos kernels and therefore cannot throw. +/// Every spatial entrypoint owns one tracker, passes its trivially-copyable recorder to all of its +/// kernels, and consumes the collective report before publishing the computed field. The packed +/// reduction is ordered first by status severity (Ok < Retry < Reject < Failed), then by the +/// unsigned reason code. Consequently concurrent failures produce one deterministic result on +/// every backend and, after the world reduction, on every MPI rank. #include +#include #include #include @@ -116,6 +117,28 @@ namespace detail { inline constexpr std::uint64_t kFluxReasonMask = UINT64_C(0xffffffff); inline constexpr int kFluxSeverityShift = 32; inline constexpr std::uint32_t kNonFiniteFiniteVolumeReason = UINT32_C(0x4e46494e); // "NFIN" +inline constexpr std::uint32_t kVariableRecoveryReasonBase = UINT32_C(0x56520000); // "VR" + +POPS_HD constexpr EvaluationStatus recovery_evaluation_status(RecoveryStatus status) { + switch (status) { + case RecoveryStatus::kRecovered: + return EvaluationStatus::kOk; + case RecoveryStatus::kExhausted: + return EvaluationStatus::kRetry; + case RecoveryStatus::kRejected: + return EvaluationStatus::kReject; + case RecoveryStatus::kInvalidContract: + return EvaluationStatus::kFailed; + } + return EvaluationStatus::kFailed; +} + +POPS_HD constexpr std::uint32_t recovery_evaluation_reason(const RecoveryReport& report) { + if (report.reason_code != 0) + return report.reason_code; + return kVariableRecoveryReasonBase | + (static_cast(report.cause) & UINT32_C(0x0000ffff)); +} POPS_HD constexpr std::uint64_t pack_flux_failure(EvaluationStatus status, std::uint32_t reason_code) { @@ -170,6 +193,20 @@ struct FluxEvaluationRecorder { if (candidate > aggregate) aggregate = candidate; } + + /// Join one device-side primitive/local-variable recovery refusal into the same deterministic + /// transport reduction as numerical-flux failures. Recovery is computed pointwise and cannot + /// throw from a Kokkos kernel; this adapter preserves Retry/Reject/Fatal semantics and the + /// provider reason code without allocating or invoking a type-erased callback. + POPS_HD void record_recovery(const RecoveryReport& report, std::uint64_t& aggregate) const { + if (report.publication_permitted()) + return; + const std::uint64_t candidate = + detail::pack_flux_failure(detail::recovery_evaluation_status(report.status), + detail::recovery_evaluation_reason(report)); + if (candidate > aggregate) + aggregate = candidate; + } }; /// Explicit authority for the current transport scheduler's process-world collective order. diff --git a/include/pops/numerics/fv/flux_interfaces.hpp b/include/pops/numerics/fv/flux_interfaces.hpp index 0265fad9f..dbe98d67a 100644 --- a/include/pops/numerics/fv/flux_interfaces.hpp +++ b/include/pops/numerics/fv/flux_interfaces.hpp @@ -10,9 +10,11 @@ #include #include +#include #include #include #include +#include namespace pops { @@ -96,12 +98,54 @@ struct QualifiedProviderRequirement { const char* layout; const char* value_kind; const char* producer; + bool available; int storage_slot; }; enum class EvaluationStatus : std::uint8_t { kOk, kRetry, kReject, kFailed }; enum class TransactionFailureAction : std::uint8_t { kNone, kRetryStep, kRejectStep, kAbortRun }; +/// Stable identity of a numerical Riemann candidate. +/// +/// The value is carried by every production face result, so a successful declared fallback can +/// never be reported as if the requested solver had produced the flux. `kReject` is a terminal +/// policy action rather than an evaluated numerical solver; `kExternal` identifies a statically +/// installed user flux whose component identity remains owned by the external-brick manifest. +enum class RiemannSolverId : std::uint8_t { + kUnspecified = 0, + kRusanov = 1, + kHll = 2, + kHllc = 3, + kRoe = 4, + kExternal = 254, + kReject = 255, +}; + +/// Stable, device-copyable causes emitted by the built-in Riemann candidates. +/// +/// External numerical-flux providers may retain their own qualified reason codes. Built-ins use +/// this enum instead of scattering untyped literals through face kernels, so one rejected candidate +/// remains attributable after device/MPI reduction and step-transaction rollback. +enum class RiemannFailureCause : std::uint32_t { + kRusanovInvalidStability = UINT32_C(0x53544201), + kHllInvalidWaveInterval = UINT32_C(0x484c4c01), + kHllInvalidStability = UINT32_C(0x53544202), + kHllcInvalidWaveInterval = UINT32_C(0x484c4c02), + kHllcInvalidStability = UINT32_C(0x53544203), + kHllcNonFinitePhysicalFlux = UINT32_C(0x484c4301), + kHllcNonFinitePressure = UINT32_C(0x484c4302), + kHllcNonFiniteContact = UINT32_C(0x484c4303), + kHllcNonFiniteStarState = UINT32_C(0x484c4304), + kHllcNonFiniteFlux = UINT32_C(0x484c4305), + kRoeInvalidStability = UINT32_C(0x53544204), + kRoeNonFiniteDissipation = UINT32_C(0x524f4501), + kRoeNonFiniteFlux = UINT32_C(0x524f4502), +}; + +POPS_HD constexpr std::uint32_t riemann_reason_code(RiemannFailureCause cause) { + return static_cast(cause); +} + POPS_HD constexpr TransactionFailureAction transaction_action(EvaluationStatus status) { switch (status) { case EvaluationStatus::kOk: @@ -126,6 +170,46 @@ inline constexpr int flux_provider_count = [] { return kAuxBaseComps; }(); +template +inline constexpr bool has_qualified_flux_provider_requirements = requires { + Model::n_flux_providers; + Model::flux_provider_requirements; +}; + +/// Authenticate the generated logical provider ABI before a device pack can be instantiated. +/// +/// Hand-written C++ test models may omit both members. Generated models must provide both, and +/// every selected provider must be available, fully qualified, and backed by one in-range native +/// storage slot. The binder consumes exactly these rows; they are not inspection-only metadata. +template +consteval bool qualified_flux_provider_requirements_valid() { + constexpr bool has_count = requires { Model::n_flux_providers; }; + constexpr bool has_rows = requires { Model::flux_provider_requirements; }; + if constexpr (has_count != has_rows) { + return false; + } else if constexpr (!has_count) { + return true; + } else { + if (Model::n_flux_providers < 0 || static_cast(Model::n_flux_providers) != + Model::flux_provider_requirements.size()) + return false; + const auto nonempty = [](const char* value) { return value != nullptr && value[0] != '\0'; }; + for (std::size_t index = 0; index < Model::flux_provider_requirements.size(); ++index) { + const auto& row = Model::flux_provider_requirements[index]; + if (!row.available || row.storage_slot < 0 || + row.storage_slot >= flux_provider_count || !nonempty(row.owner_qid) || + !nonempty(row.space_kind) || !nonempty(row.space_name) || !nonempty(row.component) || + !nonempty(row.representation) || !nonempty(row.centering) || !nonempty(row.layout) || + !nonempty(row.producer)) + return false; + for (std::size_t previous = 0; previous < index; ++previous) + if (Model::flux_provider_requirements[previous].storage_slot == row.storage_slot) + return false; + } + return true; + } +} + /// Exact, model-qualified values before they are sealed into a bound device pack. /// /// Unlike the historical global Aux object this type has exactly the width requested by Model. @@ -135,6 +219,8 @@ inline constexpr int flux_provider_count = [] { template struct FluxProviderValues { static constexpr int size = flux_provider_count; + static_assert(qualified_flux_provider_requirements_valid(), + "generated physical flux provider requirements are invalid"); static_assert(size >= kAuxBaseComps, "physical flux provider packs must declare the required base providers"); static_assert(size <= kAuxMaxComps, @@ -161,6 +247,13 @@ class BoundFluxProviders { POPS_HD BoundFluxProviders(const BoundFluxProviders&) = default; BoundFluxProviders& operator=(const BoundFluxProviders&) = delete; + template + POPS_HD Real flux_provider() const { + static_assert(Component >= 0 && Component < value_count, + "physical law requested a provider outside its exact qualified pack"); + return values_[Component]; + } + private: FluxProviderValues values_; @@ -175,15 +268,43 @@ POPS_HD BoundFluxProviders bind_flux_providers(const FluxProviderValues(values); } +namespace detail { + +template +inline constexpr int qualified_flux_provider_storage_slot = + Model::flux_provider_requirements[Index].storage_slot; + +template +POPS_HD BoundFluxProviders bind_qualified_flux_providers_at( + const Storage& storage, int i, int j, std::index_sequence) { + FluxProviderValues values{}; + ((values[qualified_flux_provider_storage_slot] = + storage(i, j, qualified_flux_provider_storage_slot)), + ...); + return bind_flux_providers(values); +} + +} // namespace detail + /// Bind one exact provider pack directly from native field storage. The caller supplies a /// model-qualified component count at compile time; there is no global Aux object, truncation, or /// zero-on-missing branch on this path. template POPS_HD BoundFluxProviders bind_flux_providers_at(const Storage& storage, int i, int j) { - FluxProviderValues values{}; - for (int component = 0; component < FluxProviderValues::size; ++component) - values[component] = storage(i, j, component); - return bind_flux_providers(values); + if constexpr (has_qualified_flux_provider_requirements) { + static_assert(qualified_flux_provider_requirements_valid(), + "generated physical flux provider requirements are invalid"); + constexpr std::size_t count = qualified_flux_provider_requirements_valid() + ? static_cast(Model::n_flux_providers) + : 0; + return detail::bind_qualified_flux_providers_at(storage, i, j, + std::make_index_sequence{}); + } else { + FluxProviderValues values{}; + for (int component = 0; component < FluxProviderValues::size; ++component) + values[component] = storage(i, j, component); + return bind_flux_providers(values); + } } template @@ -219,6 +340,11 @@ struct FluxEvaluation { EvaluationStatus status = EvaluationStatus::kFailed; StabilityBound stability{}; std::uint32_t reason_code = 0; + RiemannSolverId requested_solver = RiemannSolverId::kUnspecified; + RiemannSolverId used_solver = RiemannSolverId::kUnspecified; + RiemannSolverId last_attempted_solver = RiemannSolverId::kUnspecified; + std::uint32_t recovery_reason_code = 0; + std::uint8_t attempt_count = 0; POPS_HD static FluxEvaluation ok(const State& value, StabilityBound bound) { return FluxEvaluation(EvaluationStatus::kOk, bound, 0, FluxDensity{value}); @@ -229,12 +355,47 @@ struct FluxEvaluation { POPS_HD static FluxEvaluation reject(std::uint32_t reason) { return FluxEvaluation(EvaluationStatus::kReject, {}, reason, invalid_density()); } + POPS_HD static FluxEvaluation reject(RiemannFailureCause cause) { + return reject(riemann_reason_code(cause)); + } POPS_HD static FluxEvaluation failed(std::uint32_t reason) { return FluxEvaluation(EvaluationStatus::kFailed, {}, reason, invalid_density()); } POPS_HD bool succeeded() const { return status == EvaluationStatus::kOk; } POPS_HD TransactionFailureAction failure_action() const { return transaction_action(status); } + POPS_HD bool used_fallback() const { + return succeeded() && requested_solver != RiemannSolverId::kUnspecified && + used_solver != requested_solver; + } + + /// Complete provenance for one explicitly selected solver. External policies retain their + /// own qualified reason codes; the common evaluator supplies `kExternal` when they do not expose + /// a native built-in identity. A refusal has no flux-producing solver and therefore records + /// `kReject` as the used policy action. + POPS_HD FluxEvaluation with_single_solver(RiemannSolverId solver) const { + FluxEvaluation result = *this; + result.requested_solver = solver; + result.used_solver = succeeded() ? solver : RiemannSolverId::kReject; + result.last_attempted_solver = solver; + result.recovery_reason_code = 0; + result.attempt_count = 1; + return result; + } + + /// Complete provenance after an explicit prepared recovery chain has run. + POPS_HD FluxEvaluation with_recovery_provenance(RiemannSolverId requested, RiemannSolverId used, + RiemannSolverId last_attempted, + std::uint32_t first_recovery_reason, + std::uint8_t attempts) const { + FluxEvaluation result = *this; + result.requested_solver = requested; + result.used_solver = used; + result.last_attempted_solver = last_attempted; + result.recovery_reason_code = first_recovery_reason; + result.attempt_count = attempts; + return result; + } /// Sole access to a flux density. A failed evaluator can never smuggle a plausible value into /// a spatial kernel: every non-success status produces an invalid density independently of the @@ -267,6 +428,11 @@ struct FluxEvaluation { } }; +/// Final numerical vocabulary: retain the established FluxEvaluation spelling while exposing the +/// Riemann-specific name used by prepared recovery policies. +template +using RiemannResult = FluxEvaluation; + /// The only operation which accepts a FluxDensity and a geometric measure. Its distinct return /// type has no overload here, so an IntegratedFaceFlux cannot accidentally be integrated twice. template @@ -279,9 +445,8 @@ POPS_HD IntegratedFaceFlux apply_face_measure(const FluxDensity& d } /// Narrow physical constitutive interface over a bound provider pack. Numerical-flux policies see -/// this value, never the complete runtime Model. The current native formulas still use Aux -/// internally; that storage representation is sealed behind BoundFluxProviders and cannot leak -/// into a numerical-flux signature. +/// this value, never the complete runtime Model. Physical laws consume BoundFluxProviders directly; +/// no global Aux value is reconstructed on the finite-volume path. template struct PhysicalFluxView { using State = typename Model::State; @@ -291,31 +456,8 @@ struct PhysicalFluxView { Model physical; - private: - POPS_HD static Aux physical_providers(const ProviderPack& providers) { - Aux result{}; - if constexpr (ProviderPack::value_count > 0) - result.phi = providers.values_[0]; - if constexpr (ProviderPack::value_count > 1) - result.grad_x = providers.values_[1]; - if constexpr (ProviderPack::value_count > 2) - result.grad_y = providers.values_[2]; -#define POPS_FLUX_PROVIDER_ASSIGN(name, index) \ - if constexpr (ProviderPack::value_count > index) \ - result.name = providers.values_[index]; - POPS_AUX_FIELDS(POPS_FLUX_PROVIDER_ASSIGN) -#undef POPS_FLUX_PROVIDER_ASSIGN - if constexpr (ProviderPack::value_count > kAuxNamedBase) { - for (int component = kAuxNamedBase; component < ProviderPack::value_count; ++component) - result.extra[component - kAuxNamedBase] = providers.values_[component]; - } - return result; - } - - public: POPS_HD FluxDensity evaluate(const Trace& trace, const FaceContext& face) const { - const Aux providers = physical_providers(trace.providers); - State result = physical.flux(trace.state, providers, face.axis); + State result = physical.flux(trace.state, trace.providers, face.axis); const Real sign = face.orientation_sign(); if (sign < Real(0)) { for (int component = 0; component < n_vars; ++component) @@ -325,18 +467,17 @@ struct PhysicalFluxView { } POPS_HD StabilityBound stability(const Trace& trace, const FaceContext& face) const { - const Aux providers = physical_providers(trace.providers); - return {physical.max_wave_speed(trace.state, providers, face.axis), + return {physical.max_wave_speed(trace.state, trace.providers, face.axis), StabilityUnit::kLengthPerTime, StabilityConvention::kNormalSpectralRadius}; } POPS_HD void signed_wave_speeds(const Trace& trace, const FaceContext& face, Real& lower, Real& upper) const - requires requires(const Model& model, const State& state, const Aux& providers, int axis, - Real& lo, Real& hi) { model.wave_speeds(state, providers, axis, lo, hi); } + requires requires(const Model& model, const State& state, const ProviderPack& providers, + int axis, Real& lo, + Real& hi) { model.wave_speeds(state, providers, axis, lo, hi); } { - const Aux providers = physical_providers(trace.providers); - physical.wave_speeds(trace.state, providers, face.axis, lower, upper); + physical.wave_speeds(trace.state, trace.providers, face.axis, lower, upper); if (face.orientation == FaceOrientation::kNegative) { const Real old_lower = lower; lower = -upper; @@ -371,12 +512,12 @@ struct PhysicalFluxView { POPS_HD State roe_dissipation(const Trace& left, const Trace& right, const FaceContext& face) const - requires requires(const Model& model, const State& l, const Aux& lp, const State& r, - const Aux& rp, int axis) { model.roe_dissipation(l, lp, r, rp, axis); } + requires requires(const Model& model, const State& l, const ProviderPack& lp, const State& r, + const ProviderPack& rp, + int axis) { model.roe_dissipation(l, lp, r, rp, axis); } { - const Aux left_values = physical_providers(left.providers); - const Aux right_values = physical_providers(right.providers); - return physical.roe_dissipation(left.state, left_values, right.state, right_values, face.axis); + return physical.roe_dissipation(left.state, left.providers, right.state, right.providers, + face.axis); } }; @@ -403,9 +544,9 @@ concept NumericalFlux = /// Constitutive capability gates used only during route resolution. NumericalFlux policies do not /// receive these Models; installation wraps a conforming value in the narrow PhysicalFluxView. template -concept HasHLLCStructure = requires(const Model& model, const typename Model::State& state, - const typename Model::State& other, const Aux& providers, - Real scalar, int axis, Real& lower, Real& upper) { +concept HasHLLCStructure = requires( + const Model& model, const typename Model::State& state, const typename Model::State& other, + const BoundFluxProviders& providers, Real scalar, int axis, Real& lower, Real& upper) { { model.pressure(state) } -> std::convertible_to; model.wave_speeds(state, providers, axis, lower, upper); { @@ -418,8 +559,9 @@ concept HasHLLCStructure = requires(const Model& model, const typename Model::St template concept HasRoeDissipation = - requires(const Model& model, const typename Model::State& left, const Aux& left_providers, - const typename Model::State& right, const Aux& right_providers, int axis) { + requires(const Model& model, const typename Model::State& left, + const BoundFluxProviders& left_providers, const typename Model::State& right, + const BoundFluxProviders& right_providers, int axis) { { model.roe_dissipation(left, left_providers, right, right_providers, axis) } -> std::same_as; @@ -435,7 +577,15 @@ POPS_HD FluxEvaluation evaluate_numerical_flux( const auto right = make_face_trace(right_state, right_providers); static_assert(NumericalFlux>, "numerical flux does not satisfy the typed two-trace contract"); - return numerical(physical, left, right, face); + auto result = numerical(physical, left, right, face); + if (result.requested_solver != RiemannSolverId::kUnspecified) + return result; + constexpr RiemannSolverId solver = [] { + if constexpr (requires { Numerical::solver_id; }) + return static_cast(Numerical::solver_id); + return RiemannSolverId::kExternal; + }(); + return result.with_single_solver(solver); } template diff --git a/include/pops/numerics/fv/numerical_flux.hpp b/include/pops/numerics/fv/numerical_flux.hpp index 657b6390b..d85a11556 100644 --- a/include/pops/numerics/fv/numerical_flux.hpp +++ b/include/pops/numerics/fv/numerical_flux.hpp @@ -12,9 +12,12 @@ #include +#include #include #include +#include #include +#include namespace pops { @@ -53,6 +56,14 @@ POPS_HD inline bool valid_hll_speed_interval(Real lower, Real upper) { return Kokkos::isfinite(lower) && Kokkos::isfinite(upper) && lower <= upper; } +template +POPS_HD inline bool finite_state(const State& state) { + for (int component = 0; component < State::size(); ++component) + if (!Kokkos::isfinite(state[component])) + return false; + return true; +} + /// Union two independently certified signed-wave-speed intervals. Validate both traces before /// min/max: IEEE comparisons with NaN are false, so taking the union first could silently discard /// an invalid left or right trace and manufacture a plausible finite HLL interval. @@ -71,6 +82,8 @@ POPS_HD inline void union_hll_speed_intervals(Real left_lower, Real left_upper, /// Local Lax-Friedrichs/Rusanov flux. struct RusanovFlux { + static constexpr RiemannSolverId solver_id = RiemannSolverId::kRusanov; + template POPS_HD FluxEvaluation operator()(const Physical& physical, const typename Physical::Trace& left, @@ -81,7 +94,8 @@ struct RusanovFlux { StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544201u); + return FluxEvaluation::reject( + RiemannFailureCause::kRusanovInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); typename Physical::State density{}; @@ -113,11 +127,13 @@ POPS_HD FluxEvaluation hll_flux_with_speeds( const Physical& physical, const typename Physical::Trace& left, const typename Physical::Trace& right, const FaceContext& face, Real lower, Real upper) { if (!detail::valid_hll_speed_interval(lower, upper)) - return FluxEvaluation::reject(0x484c4c01u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllInvalidWaveInterval); StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544202u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); if (lower >= Real(0)) @@ -137,6 +153,8 @@ POPS_HD FluxEvaluation hll_flux_with_speeds( /// Harten-Lax-van Leer two-wave flux. struct HLLFlux { + static constexpr RiemannSolverId solver_id = RiemannSolverId::kHll; + template POPS_HD FluxEvaluation operator()(const Physical& physical, const typename Physical::Trace& left, @@ -175,6 +193,8 @@ concept HLLCPhysicalFlux = /// Contact-resolving HLLC policy. Physical structure is supplied by the narrow PhysicalFlux. struct HLLCFlux { + static constexpr RiemannSolverId solver_id = RiemannSolverId::kHllc; + template POPS_HD FluxEvaluation operator()(const Physical& physical, const typename Physical::Trace& left, @@ -186,33 +206,61 @@ struct HLLCFlux { Real lower, upper; hll_speeds(physical, left, right, face, lower, upper); if (!detail::valid_hll_speed_interval(lower, upper)) - return FluxEvaluation::reject(0x484c4c02u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllcInvalidWaveInterval); StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544203u); + return FluxEvaluation::reject( + RiemannFailureCause::kHllcInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); - if (lower >= Real(0)) + if (lower >= Real(0)) { + if (!detail::finite_state(left_density.value)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePhysicalFlux); return FluxEvaluation::ok(left_density.value, bound); - if (upper <= Real(0)) + } + if (upper <= Real(0)) { + if (!detail::finite_state(right_density.value)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePhysicalFlux); return FluxEvaluation::ok(right_density.value, bound); + } + if (!detail::finite_state(left_density.value) || !detail::finite_state(right_density.value)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePhysicalFlux); const Real pressure_left = physical.pressure(left.state); const Real pressure_right = physical.pressure(right.state); + if (!Kokkos::isfinite(pressure_left) || !Kokkos::isfinite(pressure_right)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFinitePressure); const Real contact = physical.contact_speed(left.state, right.state, pressure_left, pressure_right, lower, upper, face); + if (!Kokkos::isfinite(contact)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteContact); typename Physical::State density{}; if (contact >= Real(0)) { const auto star = physical.star_state(left.state, pressure_left, lower, contact, face); + if (!detail::finite_state(star)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteStarState); for (int component = 0; component < Physical::n_vars; ++component) density[component] = left_density.value[component] + lower * (star[component] - left.state[component]); } else { const auto star = physical.star_state(right.state, pressure_right, upper, contact, face); + if (!detail::finite_state(star)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteStarState); for (int component = 0; component < Physical::n_vars; ++component) density[component] = right_density.value[component] + upper * (star[component] - right.state[component]); } + if (!detail::finite_state(density)) + return FluxEvaluation::reject( + RiemannFailureCause::kHllcNonFiniteFlux); return FluxEvaluation::ok(density, bound); } else { static_assert(detail::dependent_false, @@ -232,6 +280,8 @@ concept RoePhysicalFlux = PhysicalFlux && /// Roe-like policy. Eigenstructure and entropy policy belong to the physical provider. struct RoeFlux { + static constexpr RiemannSolverId solver_id = RiemannSolverId::kRoe; + template POPS_HD FluxEvaluation operator()(const Physical& physical, const typename Physical::Trace& left, @@ -243,16 +293,23 @@ struct RoeFlux { StabilityBound bound{}; if (!detail::max_normal_stability_bound(physical.stability(left, face), physical.stability(right, face), bound)) - return FluxEvaluation::reject(0x53544204u); + return FluxEvaluation::reject( + RiemannFailureCause::kRoeInvalidStability); const auto left_density = physical.evaluate(left, face); const auto right_density = physical.evaluate(right, face); const auto dissipation = physical.roe_dissipation(left, right, face); + if (!detail::finite_state(dissipation)) + return FluxEvaluation::reject( + RiemannFailureCause::kRoeNonFiniteDissipation); typename Physical::State density{}; for (int component = 0; component < Physical::n_vars; ++component) { density[component] = Real(0.5) * (left_density.value[component] + right_density.value[component]) - Real(0.5) * dissipation[component]; } + if (!detail::finite_state(density)) + return FluxEvaluation::reject( + RiemannFailureCause::kRoeNonFiniteFlux); return FluxEvaluation::ok(density, bound); } else { static_assert(detail::dependent_false, @@ -261,4 +318,125 @@ struct RoeFlux { } }; +/// Explicit terminal action of a prepared Riemann recovery chain. It is not a numerical flux and +/// is never evaluated; reaching it preserves the last candidate's typed rejection and prevents +/// publication through the ordinary FluxEvaluation failure path. +struct RejectRiemannRecovery { + static constexpr RiemannSolverId solver_id = RiemannSolverId::kReject; +}; + +namespace detail { + +template +consteval RiemannSolverId declared_riemann_solver_id() { + static_assert( + requires { Candidate::solver_id; }, + "a prepared Riemann recovery candidate must expose a typed solver_id"); + return static_cast(Candidate::solver_id); +} + +template +consteval bool valid_riemann_recovery_chain() { + constexpr std::array ids{declared_riemann_solver_id()...}; + if constexpr (sizeof...(Candidates) < 2 || sizeof...(Candidates) > 255) + return false; + if (ids.back() != RiemannSolverId::kReject) + return false; + for (std::size_t index = 0; index + 1 < ids.size(); ++index) { + if (ids[index] == RiemannSolverId::kUnspecified || ids[index] == RiemannSolverId::kReject) + return false; + for (std::size_t previous = 0; previous < index; ++previous) + if (ids[previous] == ids[index]) + return false; + } + return true; +} + +template +POPS_HD FluxEvaluation continue_riemann_recovery( + const Physical& physical, const typename Physical::Trace& left, + const typename Physical::Trace& right, const FaceContext& face, + const FluxEvaluation& current, RiemannSolverId requested, + RiemannSolverId last_attempted, std::uint32_t first_recovery_reason, std::uint8_t attempts) { + if (current.succeeded()) + return current.with_recovery_provenance(requested, last_attempted, last_attempted, + first_recovery_reason, attempts); + + // Retry and fatal outcomes are scheduler decisions, not solver degeneracies. A prepared chain + // may recover only a typed candidate rejection; it must never silently downgrade stronger + // failure semantics. + if (current.status != EvaluationStatus::kReject) + return current.with_recovery_provenance(requested, RiemannSolverId::kReject, last_attempted, + first_recovery_reason, attempts); + + if constexpr (std::is_same_v) { + static_assert(sizeof...(Rest) == 0, + "RejectRiemannRecovery must be the final prepared policy action"); + return current.with_recovery_provenance(requested, RiemannSolverId::kReject, last_attempted, + first_recovery_reason, attempts); + } else { + static_assert(NumericalFlux, + "a prepared Riemann recovery candidate does not satisfy NumericalFlux for the " + "selected physical provider"); + constexpr RiemannSolverId next_id = declared_riemann_solver_id(); + const auto next = Next{}(physical, left, right, face); + const std::uint32_t recovery_reason = + first_recovery_reason != 0 ? first_recovery_reason : next.reason_code; + return continue_riemann_recovery(physical, left, right, face, next, requested, next_id, + recovery_reason, + static_cast(attempts + 1)); + } +} + +} // namespace detail + +/// Fixed, allocation-free and device-copyable Riemann recovery chain. +/// +/// Candidate types and their order are resolved before a spatial kernel is instantiated. The hot +/// loop contains no string dispatch, virtual call, callback, exception, heap allocation or hidden +/// substitution. Only `kReject` advances to the next declared candidate; retry/fatal outcomes +/// remain terminal. The chain must end explicitly in RejectRiemannRecovery. +template +struct PreparedRiemannRecoveryPolicy; + +template +struct PreparedRiemannRecoveryPolicy { + static_assert(detail::valid_riemann_recovery_chain(), + "a prepared Riemann recovery chain must contain unique typed candidates and end " + "in RejectRiemannRecovery"); + static_assert((std::is_trivially_copyable_v && ... && std::is_trivially_copyable_v), + "prepared Riemann recovery candidates must be device-copyable values"); + + static constexpr RiemannSolverId solver_id = First::solver_id; + static constexpr std::size_t candidate_count = sizeof...(Rest); + inline static constexpr std::array ordered_solver_ids{ + detail::declared_riemann_solver_id(), detail::declared_riemann_solver_id()...}; + + template + POPS_HD FluxEvaluation operator()(const Physical& physical, + const typename Physical::Trace& left, + const typename Physical::Trace& right, + const FaceContext& face) const { + static_assert(!std::is_same_v, + "a prepared Riemann recovery chain requires a numerical first candidate"); + static_assert(NumericalFlux, + "the requested Riemann candidate does not satisfy NumericalFlux for the " + "selected physical provider"); + constexpr RiemannSolverId requested = detail::declared_riemann_solver_id(); + const auto first = First{}(physical, left, right, face); + const std::uint32_t first_reason = first.succeeded() ? 0 : first.reason_code; + return detail::continue_riemann_recovery(physical, left, right, face, first, requested, + requested, first_reason, 1); + } +}; + +template +POPS_HD constexpr PreparedRiemannRecoveryPolicy prepare_riemann_recovery_policy() { + return {}; +} + +/// Sole public fixed recovery route currently instantiated by the runtime builders. +using RoeHllRusanovRecoveryPolicy = + PreparedRiemannRecoveryPolicy; + } // namespace pops diff --git a/include/pops/numerics/fv/reconstruction.hpp b/include/pops/numerics/fv/reconstruction.hpp index 8adf4af99..1a31c9686 100644 --- a/include/pops/numerics/fv/reconstruction.hpp +++ b/include/pops/numerics/fv/reconstruction.hpp @@ -84,6 +84,55 @@ struct VanLeer { } }; +/// Monotonized-central (MC) limiter: second-order TVD with two ghosts. +/// +/// For backward/forward differences a,b with the same sign this returns +/// min((|a|+|b|)/2, 2|a|, 2|b|) with their common sign, and zero otherwise. +/// The comparisons are arranged so finite inputs cannot overflow while forming either the +/// centred candidate or the doubled bound. Stateless, branch-local and POPS_HD. +struct MC { + static constexpr int formal_order = 2; + static constexpr int n_ghost = 2; + POPS_HD Real limited_slope(Real backward, Real forward) const { + const bool positive = backward > Real(0) && forward > Real(0); + const bool negative = backward < Real(0) && forward < Real(0); + if (!positive && !negative) + return Real(0); + + const Real a = positive ? backward : -backward; + const Real b = positive ? forward : -forward; + const Real centred = Real(0.5) * a + Real(0.5) * b; + const Real smaller = a < b ? a : b; + // centred <= 2*smaller without forming the potentially overflowing doubled value. + const Real magnitude = Real(0.5) * centred <= smaller ? centred : Real(2) * smaller; + return positive ? magnitude : -magnitude; + } +}; + +/// Superbee limiter: compressive second-order TVD reconstruction with two ghosts. +/// +/// For same-sign differences it evaluates +/// max(min(2|a|,|b|), min(|a|,2|b|)) with their common sign. Each doubled candidate is formed +/// only after proving it is bounded by the other finite difference, so finite inputs remain +/// finite. Stateless and POPS_HD; selection remains compile-time through the prepared registry. +struct Superbee { + static constexpr int formal_order = 2; + static constexpr int n_ghost = 2; + POPS_HD Real limited_slope(Real backward, Real forward) const { + const bool positive = backward > Real(0) && forward > Real(0); + const bool negative = backward < Real(0) && forward < Real(0); + if (!positive && !negative) + return Real(0); + + const Real a = positive ? backward : -backward; + const Real b = positive ? forward : -forward; + const Real twice_a_bounded = a <= Real(0.5) * b ? Real(2) * a : b; + const Real twice_b_bounded = b <= Real(0.5) * a ? Real(2) * b : a; + const Real magnitude = twice_a_bounded > twice_b_bounded ? twice_a_bounded : twice_b_bounded; + return positive ? magnitude : -magnitude; + } +}; + /// weno5z: WENO5-Z reconstruction (Borges 2008) at one interface, on a 5-point stencil. /// /// Returns the reconstructed value at the face BETWEEN v0 and vp1 (face +dir of cell v0). diff --git a/include/pops/numerics/linalg/dense_eig.hpp b/include/pops/numerics/linalg/dense_eig.hpp index 6fa5758a5..f33f93d9c 100644 --- a/include/pops/numerics/linalg/dense_eig.hpp +++ b/include/pops/numerics/linalg/dense_eig.hpp @@ -730,6 +730,55 @@ POPS_HD inline bool roe_entropy_fix_apply_certified_real(const Real (&A)[N][N], } // namespace detail +/// Apply the strictly incoming spectral projector of an outward-normal flux Jacobian. +/// +/// ``out = P_in jump`` with ``P_in = R diag(lambda < 0) R^-1``. The model supplies the complete +/// Cartesian flux Jacobian and ``outward_sign`` orients it; this helper contains no Euler layout or +/// component convention. A scale-relative shifted matrix-sign separates negative modes while +/// treating the numerical sonic subspace as neutral. Complex/non-converged spectra and invalid +/// orientation fail explicitly, leaving ``out`` untouched. +template +POPS_HD inline bool characteristic_incoming_apply(const Real (&flux_jacobian)[N][N], + const Real (&jump)[N], Real (&out)[N], + int outward_sign, int max_iter = 80, + Real tol = Real(1e-13), + Real im_tol = kEigStrictImagTol, + int max_iter_per_eig = 100) { + static_assert(N >= 1 && N <= 16, "characteristic_incoming_apply: 1 <= N <= 16"); + if (outward_sign != -1 && outward_sign != 1) + return false; + Real oriented[N][N]; + for (int i = 0; i < N; ++i) + for (int j = 0; j < N; ++j) + oriented[i][j] = static_cast(outward_sign) * flux_jacobian[i][j]; + if (real_spectrum(oriented, im_tol, max_iter_per_eig) != Spectrum::kReal) + return false; + const Real scale = detail::mat_norm_inf(oriented); + if (!(scale <= std::numeric_limits::max())) + return false; + if (scale == Real(0)) { + for (int i = 0; i < N; ++i) + out[i] = Real(0); + return true; + } + const Real cutoff = Real(64) * std::numeric_limits::epsilon() * scale; + if (!(cutoff > Real(0)) || !(cutoff <= std::numeric_limits::max())) + return false; + Real plus_jump[N], minus_jump[N], candidate[N]; + if (!detail::shifted_sign_actions(oriented, jump, cutoff, plus_jump, minus_jump, max_iter, tol)) + return false; + (void)minus_jump; + for (int i = 0; i < N; ++i) { + candidate[i] = detail::safe_average(jump[i], -plus_jump[i]); + if (!(candidate[i] <= std::numeric_limits::max()) || + !(candidate[i] >= -std::numeric_limits::max())) + return false; + } + for (int i = 0; i < N; ++i) + out[i] = candidate[i]; + return true; +} + /// Roe matrix-absolute-value applied to a state jump: out = |A| dU, with |A| the SPECTRAL absolute /// value A * sign(A). sign(A) is computed by the determinant-free, infinity-norm-SCALED Newton /// matrix-sign iteration S_{k+1} = 1/2 (mu S_k + 1/mu S_k^-1), mu = sqrt(||S^-1||/||S||), which diff --git a/include/pops/numerics/nonlinear/local_nonlinear_collective.hpp b/include/pops/numerics/nonlinear/local_nonlinear_collective.hpp new file mode 100644 index 000000000..29a66df56 --- /dev/null +++ b/include/pops/numerics/nonlinear/local_nonlinear_collective.hpp @@ -0,0 +1,161 @@ +#pragma once + +/// @file +/// @brief Exact collective selection of the first failed cell of a local nonlinear solve. +/// +/// Failure diagnostics must preserve arbitrary signed `Box2D` indices. Packing two coordinates, +/// one component and the failure priority into a binary64 value cannot provide that contract: the +/// mantissa is too small, and negative coordinates can even escape the intended priority bin. +/// This helper instead performs exact staged reductions: priority is selected by the caller, then +/// minimum `j`, minimum `i` at that `j`, and minimum component at that exact cell. Integer Kokkos +/// reducers and integer MPI collectives preserve the full iterable `Box2D` index range independently +/// of the configured floating-point precision. + +#include +#include +#include +#include + +#include +#include + +namespace pops { + +struct LocalNonlinearFailureLocation { + int priority = 0; + int i = -1; + int j = -1; + int component = -1; + bool found = false; +}; + +namespace detail { + +struct LocalNonlinearFailurePresenceMax { + ConstArray4 values; + int priority = 0; + int priority_component = 0; + + POPS_HD void operator()(int i, int j, int& result) const { + if (static_cast(values(i, j, priority_component)) == priority) + result = 1; + } +}; + +struct LocalNonlinearFailureJMin { + ConstArray4 values; + int priority = 0; + int priority_component = 0; + + POPS_HD void operator()(int i, int j, int& result) const { + if (static_cast(values(i, j, priority_component)) == priority && j < result) + result = j; + } +}; + +struct LocalNonlinearFailureIMin { + ConstArray4 values; + int priority = 0; + int priority_component = 0; + int selected_j = 0; + + POPS_HD void operator()(int i, int j, int& result) const { + if (j == selected_j && static_cast(values(i, j, priority_component)) == priority && + i < result) + result = i; + } +}; + +struct LocalNonlinearFailureComponentMin { + ConstArray4 values; + int priority = 0; + int priority_component = 0; + int component_component = 0; + int selected_i = 0; + int selected_j = 0; + + POPS_HD void operator()(int i, int j, int& result) const { + if (i == selected_i && j == selected_j && + static_cast(values(i, j, priority_component)) == priority) { + const int component = static_cast(values(i, j, component_component)); + if (component < result) + result = component; + } + } +}; + +template +inline int local_nonlinear_failure_min(const MultiFab& statistics, Reducer reducer) { + int local = std::numeric_limits::max(); + for (int local_index = 0; local_index < statistics.local_size(); ++local_index) { + reducer.values = statistics.fab(local_index).const_array(); + const Box2D box = statistics.box(local_index); + if (box.empty()) + continue; + require_iterable_box(box); + ensure_kokkos_initialized(); + int selected = 0; + Kokkos::parallel_reduce("pops_local_nonlinear_failure_min", + Kokkos::MDRangePolicy, Kokkos::IndexType>( + {box.lo[0], box.lo[1]}, {box.hi[0] + 1, box.hi[1] + 1}), + reducer, Kokkos::Min{selected}); + if (selected < local) + local = selected; + } + return static_cast(all_reduce_min(static_cast(local))); +} + +inline bool local_nonlinear_failure_exists(const MultiFab& statistics, int priority, + int priority_component) { + int local = 0; + for (int local_index = 0; local_index < statistics.local_size(); ++local_index) { + const Box2D box = statistics.box(local_index); + if (box.empty()) + continue; + require_iterable_box(box); + ensure_kokkos_initialized(); + int found = 0; + Kokkos::parallel_reduce( + "pops_local_nonlinear_failure_presence", + Kokkos::MDRangePolicy, Kokkos::IndexType>( + {box.lo[0], box.lo[1]}, {box.hi[0] + 1, box.hi[1] + 1}), + LocalNonlinearFailurePresenceMax{statistics.fab(local_index).const_array(), priority, + priority_component}, + Kokkos::Max{found}); + if (found != 0) + local = 1; + } + return all_reduce_max(static_cast(local)) != 0; +} + +} // namespace detail + +/// Select the lexicographically first `(j, i, component)` carrying `priority` across all ranks. +/// `priority_component` and `component_component` identify scalar statistics components written by +/// the device kernel. A positive priority must occur at least once; otherwise the collective fails +/// closed instead of fabricating a diagnostic location. +inline LocalNonlinearFailureLocation collective_first_local_nonlinear_failure( + const MultiFab& statistics, int priority, int priority_component, int component_component) { + if (priority <= 0) + return {}; + if (priority_component < 0 || priority_component >= statistics.ncomp() || + component_component < 0 || component_component >= statistics.ncomp()) + throw std::invalid_argument("local nonlinear failure-statistics component is out of range"); + if (!detail::local_nonlinear_failure_exists(statistics, priority, priority_component)) + throw std::runtime_error("local nonlinear collective priority has no failing cell"); + + const int selected_j = detail::local_nonlinear_failure_min( + statistics, detail::LocalNonlinearFailureJMin{{}, priority, priority_component}); + + const int selected_i = detail::local_nonlinear_failure_min( + statistics, detail::LocalNonlinearFailureIMin{{}, priority, priority_component, selected_j}); + + const int selected_component = detail::local_nonlinear_failure_min( + statistics, + detail::LocalNonlinearFailureComponentMin{ + {}, priority, priority_component, component_component, selected_i, selected_j}); + + return {priority, selected_i, selected_j, selected_component, true}; +} + +} // namespace pops diff --git a/include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp b/include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp index e8e566a89..852de7cb8 100644 --- a/include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp +++ b/include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp @@ -224,63 +224,6 @@ struct PreparedLocalNonlinearProblem { namespace detail { -inline constexpr long long kLocalNonlinearFailureComponentBase = 1024; -inline constexpr long long kLocalNonlinearFailureCellStride = 1048576; -inline constexpr long long kLocalNonlinearFailureEncodingCeiling = 4503599627370496LL; - -/// Reverse-pack one failing cell and component into an exactly representable binary64 value. A max -/// reduction then selects the lexicographically first global cell without atomics, and keeps its -/// component attached to that exact cell. -POPS_HD inline Real encode_local_nonlinear_failure(int i, int j, int component) { - const Real cell = Real(j) * Real(kLocalNonlinearFailureCellStride) + Real(i); - return Real(kLocalNonlinearFailureEncodingCeiling) - - (cell * Real(kLocalNonlinearFailureComponentBase) + Real(component + 1) + Real(1)); -} - -POPS_HD inline void decode_local_nonlinear_failure(Real encoded, int& i, int& j, int& component) { - const long long packed = - kLocalNonlinearFailureEncodingCeiling - static_cast(encoded) - 1; - component = static_cast(packed % kLocalNonlinearFailureComponentBase) - 1; - const long long cell = packed / kLocalNonlinearFailureComponentBase; - i = static_cast(cell % kLocalNonlinearFailureCellStride); - j = static_cast(cell / kLocalNonlinearFailureCellStride); -} - -/// Pack collective failure precedence together with the exact first cell/component. Generated -/// Program kernels reduce a single statistics field, so independently reducing precedence and -/// location would be able to pair a fatal status with the location of an unrelated recoverable -/// failure. Precedence selects a disjoint power-of-two bin while the binary64 significand retains -/// the complete 52-bit location payload, so this adds no model-size restriction. -POPS_HD inline Real encode_ranked_local_nonlinear_failure(int priority, int i, int j, - int component) { - const Real cell = Real(j) * Real(kLocalNonlinearFailureCellStride) + Real(i); - const Real packed = cell * Real(kLocalNonlinearFailureComponentBase) + Real(component + 1); - const long long location_rank = - kLocalNonlinearFailureEncodingCeiling - static_cast(packed) - 1; - Real priority_scale = Real(1); - for (int bit = 0; bit < priority; ++bit) - priority_scale *= Real(2); - return priority_scale * - (Real(1) + Real(location_rank) / Real(kLocalNonlinearFailureEncodingCeiling)); -} - -POPS_HD inline void decode_ranked_local_nonlinear_failure(Real encoded, int& priority, int& i, - int& j, int& component) { - priority = 0; - Real normalized = encoded; - while (normalized >= Real(2)) { - normalized *= Real(0.5); - ++priority; - } - const long long location_rank = - static_cast((normalized - Real(1)) * Real(kLocalNonlinearFailureEncodingCeiling)); - const long long packed = kLocalNonlinearFailureEncodingCeiling - location_rank - 1; - component = static_cast(packed % kLocalNonlinearFailureComponentBase) - 1; - const long long cell = packed / kLocalNonlinearFailureComponentBase; - i = static_cast(cell % kLocalNonlinearFailureCellStride); - j = static_cast(cell / kLocalNonlinearFailureCellStride); -} - POPS_HD inline Real local_abs(Real value) { return value < Real(0) ? -value : value; } diff --git a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp new file mode 100644 index 000000000..ab4814f86 --- /dev/null +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -0,0 +1,654 @@ +#pragma once + +/// @file +/// @brief Prepared, ordered and transactional recovery of primitive/local variables. +/// +/// A recovery plan is a compile-time chain of concrete methods. It is device-callable and owns no +/// allocation, callback registry or mutable cache. Every method returns an explicit action and +/// cause; the chain publishes only a finite candidate accepted by the plan-wide admissibility +/// provider. Warm starts and accepted state remain caller-owned and are changed only through the +/// explicit publication transaction below. + +#include +#include +#include + +#include +#include +#include + +namespace pops { + +enum class RecoveryMethodKind : int { + kUnknown = 0, + kClosedForm = 1, + kPreparedLocalNonlinear = 2, + kBracketed = 3, + kRepair = 4, + kCustom = 5, +}; + +enum class RecoveryMethodAction : int { + kCandidate = 0, + kContinueChain = 1, + kReject = 2, +}; + +enum class RecoveryStatus : int { + kRecovered = 0, + kExhausted = 1, + kRejected = 2, + kInvalidContract = 3, +}; + +enum class RecoveryCause : int { + kNone = 0, + kClosedFormUnavailable = 1, + kIterationLimit = 2, + kSingularJacobian = 3, + kInadmissibleCandidate = 4, + kSafeguardFailure = 5, + kInvalidEvaluation = 6, + kUnsupportedCapability = 7, + kEvaluationRetry = 8, + kEvaluationReject = 9, + kEvaluationFailed = 10, + kExplicitRejection = 11, + kNonFiniteCandidate = 12, + kRepairPublicationForbidden = 13, + kMissingFailureCause = 14, + kInvalidMethodAction = 15, +}; + +POPS_HD inline RecoveryCause recovery_cause_from_local_nonlinear_status( + LocalNonlinearStatus status) { + switch (status) { + case LocalNonlinearStatus::kConverged: + return RecoveryCause::kNone; + case LocalNonlinearStatus::kIterationLimit: + return RecoveryCause::kIterationLimit; + case LocalNonlinearStatus::kSingularJacobian: + return RecoveryCause::kSingularJacobian; + case LocalNonlinearStatus::kInadmissibleCandidate: + return RecoveryCause::kInadmissibleCandidate; + case LocalNonlinearStatus::kSafeguardFailure: + return RecoveryCause::kSafeguardFailure; + case LocalNonlinearStatus::kInvalidEvaluation: + return RecoveryCause::kInvalidEvaluation; + case LocalNonlinearStatus::kUnsupportedCapability: + return RecoveryCause::kUnsupportedCapability; + case LocalNonlinearStatus::kEvaluationRetry: + return RecoveryCause::kEvaluationRetry; + case LocalNonlinearStatus::kEvaluationReject: + return RecoveryCause::kEvaluationReject; + case LocalNonlinearStatus::kEvaluationFailed: + return RecoveryCause::kEvaluationFailed; + } + return RecoveryCause::kInvalidEvaluation; +} + +template +struct RecoveryMethodResult { + static_assert(N > 0, "a recovery method needs at least one variable"); + + Real value[N] = {}; + RecoveryMethodAction action = RecoveryMethodAction::kContinueChain; + RecoveryCause cause = RecoveryCause::kMissingFailureCause; + int iterations = 0; + int evaluations = 0; + Real residual_norm = std::numeric_limits::max(); + int failing_component = -1; + std::uint32_t reason_code = 0; + + POPS_HD static RecoveryMethodResult candidate(const Real (&candidate_value)[N]) { + RecoveryMethodResult result; + for (int component = 0; component < N; ++component) + result.value[component] = candidate_value[component]; + result.action = RecoveryMethodAction::kCandidate; + result.cause = RecoveryCause::kNone; + return result; + } + + POPS_HD static RecoveryMethodResult continue_chain(RecoveryCause failure) { + RecoveryMethodResult result; + result.action = RecoveryMethodAction::kContinueChain; + result.cause = failure; + return result; + } + + POPS_HD static RecoveryMethodResult reject(RecoveryCause failure) { + RecoveryMethodResult result; + result.action = RecoveryMethodAction::kReject; + result.cause = failure; + return result; + } +}; + +template +struct RecoveryOutcome { + static_assert(N > 0, "a recovery outcome needs at least one variable"); + + Real value[N] = {}; + RecoveryStatus status = RecoveryStatus::kExhausted; + RecoveryCause cause = RecoveryCause::kNone; + int attempted_methods = 0; + int selected_method = -1; + int last_method = -1; + RecoveryMethodKind selected_method_kind = RecoveryMethodKind::kUnknown; + RecoveryMethodKind last_method_kind = RecoveryMethodKind::kUnknown; + int total_iterations = 0; + int total_evaluations = 0; + Real residual_norm = std::numeric_limits::max(); + int failing_component = -1; + std::uint32_t reason_code = 0; + + POPS_HD bool recovered() const { return status == RecoveryStatus::kRecovered; } + POPS_HD bool publication_permitted() const { return recovered(); } +}; + +/// Fixed-width, type-erased summary carried across runtime/component seams. +/// +/// RecoveryOutcome keeps the recovered value at its compile-time width. Runtime block registries +/// erase that width, but must not erase the decision that controls publication. RecoveryReport is +/// therefore the exact scalar control metadata of an outcome, without a candidate buffer. +struct RecoveryReport { + RecoveryStatus status = RecoveryStatus::kExhausted; + RecoveryCause cause = RecoveryCause::kNone; + int attempted_methods = 0; + int selected_method = -1; + int last_method = -1; + RecoveryMethodKind selected_method_kind = RecoveryMethodKind::kUnknown; + RecoveryMethodKind last_method_kind = RecoveryMethodKind::kUnknown; + int total_iterations = 0; + int total_evaluations = 0; + Real residual_norm = std::numeric_limits::max(); + int failing_component = -1; + std::uint32_t reason_code = 0; + + POPS_HD bool recovered() const { return status == RecoveryStatus::kRecovered; } + POPS_HD bool publication_permitted() const { return recovered(); } +}; + +static_assert(std::is_trivially_copyable_v, + "type-erased recovery reports must remain fixed-layout copyable values"); + +template +POPS_HD inline RecoveryReport recovery_report(const RecoveryOutcome& outcome) { + return RecoveryReport{outcome.status, + outcome.cause, + outcome.attempted_methods, + outcome.selected_method, + outcome.last_method, + outcome.selected_method_kind, + outcome.last_method_kind, + outcome.total_iterations, + outcome.total_evaluations, + outcome.residual_norm, + outcome.failing_component, + outcome.reason_code}; +} + +inline constexpr const char* recovery_status_name(RecoveryStatus status) { + switch (status) { + case RecoveryStatus::kRecovered: + return "recovered"; + case RecoveryStatus::kExhausted: + return "exhausted"; + case RecoveryStatus::kRejected: + return "rejected"; + case RecoveryStatus::kInvalidContract: + return "invalid_contract"; + } + return "unknown"; +} + +inline constexpr const char* recovery_method_kind_name(RecoveryMethodKind kind) { + switch (kind) { + case RecoveryMethodKind::kUnknown: + return "unknown"; + case RecoveryMethodKind::kClosedForm: + return "closed_form"; + case RecoveryMethodKind::kPreparedLocalNonlinear: + return "prepared_local_nonlinear"; + case RecoveryMethodKind::kBracketed: + return "bracketed"; + case RecoveryMethodKind::kRepair: + return "repair"; + case RecoveryMethodKind::kCustom: + return "custom"; + } + return "unknown"; +} + +inline constexpr const char* recovery_cause_name(RecoveryCause cause) { + switch (cause) { + case RecoveryCause::kNone: + return "none"; + case RecoveryCause::kClosedFormUnavailable: + return "closed_form_unavailable"; + case RecoveryCause::kIterationLimit: + return "iteration_limit"; + case RecoveryCause::kSingularJacobian: + return "singular_jacobian"; + case RecoveryCause::kInadmissibleCandidate: + return "inadmissible_candidate"; + case RecoveryCause::kSafeguardFailure: + return "safeguard_failure"; + case RecoveryCause::kInvalidEvaluation: + return "invalid_evaluation"; + case RecoveryCause::kUnsupportedCapability: + return "unsupported_capability"; + case RecoveryCause::kEvaluationRetry: + return "evaluation_retry"; + case RecoveryCause::kEvaluationReject: + return "evaluation_reject"; + case RecoveryCause::kEvaluationFailed: + return "evaluation_failed"; + case RecoveryCause::kExplicitRejection: + return "explicit_rejection"; + case RecoveryCause::kNonFiniteCandidate: + return "non_finite_candidate"; + case RecoveryCause::kRepairPublicationForbidden: + return "repair_publication_forbidden"; + case RecoveryCause::kMissingFailureCause: + return "missing_failure_cause"; + case RecoveryCause::kInvalidMethodAction: + return "invalid_method_action"; + } + return "unknown"; +} + +struct EmptyRecoveryMethodList { + static constexpr int size = 0; + + POPS_HD constexpr RecoveryMethodKind kind_at(int) const { return RecoveryMethodKind::kUnknown; } +}; + +template +struct RecoveryMethodList { + Head head; + Tail tail; + static constexpr int size = 1 + Tail::size; + + POPS_HD constexpr RecoveryMethodKind kind_at(int index) const { + return index == 0 ? Head::kind + : (index > 0 ? tail.kind_at(index - 1) : RecoveryMethodKind::kUnknown); + } +}; + +template +POPS_HD constexpr auto recovery_methods(Head head) { + return RecoveryMethodList{head, {}}; +} + +template + requires(sizeof...(Tail) > 0) +POPS_HD constexpr auto recovery_methods(Head head, Tail... tail) { + auto prepared_tail = recovery_methods(tail...); + return RecoveryMethodList{head, prepared_tail}; +} + +template +struct PreparedVariableRecoveryPlan { + static_assert(N > 0, "a recovery plan needs at least one variable"); + static_assert(Methods::size > 0, "a recovery plan needs at least one method"); + + Admissible admissible; + Methods methods; + + POPS_HD static constexpr int method_count() { return Methods::size; } + POPS_HD constexpr RecoveryMethodKind method_kind(int index) const { + return methods.kind_at(index); + } +}; + +template +POPS_HD constexpr auto prepare_variable_recovery(Admissible admissible, Methods methods) { + return PreparedVariableRecoveryPlan{admissible, methods}; +} + +namespace recovery_detail { + +POPS_HD inline bool recovery_finite(Real value) { + return value == value && value <= std::numeric_limits::max() && + value >= -std::numeric_limits::max(); +} + +template +POPS_HD inline bool finite_vector(const Real (&value)[N], int* failing_component) { + for (int component = 0; component < N; ++component) + if (!recovery_finite(value[component])) { + if (failing_component != nullptr) + *failing_component = component; + return false; + } + if (failing_component != nullptr) + *failing_component = -1; + return true; +} + +template +POPS_HD inline void copy_vector(const Real (&source)[N], Real (&destination)[N]) { + for (int component = 0; component < N; ++component) + destination[component] = source[component]; +} + +template +POPS_HD inline void execute_recovery_chain(const EmptyRecoveryMethodList&, const Admissible&, + const Real (&)[N], const Real (&)[N], + RecoveryOutcome&) {} + +template +POPS_HD inline void execute_recovery_chain(const RecoveryMethodList& methods, + const Admissible& admissible, const Real (&conserved)[N], + const Real (&initial_guess)[N], + RecoveryOutcome& outcome) { + const RecoveryMethodResult method_result = methods.head(conserved, initial_guess); + ++outcome.attempted_methods; + outcome.last_method = MethodIndex; + outcome.last_method_kind = Head::kind; + outcome.total_iterations += method_result.iterations; + outcome.total_evaluations += method_result.evaluations; + outcome.residual_norm = method_result.residual_norm; + outcome.failing_component = method_result.failing_component; + outcome.reason_code = method_result.reason_code; + outcome.cause = method_result.cause; + + if (method_result.action == RecoveryMethodAction::kReject) { + outcome.status = method_result.cause == RecoveryCause::kNone ? RecoveryStatus::kInvalidContract + : RecoveryStatus::kRejected; + if (method_result.cause == RecoveryCause::kNone) + outcome.cause = RecoveryCause::kMissingFailureCause; + return; + } + if (method_result.action == RecoveryMethodAction::kContinueChain) { + if (method_result.cause == RecoveryCause::kNone) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kMissingFailureCause; + return; + } + execute_recovery_chain(methods.tail, admissible, conserved, initial_guess, + outcome); + return; + } + if (method_result.action != RecoveryMethodAction::kCandidate || + method_result.cause != RecoveryCause::kNone) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kInvalidMethodAction; + return; + } + + if constexpr (Head::kind == RecoveryMethodKind::kRepair) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kRepairPublicationForbidden; + return; + } + + int failing_component = -1; + if (!finite_vector(method_result.value, &failing_component)) { + outcome.status = RecoveryStatus::kInvalidContract; + outcome.cause = RecoveryCause::kNonFiniteCandidate; + outcome.failing_component = failing_component; + return; + } + if (!admissible(method_result.value, &failing_component)) { + outcome.cause = RecoveryCause::kInadmissibleCandidate; + outcome.failing_component = failing_component; + execute_recovery_chain(methods.tail, admissible, conserved, initial_guess, + outcome); + return; + } + + copy_vector(method_result.value, outcome.value); + outcome.status = RecoveryStatus::kRecovered; + outcome.cause = RecoveryCause::kNone; + outcome.selected_method = MethodIndex; + outcome.selected_method_kind = Head::kind; + outcome.failing_component = -1; +} + +} // namespace recovery_detail + +template +POPS_HD inline RecoveryOutcome recover_prepared_variable( + const PreparedVariableRecoveryPlan& plan, const Real (&conserved)[N], + const Real (&initial_guess)[N]) { + RecoveryOutcome outcome; + recovery_detail::execute_recovery_chain<0>(plan.methods, plan.admissible, conserved, + initial_guess, outcome); + return outcome; +} + +/// Admissibility provider for model conversions that impose no additional physical policy. +/// recover_prepared_variable has already rejected every non-finite component before this provider is +/// called. This preserves each model's historical closed-form conversion without adding a hidden +/// repair, floor or fallback. +template +struct FiniteModelRecoveryAdmissibility { + POPS_HD bool operator()(const Real (&)[N], int* failing_component) const { + if (failing_component != nullptr) + *failing_component = -1; + return true; + } +}; + +/// Adapter for a model-declared primitive admissibility predicate. +/// +/// The plan owns a concrete model value, so the predicate remains allocation-free and +/// device-callable. This adapter is selected only when HasRecoveryAdmissibility is true; +/// models without the optional contract retain the historical finite-only fast path above. +template +struct DeclaredModelRecoveryAdmissibility { + Model model; + + POPS_HD bool operator()(const Real (&value)[Model::n_vars], int* failing_component) const { + typename Model::Prim primitive{}; + for (int component = 0; component < Model::n_vars; ++component) + primitive[component] = value[component]; + return model.recovery_admissible(primitive, failing_component); + } +}; + +/// One declared closed-form method around the model-owned conservative -> primitive formula. +template +struct ClosedFormModelRecoveryMethod { + static constexpr RecoveryMethodKind kind = RecoveryMethodKind::kClosedForm; + Model model; + + POPS_HD RecoveryMethodResult operator()(const Real (&conserved)[Model::n_vars], + const Real (&)[Model::n_vars]) const { + typename Model::State state{}; + for (int component = 0; component < Model::n_vars; ++component) + state[component] = conserved[component]; + const typename Model::Prim primitive = model.to_primitive(state); + Real candidate[Model::n_vars] = {}; + for (int component = 0; component < Model::n_vars; ++component) + candidate[component] = primitive[component]; + return RecoveryMethodResult::candidate(candidate); + } +}; + +/// Identity is still an explicit prepared method for scalar/no-primitive models. Consequently the +/// type-erased runtime consumer receives the same failure contract for every block. +template +struct IdentityModelRecoveryMethod { + static constexpr RecoveryMethodKind kind = RecoveryMethodKind::kClosedForm; + + POPS_HD RecoveryMethodResult operator()(const Real (&conserved)[N], const Real (&)[N]) const { + return RecoveryMethodResult::candidate(conserved); + } +}; + +/// Prepare exactly one conservative -> primitive method. There is deliberately no repair or +/// fallback method in this compatibility route. +template +POPS_HD constexpr auto prepare_model_variable_recovery(const Model& model) { + constexpr int N = Model::n_vars; + if constexpr (HasPrimitiveVars) { + const auto methods = recovery_methods(ClosedFormModelRecoveryMethod{model}); + if constexpr (HasRecoveryAdmissibility) { + return prepare_variable_recovery(DeclaredModelRecoveryAdmissibility{model}, + methods); + } else { + return prepare_variable_recovery(FiniteModelRecoveryAdmissibility{}, methods); + } + } else { + return prepare_variable_recovery(FiniteModelRecoveryAdmissibility{}, + recovery_methods(IdentityModelRecoveryMethod{})); + } +} + +/// Adapter from the common ADC-750 prepared nonlinear provider to one explicit recovery method. +/// Recoverable numerical failures advance the declared chain; fatal evaluation failures reject the +/// attempt. Both decisions remain visible in the final RecoveryOutcome. +template +struct PreparedLocalNonlinearRecoveryMethod { + static constexpr RecoveryMethodKind kind = RecoveryMethodKind::kPreparedLocalNonlinear; + ProblemFactory problem_factory; + + POPS_HD RecoveryMethodResult operator()(const Real (&conserved)[N], + const Real (&initial_guess)[N]) const { + const auto problem = problem_factory(conserved); + const LocalNonlinearCellResult local = + solve_prepared_local_nonlinear(problem, initial_guess); + RecoveryMethodResult result; + for (int component = 0; component < N; ++component) + result.value[component] = local.value[component]; + result.iterations = local.iterations; + result.evaluations = local.evaluations; + result.residual_norm = local.residual_norm; + result.failing_component = local.failing_component; + result.reason_code = local.reason_code; + result.cause = recovery_cause_from_local_nonlinear_status(local.status); + + switch (local.status) { + case LocalNonlinearStatus::kConverged: + result.action = RecoveryMethodAction::kCandidate; + break; + case LocalNonlinearStatus::kInvalidEvaluation: + case LocalNonlinearStatus::kEvaluationReject: + case LocalNonlinearStatus::kEvaluationFailed: + result.action = RecoveryMethodAction::kReject; + break; + case LocalNonlinearStatus::kIterationLimit: + case LocalNonlinearStatus::kSingularJacobian: + case LocalNonlinearStatus::kInadmissibleCandidate: + case LocalNonlinearStatus::kSafeguardFailure: + case LocalNonlinearStatus::kUnsupportedCapability: + case LocalNonlinearStatus::kEvaluationRetry: + result.action = RecoveryMethodAction::kContinueChain; + break; + } + return result; + } +}; + +template +POPS_HD constexpr auto prepared_local_nonlinear_recovery(ProblemFactory problem_factory) { + return PreparedLocalNonlinearRecoveryMethod{problem_factory}; +} + +/// One caller-owned, trivially copyable warm-start slot. A topology/state-generation mismatch is +/// an explicit cache miss; reading a stale slot never mutates or silently refreshes it. +template +struct RecoveryWarmStartSlot { + Real value[N] = {}; + std::uint64_t topology_generation = 0; + std::uint64_t state_generation = 0; + bool valid = false; + + POPS_HD bool current(std::uint64_t expected_topology, std::uint64_t expected_state) const { + return valid && topology_generation == expected_topology && state_generation == expected_state; + } + + POPS_HD bool load_if_current(std::uint64_t expected_topology, std::uint64_t expected_state, + Real (&destination)[N]) const { + if (!current(expected_topology, expected_state)) + return false; + recovery_detail::copy_vector(value, destination); + return true; + } + + POPS_HD void store(const Real (&source)[N], std::uint64_t topology, std::uint64_t state) { + recovery_detail::copy_vector(source, value); + topology_generation = topology; + state_generation = state; + valid = true; + } + + POPS_HD void invalidate() { valid = false; } +}; + +enum class RecoveryPublicationState : int { + kOpen = 0, + kTentative = 1, + kCommitted = 2, + kRolledBack = 3, +}; + +/// Transaction for the only mutation point of accepted variables and their warm-start cache. +/// A failed/rejected outcome cannot enter the tentative state. Rollback restores both snapshots +/// exactly; commit makes the already-staged candidate durable to the caller. +template +class RecoveryPublicationTransaction { + public: + POPS_HD RecoveryPublicationTransaction(Real (&accepted_value)[N], RecoveryWarmStartSlot& cache) + : accepted_value_(&accepted_value), cache_(&cache), cache_snapshot_(cache) { + recovery_detail::copy_vector(accepted_value, value_snapshot_); + } + + RecoveryPublicationTransaction(const RecoveryPublicationTransaction&) = delete; + RecoveryPublicationTransaction& operator=(const RecoveryPublicationTransaction&) = delete; + RecoveryPublicationTransaction(RecoveryPublicationTransaction&&) = delete; + RecoveryPublicationTransaction& operator=(RecoveryPublicationTransaction&&) = delete; + + /// A tentative publication is never allowed to escape merely because a caller returns early. + /// Device code has no exception unwinding contract to lean on, so scope exit itself is the final + /// fail-closed guard. Only an explicit commit makes the staged value and cache durable. + POPS_HD ~RecoveryPublicationTransaction() { + if (state_ == RecoveryPublicationState::kOpen || state_ == RecoveryPublicationState::kTentative) + (void)rollback(); + } + + POPS_HD bool publish_tentative(const RecoveryOutcome& outcome, + std::uint64_t topology_generation, + std::uint64_t state_generation) { + if (state_ != RecoveryPublicationState::kOpen || !outcome.publication_permitted()) + return false; + recovery_detail::copy_vector(outcome.value, *accepted_value_); + cache_->store(outcome.value, topology_generation, state_generation); + state_ = RecoveryPublicationState::kTentative; + return true; + } + + POPS_HD bool commit() { + if (state_ != RecoveryPublicationState::kTentative) + return false; + state_ = RecoveryPublicationState::kCommitted; + return true; + } + + POPS_HD bool rollback() { + if (state_ == RecoveryPublicationState::kCommitted || + state_ == RecoveryPublicationState::kRolledBack) + return false; + recovery_detail::copy_vector(value_snapshot_, *accepted_value_); + *cache_ = cache_snapshot_; + state_ = RecoveryPublicationState::kRolledBack; + return true; + } + + POPS_HD RecoveryPublicationState state() const { return state_; } + + private: + Real (*accepted_value_)[N]; + RecoveryWarmStartSlot* cache_; + Real value_snapshot_[N] = {}; + RecoveryWarmStartSlot cache_snapshot_; + RecoveryPublicationState state_ = RecoveryPublicationState::kOpen; +}; + +static_assert(std::is_trivially_copyable_v>, + "warm-start slots must remain device-copyable PODs"); + +} // namespace pops diff --git a/include/pops/numerics/spatial/embedded_boundary/operator.hpp b/include/pops/numerics/spatial/embedded_boundary/operator.hpp index 951b7ac69..873211b8e 100644 --- a/include/pops/numerics/spatial/embedded_boundary/operator.hpp +++ b/include/pops/numerics/spatial/embedded_boundary/operator.hpp @@ -218,13 +218,18 @@ struct EbFaceFluxXKernel { fx(i, j, c) = Real(0); return; } - const auto L = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + fx(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0, alpha); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i - 1, j, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i - 1, j, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { // This face field is transactional scratch. Keep later divergence arithmetic finite while @@ -264,13 +269,18 @@ struct EbFaceFluxYKernel { fy(i, j, c) = Real(0); return; } - const auto L = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + fy(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1, alpha); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i, j - 1, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i, j - 1, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { for (int c = 0; c < Model::n_vars; ++c) @@ -414,7 +424,7 @@ void assemble_rhs_eb_with_metrics(const Model& model, const MultiFab& U, const M /// above. No flux crosses an active/inactive face. This API accepts any device-callable level set and /// contains no shape-specific transport branch. /// -/// @tparam Limiter reconstruction (NoSlope / Minmod / VanLeer / Weno5), like the Cartesian operator. +/// @tparam Limiter prepared reconstruction policy, like the Cartesian operator. /// @tparam NumericalFlux flux policy (RusanovFlux by default). /// @param ls POPS_HD callable level set (e.g. detail::DiscDomain): ls < 0 inside. /// @param kappa_min volume fraction floor (small-cell clamp), default kEbKappaMin. diff --git a/include/pops/numerics/spatial/nd/conservation_laws.hpp b/include/pops/numerics/spatial/nd/conservation_laws.hpp new file mode 100644 index 000000000..9ef9456a0 --- /dev/null +++ b/include/pops/numerics/spatial/nd/conservation_laws.hpp @@ -0,0 +1,284 @@ +/// @file +/// @brief Dimension-generic scalar-advection and ideal-gas Euler conservation laws. + +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace pops::nd { + +namespace conservation_law_detail { + +POPS_HD inline bool finite(Real value) { + return Kokkos::isfinite(value); +} + +template +POPS_HD State invalid_state() { + State result{}; + for (int component = 0; component < State::size(); ++component) + result[component] = std::numeric_limits::quiet_NaN(); + return result; +} + +template +POPS_HD bool finite_state(const State& state) { + for (int component = 0; component < State::size(); ++component) + if (!finite(state[component])) + return false; + return true; +} + +} // namespace conservation_law_detail + +template +class ScalarAdvection { + public: + using Schema = ScalarStateSchema; + using State = typename Schema::Conservative; + using Primitive = typename Schema::Primitive; + static constexpr int dimension = Dim; + static constexpr int n_vars = Schema::nvars; + + ScalarAdvection() = default; + + static ScalarAdvection prepare(RealVector velocity) { + for (int axis = 0; axis < Dim; ++axis) + if (!std::isfinite(static_cast(velocity[axis]))) + throw std::invalid_argument("ND scalar-advection velocity must be finite on every axis"); + return ScalarAdvection(velocity); + } + + POPS_HD const RealVector& velocity() const { return velocity_; } + + POPS_HD StateConversion recover(const State& state) const { + return {state, conservation_law_detail::finite_state(state) + ? StateConversionStatus::Success + : StateConversionStatus::NonFiniteState}; + } + + POPS_HD StateConversion make_conservative(const Primitive& primitive) const { + return {primitive, conservation_law_detail::finite_state(primitive) + ? StateConversionStatus::Success + : StateConversionStatus::NonFiniteState}; + } + + POPS_HD StateConversionStatus admissibility(const State& state) const { + return recover(state).status; + } + + template + POPS_HD State flux(const State& state) const { + static_assert(Axis >= 0 && Axis < Dim, "scalar-advection flux axis is outside the dimension"); + return State{velocity_[Axis] * state[Schema::scalar]}; + } + + template + POPS_HD Real max_wave_speed(const State&) const { + static_assert(Axis >= 0 && Axis < Dim, + "scalar-advection wave-speed axis is outside the dimension"); + return velocity_[Axis] < Real(0) ? -velocity_[Axis] : velocity_[Axis]; + } + + template + POPS_HD void wave_speeds(const State&, Real& lower, Real& upper) const { + static_assert(Axis >= 0 && Axis < Dim, + "scalar-advection wave-speed axis is outside the dimension"); + lower = upper = velocity_[Axis]; + } + + private: + POPS_HD explicit constexpr ScalarAdvection(RealVector velocity) : velocity_(velocity) {} + + RealVector velocity_{}; +}; + +template +class IdealGasEuler { + public: + using Schema = EulerStateSchema; + using State = typename Schema::Conservative; + using Primitive = typename Schema::Primitive; + static constexpr int dimension = Dim; + static constexpr int n_vars = Schema::nvars; + + IdealGasEuler() = default; + + static IdealGasEuler prepare(Real gamma) { + if (!std::isfinite(static_cast(gamma)) || !(gamma > Real(1))) + throw std::invalid_argument("ND ideal-gas Euler requires a finite gamma greater than one"); + return IdealGasEuler(gamma); + } + + POPS_HD Real gamma() const { return gamma_; } + + POPS_HD StateConversion recover(const State& conservative) const { + StateConversion result{}; + if (!conservation_law_detail::finite(gamma_) || !(gamma_ > Real(1))) { + result.status = StateConversionStatus::InvalidEquationOfState; + return result; + } + if (!conservation_law_detail::finite_state(conservative)) { + result.status = StateConversionStatus::NonFiniteState; + return result; + } + + const Real density = conservative[Schema::density]; + if (!(density > Real(0))) { + result.status = StateConversionStatus::NonPositiveDensity; + return result; + } + + Real kinetic = Real(0); + result.value[Schema::density] = density; + for (int axis = 0; axis < Dim; ++axis) { + const Real velocity = conservative[axis + 1] / density; + result.value[axis + 1] = velocity; + kinetic += Real(0.5) * density * velocity * velocity; + } + const Real pressure = (gamma_ - Real(1)) * (conservative[Schema::energy] - kinetic); + if (!conservation_law_detail::finite(kinetic) || !conservation_law_detail::finite(pressure)) { + result.status = StateConversionStatus::NonFiniteState; + return result; + } + if (!(pressure > Real(0))) { + result.status = StateConversionStatus::NonPositivePressure; + return result; + } + result.value[Schema::pressure] = pressure; + result.status = StateConversionStatus::Success; + return result; + } + + POPS_HD StateConversion make_conservative(const Primitive& primitive) const { + StateConversion result{}; + if (!conservation_law_detail::finite(gamma_) || !(gamma_ > Real(1))) { + result.status = StateConversionStatus::InvalidEquationOfState; + return result; + } + if (!conservation_law_detail::finite_state(primitive)) { + result.status = StateConversionStatus::NonFiniteState; + return result; + } + + const Real density = primitive[Schema::density]; + if (!(density > Real(0))) { + result.status = StateConversionStatus::NonPositiveDensity; + return result; + } + const Real pressure = primitive[Schema::pressure]; + if (!(pressure > Real(0))) { + result.status = StateConversionStatus::NonPositivePressure; + return result; + } + + result.value[Schema::density] = density; + Real kinetic = Real(0); + for (int axis = 0; axis < Dim; ++axis) { + const Real velocity = primitive[axis + 1]; + result.value[axis + 1] = density * velocity; + kinetic += Real(0.5) * density * velocity * velocity; + } + result.value[Schema::energy] = pressure / (gamma_ - Real(1)) + kinetic; + if (!conservation_law_detail::finite_state(result.value)) { + result.value = {}; + result.status = StateConversionStatus::NonFiniteState; + return result; + } + result.status = StateConversionStatus::Success; + return result; + } + + POPS_HD StateConversionStatus admissibility(const State& state) const { + return recover(state).status; + } + + POPS_HD Real pressure(const State& state) const { + const auto primitive = recover(state); + return primitive.succeeded() ? primitive.value[Schema::pressure] + : std::numeric_limits::quiet_NaN(); + } + + template + POPS_HD State flux(const State& conservative) const { + static_assert(Axis >= 0 && Axis < Dim, "Euler flux axis is outside the dimension"); + const auto recovered = recover(conservative); + if (!recovered.succeeded()) + return conservation_law_detail::invalid_state(); + + const Primitive& primitive = recovered.value; + const Real normal_velocity = primitive[Schema::template velocity]; + const Real pressure = primitive[Schema::pressure]; + State result{}; + result[Schema::density] = conservative[Schema::template momentum]; + for (int momentum_axis = 0; momentum_axis < Dim; ++momentum_axis) { + result[momentum_axis + 1] = conservative[momentum_axis + 1] * normal_velocity; + if (momentum_axis == Axis) + result[momentum_axis + 1] += pressure; + } + result[Schema::energy] = (conservative[Schema::energy] + pressure) * normal_velocity; + return result; + } + + template + POPS_HD Real max_wave_speed(const State& conservative) const { + static_assert(Axis >= 0 && Axis < Dim, "Euler wave-speed axis is outside the dimension"); + const auto primitive = recover(conservative); + if (!primitive.succeeded()) + return std::numeric_limits::quiet_NaN(); + const Real velocity = primitive.value[Schema::template velocity]; + const Real absolute_velocity = velocity < Real(0) ? -velocity : velocity; + return absolute_velocity + Kokkos::sqrt(gamma_ * primitive.value[Schema::pressure] / + primitive.value[Schema::density]); + } + + template + POPS_HD void wave_speeds(const State& conservative, Real& lower, Real& upper) const { + static_assert(Axis >= 0 && Axis < Dim, "Euler wave-speed axis is outside the dimension"); + const auto primitive = recover(conservative); + if (!primitive.succeeded()) { + lower = upper = std::numeric_limits::quiet_NaN(); + return; + } + const Real velocity = primitive.value[Schema::template velocity]; + const Real sound_speed = + Kokkos::sqrt(gamma_ * primitive.value[Schema::pressure] / primitive.value[Schema::density]); + lower = velocity - sound_speed; + upper = velocity + sound_speed; + } + + private: + POPS_HD explicit constexpr IdealGasEuler(Real gamma) : gamma_(gamma) {} + + Real gamma_ = Real(1.4); +}; + +template +concept ConservationLaw = Dim >= 1 && Dim <= 3 && Model::dimension == Dim && Model::n_vars >= 1 && + std::is_trivially_copyable_v && + requires(const Model& model, const typename Model::State& state) { + typename Model::Schema; + typename Model::Primitive; + { + model.recover(state) + } -> std::same_as>; + { model.admissibility(state) } -> std::same_as; + }; + +static_assert(ConservationLaw<1, ScalarAdvection<1>>); +static_assert(ConservationLaw<2, ScalarAdvection<2>>); +static_assert(ConservationLaw<3, ScalarAdvection<3>>); +static_assert(ConservationLaw<1, IdealGasEuler<1>>); +static_assert(ConservationLaw<2, IdealGasEuler<2>>); +static_assert(ConservationLaw<3, IdealGasEuler<3>>); + +} // namespace pops::nd diff --git a/include/pops/numerics/spatial/nd/face_field.hpp b/include/pops/numerics/spatial/nd/face_field.hpp new file mode 100644 index 000000000..4363df231 --- /dev/null +++ b/include/pops/numerics/spatial/nd/face_field.hpp @@ -0,0 +1,128 @@ +/// @file +/// @brief Axis-indexed owning and non-owning face fields for compile-time dimensions. + +#pragma once + +#include + +#include +#include +#include +#include + +namespace pops::nd { + +template +Box face_box(const Box& cells, int axis) { + static_assert(Dim >= 1 && Dim <= 3, "ND face boxes support dimensions 1..3"); + if (axis < 0 || axis >= Dim) + throw std::invalid_argument("ND face-box axis is outside the compile-time dimension"); + if (cells.empty()) + return cells; + if (cells.hi[axis] == std::numeric_limits::max()) + throw std::overflow_error("ND face-box upper bound exceeds the signed index range"); + Box result = cells; + ++result.hi[axis]; + return result; +} + +template +Box face_box(const Box& cells) { + static_assert(Axis >= 0 && Axis < Dim, "ND face-box axis is outside the compile-time dimension"); + return face_box(cells, Axis); +} + +template +struct FaceFieldView { + static_assert(Dim >= 1 && Dim <= 3, "ND face fields support dimensions 1..3"); + + static constexpr int dimension = Dim; + FieldView axes[Dim]{}; + Box cells{}; + int ncomp = 0; + + template + POPS_HD T& operator()(const Index& face, int component = 0) const { + static_assert(Axis >= 0 && Axis < Dim, + "ND face-field axis is outside the compile-time dimension"); + return axes[Axis](face, component); + } + + template + POPS_HD const FieldView& axis() const { + static_assert(Axis >= 0 && Axis < Dim, + "ND face-field axis is outside the compile-time dimension"); + return axes[Axis]; + } +}; + +/// One component-slowest Fab per logical face direction. The field is an owning preparation +/// object; kernels capture only the trivially copyable FaceFieldView returned by view(). +template +class FaceField { + public: + static_assert(Dim >= 1 && Dim <= 3, "ND face fields support dimensions 1..3"); + + using memory_space = MemorySpace; + using FabType = Fab; + + FaceField() = default; + + FaceField(const Box& cells, int ncomp) : cells_(cells), ncomp_(ncomp) { + if (ncomp < 1) + throw std::invalid_argument("ND face fields require a positive component count"); + for (int axis = 0; axis < Dim; ++axis) + faces_[static_cast(axis)] = FabType(face_box(cells, axis), ncomp); + } + + const Box& cell_box() const { return cells_; } + int ncomp() const { return ncomp_; } + + template + FabType& field() { + static_assert(Axis >= 0 && Axis < Dim, + "ND face-field axis is outside the compile-time dimension"); + return faces_[static_cast(Axis)]; + } + + template + const FabType& field() const { + static_assert(Axis >= 0 && Axis < Dim, + "ND face-field axis is outside the compile-time dimension"); + return faces_[static_cast(Axis)]; + } + + FaceFieldView view() { + FaceFieldView result{}; + result.cells = cells_; + result.ncomp = ncomp_; + for (int axis = 0; axis < Dim; ++axis) + result.axes[axis] = faces_[static_cast(axis)].view(); + return result; + } + + FaceFieldView view() const { + FaceFieldView result{}; + result.cells = cells_; + result.ncomp = ncomp_; + for (int axis = 0; axis < Dim; ++axis) + result.axes[axis] = faces_[static_cast(axis)].view(); + return result; + } + + void set_val(Real value) { + for (auto& face : faces_) + face.set_val(value); + } + + private: + Box cells_{}; + int ncomp_ = 0; + std::array faces_{}; +}; + +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v>); + +} // namespace pops::nd diff --git a/include/pops/numerics/spatial/nd/finite_volume.hpp b/include/pops/numerics/spatial/nd/finite_volume.hpp new file mode 100644 index 000000000..42637538b --- /dev/null +++ b/include/pops/numerics/spatial/nd/finite_volume.hpp @@ -0,0 +1,382 @@ +/// @file +/// @brief Axis-static numerical flux, metric divergence and CFL contracts for ND finite volume. + +#pragma once + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace pops::nd { + +enum class FiniteVolumeStatus : std::uint8_t { + Success = 0, + NonFiniteState = 1, + NonPositiveDensity = 2, + NonPositivePressure = 3, + InvalidEquationOfState = 4, + InvalidMetric = 5, + InvalidWaveSpeed = 6, + NonFiniteFaceFlux = 7, + InvalidCourantNumber = 8, + InvalidFaceField = 9, +}; + +namespace finite_volume_detail { + +POPS_HD constexpr FiniteVolumeStatus finite_volume_status(StateConversionStatus status) { + switch (status) { + case StateConversionStatus::Success: + return FiniteVolumeStatus::Success; + case StateConversionStatus::NonFiniteState: + return FiniteVolumeStatus::NonFiniteState; + case StateConversionStatus::NonPositiveDensity: + return FiniteVolumeStatus::NonPositiveDensity; + case StateConversionStatus::NonPositivePressure: + return FiniteVolumeStatus::NonPositivePressure; + case StateConversionStatus::InvalidEquationOfState: + return FiniteVolumeStatus::InvalidEquationOfState; + } + return FiniteVolumeStatus::NonFiniteState; +} + +POPS_HD constexpr std::uint32_t failure_reason(FiniteVolumeStatus status) { + return UINT32_C(0x4e440000) | static_cast(status); +} + +template +POPS_HD bool finite_state(const State& state) { + for (int component = 0; component < State::size(); ++component) + if (!Kokkos::isfinite(state[component])) + return false; + return true; +} + +template +consteval RiemannSolverId solver_id() { + if constexpr (requires { Numerical::solver_id; }) + return static_cast(Numerical::solver_id); + return RiemannSolverId::kExternal; +} + +struct NoFluxProviders {}; + +template +struct AxisPhysicalFlux { + static_assert(Axis >= 0 && Axis < Model::dimension, + "ND physical-flux axis is outside the conservation-law dimension"); + + using State = typename Model::State; + using ProviderPack = NoFluxProviders; + using Trace = FaceTrace; + static constexpr int n_vars = Model::n_vars; + + Model model; + + POPS_HD FluxDensity evaluate(const Trace& trace, const FaceContext& face) const { + State result = model.template flux(trace.state); + if (face.orientation == FaceOrientation::kNegative) + for (int component = 0; component < n_vars; ++component) + result[component] = -result[component]; + return {result}; + } + + POPS_HD StabilityBound stability(const Trace& trace, const FaceContext&) const { + return {model.template max_wave_speed(trace.state), StabilityUnit::kLengthPerTime, + StabilityConvention::kNormalSpectralRadius}; + } + + POPS_HD void signed_wave_speeds(const Trace& trace, const FaceContext& face, Real& lower, + Real& upper) const { + model.template wave_speeds(trace.state, lower, upper); + if (face.orientation == FaceOrientation::kNegative) { + const Real old_lower = lower; + lower = -upper; + upper = -old_lower; + } + } +}; + +template +concept AxisConservationLaw = + ConservationLaw && Axis >= 0 && Axis < Model::dimension && + requires(const Model& model, const typename Model::State& state, Real& lower, Real& upper) { + { model.template flux(state) } -> std::same_as; + { model.template max_wave_speed(state) } -> std::convertible_to; + model.template wave_speeds(state, lower, upper); + }; + +template +POPS_HD FluxEvaluation reject_face_evaluation(FiniteVolumeStatus status) { + return FluxEvaluation::reject(failure_reason(status)) + .with_single_solver(solver_id()); +} + +template +POPS_HD FluxEvaluation reject_face_evaluation(StateConversionStatus status) { + return reject_face_evaluation(finite_volume_status(status)); +} + +template +POPS_HD Real face_measure(const Metric& metric, const Index& cell, MetricFaceSide side) { + static_assert(Axis >= 0 && Axis < Dim, "ND metric face axis is outside the dimension"); + typename Metric::PhysicalPoint area{}; + if (side == MetricFaceSide::Upper) + area = metric.template oriented_face_area_vector(cell); + else + area = metric.template oriented_face_area_vector(cell); + Real squared = Real(0); + for (int physical_axis = 0; physical_axis < Metric::embedding_dimension; ++physical_axis) + squared += area[physical_axis] * area[physical_axis]; + return Kokkos::sqrt(squared); +} + +template +POPS_HD void accumulate_cfl(const Model& model, const typename Model::State& state, + const auto& metric, const Index& cell, Real inverse_volume, + Real& inverse_dt, FiniteVolumeStatus& status) { + if (status != FiniteVolumeStatus::Success) + return; + const Real lower_area = face_measure(metric, cell, MetricFaceSide::Lower); + const Real upper_area = face_measure(metric, cell, MetricFaceSide::Upper); + const Real speed = model.template max_wave_speed(state); + if (!Kokkos::isfinite(lower_area) || !Kokkos::isfinite(upper_area) || lower_area < Real(0) || + upper_area < Real(0)) { + status = FiniteVolumeStatus::InvalidMetric; + return; + } + if (!Kokkos::isfinite(speed) || speed < Real(0)) { + status = FiniteVolumeStatus::InvalidWaveSpeed; + return; + } + inverse_dt += speed * Real(0.5) * (lower_area + upper_area) * inverse_volume; + if (!Kokkos::isfinite(inverse_dt)) + status = FiniteVolumeStatus::InvalidWaveSpeed; + if constexpr (Axis + 1 < Dim) + accumulate_cfl(model, state, metric, cell, inverse_volume, inverse_dt, status); +} + +template + requires std::same_as, Real> +POPS_HD void accumulate_divergence(const FaceFieldView& faces, const Index& cell, + Real inverse_volume, StateVec& divergence, + FiniteVolumeStatus& status) { + if (status != FiniteVolumeStatus::Success) + return; + if (cell[Axis] == std::numeric_limits::max()) { + status = FiniteVolumeStatus::InvalidFaceField; + return; + } + Index upper = cell; + ++upper[Axis]; + for (int component = 0; component < N; ++component) { + const Real lower_flux = faces.template operator()(cell, component); + const Real upper_flux = faces.template operator()(upper, component); + if (!Kokkos::isfinite(lower_flux) || !Kokkos::isfinite(upper_flux)) { + status = FiniteVolumeStatus::NonFiniteFaceFlux; + return; + } + divergence[component] += (upper_flux - lower_flux) * inverse_volume; + } + if constexpr (Axis + 1 < Dim) + accumulate_divergence(faces, cell, inverse_volume, divergence, status); +} + +template + requires std::same_as, Real> +POPS_HD bool valid_face_field_layout(const FaceFieldView& faces) { + if (faces.ncomp != N || faces.cells.empty()) + return false; + for (int axis = 0; axis < Dim; ++axis) { + const auto& view = faces.axes[axis]; + if (view.data == nullptr || view.ncomp != N || view.origin != faces.cells.lo || + view.component_stride <= 0) + return false; + for (int direction = 0; direction < Dim; ++direction) { + const std::int64_t expected = + faces.cells.length(direction) + (direction == axis ? std::int64_t{1} : std::int64_t{0}); + if (view.extents[direction] != expected || view.strides[direction] <= 0) + return false; + } + } + return true; +} + +} // namespace finite_volume_detail + +template +struct FiniteVolumeResult { + State value{}; + FiniteVolumeStatus status = FiniteVolumeStatus::NonFiniteState; + + POPS_HD bool succeeded() const { return status == FiniteVolumeStatus::Success; } +}; + +struct CellCflResult { + Real inverse_dt = Real(0); + FiniteVolumeStatus status = FiniteVolumeStatus::InvalidWaveSpeed; + + POPS_HD bool succeeded() const { return status == FiniteVolumeStatus::Success; } +}; + +struct TimeStepResult { + Real value = std::numeric_limits::quiet_NaN(); + FiniteVolumeStatus status = FiniteVolumeStatus::InvalidCourantNumber; + + POPS_HD bool succeeded() const { return status == FiniteVolumeStatus::Success; } +}; + +/// Context for the lower or upper geometric face, expressed in the canonical positive logical +/// orientation used by FaceField. ``Side`` selects the metric location; it does not turn a stored +/// positive-axis flux into an outward flux for one particular cell. +template + requires PreparedMetricProvider +POPS_HD FaceContext metric_face_context(const Metric& metric, const Index& cell) { + static_assert(Axis >= 0 && Axis < Dim, "ND metric face axis is outside the dimension"); + return FaceContext::axis_aligned(Axis, + finite_volume_detail::face_measure(metric, cell, Side), + FaceOrientation::kPositive, metric.cell_measure(cell)); +} + +/// Evaluate one face with a compile-time normal axis. The model is checked for admissibility +/// before the selected Riemann policy sees either trace; a failed conversion therefore cannot +/// publish a finite-looking flux or stability bound. +template + requires finite_volume_detail::AxisConservationLaw +POPS_HD FluxEvaluation evaluate_axis_flux( + const Numerical& numerical, const Model& model, const typename Model::State& left, + const typename Model::State& right, Real face_measure = Real(1), Real cell_measure = Real(1)) { + using Physical = finite_volume_detail::AxisPhysicalFlux; + static_assert(NumericalFlux, + "ND face evaluation requires a compatible typed numerical flux"); + constexpr RiemannSolverId solver = finite_volume_detail::solver_id(); + + const StateConversionStatus left_status = model.admissibility(left); + if (left_status != StateConversionStatus::Success) + return finite_volume_detail::reject_face_evaluation( + left_status); + const StateConversionStatus right_status = model.admissibility(right); + if (right_status != StateConversionStatus::Success) + return finite_volume_detail::reject_face_evaluation( + right_status); + if (!Kokkos::isfinite(face_measure) || !Kokkos::isfinite(cell_measure) || + !(face_measure > Real(0)) || !(cell_measure > Real(0))) + return finite_volume_detail::reject_face_evaluation( + FiniteVolumeStatus::InvalidMetric); + + const Physical physical{model}; + const typename Physical::Trace left_trace{left, {}}; + const typename Physical::Trace right_trace{right, {}}; + const FaceContext face = + FaceContext::axis_aligned(Axis, face_measure, FaceOrientation::kPositive, cell_measure); + auto result = numerical(physical, left_trace, right_trace, face); + if (result.requested_solver == RiemannSolverId::kUnspecified) + result = result.with_single_solver(solver); + if (result.succeeded() && !finite_volume_detail::finite_state(result.checked_density().value)) + return finite_volume_detail::reject_face_evaluation( + FiniteVolumeStatus::NonFiniteFaceFlux); + return result; +} + +template + requires(Dim == Model::dimension && PreparedMetricProvider && + finite_volume_detail::AxisConservationLaw) +POPS_HD FluxEvaluation evaluate_metric_face_flux( + const Numerical& numerical, const Model& model, const typename Model::State& left, + const typename Model::State& right, const Metric& metric, const Index& cell) { + if (!metric.identity().domain.contains(cell)) + return finite_volume_detail::reject_face_evaluation( + FiniteVolumeStatus::InvalidMetric); + const FaceContext face = metric_face_context(metric, cell); + return evaluate_axis_flux(numerical, model, left, right, face.face_measure, + face.cell_measure); +} + +/// Conservative divergence of already integrated, positive-axis face fluxes. Geometry enters +/// exactly once through the prepared cell measure; face integration is owned by +/// evaluate_metric_face_flux + apply_face_measure. +template + requires(std::same_as, Real> && PreparedMetricProvider) +POPS_HD FiniteVolumeResult> conservative_residual( + const Metric& metric, const FaceFieldView& integrated_fluxes, const Index& cell) { + FiniteVolumeResult> result{}; + if (!integrated_fluxes.cells.contains(cell) || + !finite_volume_detail::valid_face_field_layout(integrated_fluxes)) { + result.status = FiniteVolumeStatus::InvalidFaceField; + return result; + } + if (!(metric.identity().domain == integrated_fluxes.cells)) { + result.status = FiniteVolumeStatus::InvalidMetric; + return result; + } + const Real volume = metric.cell_measure(cell); + if (!Kokkos::isfinite(volume) || !(volume > Real(0))) { + result.status = FiniteVolumeStatus::InvalidMetric; + return result; + } + result.status = FiniteVolumeStatus::Success; + finite_volume_detail::accumulate_divergence<0>(integrated_fluxes, cell, Real(1) / volume, + result.value, result.status); + if (!result.succeeded()) { + result.value = {}; + return result; + } + for (int component = 0; component < N; ++component) + result.value[component] = -result.value[component]; + return result; +} + +template + requires(Dim == Model::dimension && PreparedMetricProvider && + ConservationLaw) +POPS_HD CellCflResult cell_cfl_bound(const Model& model, const typename Model::State& state, + const Metric& metric, const Index& cell) { + CellCflResult result{}; + if (!metric.identity().domain.contains(cell)) { + result.status = FiniteVolumeStatus::InvalidMetric; + return result; + } + const auto state_status = model.admissibility(state); + if (state_status != StateConversionStatus::Success) { + result.status = finite_volume_detail::finite_volume_status(state_status); + return result; + } + const Real volume = metric.cell_measure(cell); + if (!Kokkos::isfinite(volume) || !(volume > Real(0))) { + result.status = FiniteVolumeStatus::InvalidMetric; + return result; + } + result.status = FiniteVolumeStatus::Success; + finite_volume_detail::accumulate_cfl<0>(model, state, metric, cell, Real(1) / volume, + result.inverse_dt, result.status); + if (!result.succeeded()) + result.inverse_dt = Real(0); + return result; +} + +template + requires(Dim == Model::dimension && PreparedMetricProvider && + ConservationLaw) +POPS_HD TimeStepResult cell_time_step(const Model& model, const typename Model::State& state, + const Metric& metric, const Index& cell, Real courant) { + TimeStepResult result{}; + if (!Kokkos::isfinite(courant) || !(courant > Real(0))) + return result; + const CellCflResult bound = cell_cfl_bound(model, state, metric, cell); + result.status = bound.status; + if (!bound.succeeded()) + return result; + result.value = bound.inverse_dt == Real(0) ? std::numeric_limits::infinity() + : courant / bound.inverse_dt; + return result; +} + +} // namespace pops::nd diff --git a/include/pops/numerics/spatial/nd/state_schema.hpp b/include/pops/numerics/spatial/nd/state_schema.hpp new file mode 100644 index 000000000..45836c902 --- /dev/null +++ b/include/pops/numerics/spatial/nd/state_schema.hpp @@ -0,0 +1,100 @@ +/// @file +/// @brief Compile-time state schemas shared by the 1D, 2D and 3D finite-volume laws. + +#pragma once + +#include + +#include +#include + +namespace pops::nd { + +template +struct ScalarStateSchema { + static_assert(Dim >= 1 && Dim <= 3, "scalar finite-volume states support dimensions 1..3"); + + static constexpr int dimension = Dim; + static constexpr int nvars = 1; + static constexpr int scalar = 0; + using Conservative = StateVec; + using Primitive = StateVec; +}; + +/// Axis-indexed Euler layout used by the ND laws. +/// +/// Conservative components are ``[rho, rho*u_0, ..., rho*u_(Dim-1), E]`` and primitive +/// components are ``[rho, u_0, ..., u_(Dim-1), p]``. Normal and tangent identities are compile +/// time values: a face kernel never performs a run-time permutation of its state schema. +template +struct EulerStateSchema { + static_assert(Dim >= 1 && Dim <= 3, "Euler finite-volume states support dimensions 1..3"); + + static constexpr int dimension = Dim; + static constexpr int nvars = Dim + 2; + static constexpr int density = 0; + static constexpr int energy = Dim + 1; + static constexpr int pressure = Dim + 1; + + using Conservative = StateVec; + using Primitive = StateVec; + + template + static constexpr int momentum = [] { + static_assert(Axis >= 0 && Axis < Dim, "Euler momentum axis is outside the state dimension"); + return Axis + 1; + }(); + + template + static constexpr int velocity = momentum; + + template + static constexpr int tangent_axis = [] { + static_assert(NormalAxis >= 0 && NormalAxis < Dim, + "Euler normal axis is outside the state dimension"); + static_assert(TangentOrdinal >= 0 && TangentOrdinal < Dim - 1, + "Euler tangent ordinal is outside the tangent subspace"); + return TangentOrdinal < NormalAxis ? TangentOrdinal : TangentOrdinal + 1; + }(); + + template + static constexpr int tangent_momentum = momentum>; + + template + static consteval std::array tangent_axes() { + static_assert(NormalAxis >= 0 && NormalAxis < Dim, + "Euler normal axis is outside the state dimension"); + std::array result{}; + int ordinal = 0; + for (int axis = 0; axis < Dim; ++axis) + if (axis != NormalAxis) + result[static_cast(ordinal++)] = axis; + return result; + } +}; + +enum class StateConversionStatus : unsigned char { + Success = 0, + NonFiniteState = 1, + NonPositiveDensity = 2, + NonPositivePressure = 3, + InvalidEquationOfState = 4, +}; + +template +struct StateConversion { + State value{}; + StateConversionStatus status = StateConversionStatus::NonFiniteState; + + POPS_HD constexpr bool succeeded() const { return status == StateConversionStatus::Success; } +}; + +static_assert(ScalarStateSchema<1>::nvars == ScalarStateSchema<3>::nvars); +static_assert(EulerStateSchema<1>::nvars == 3); +static_assert(EulerStateSchema<2>::nvars == 4); +static_assert(EulerStateSchema<3>::nvars == 5); +static_assert(EulerStateSchema<3>::template momentum<2> == 3); +static_assert(EulerStateSchema<3>::template tangent_axis<1, 0> == 0); +static_assert(EulerStateSchema<3>::template tangent_axis<1, 1> == 2); + +} // namespace pops::nd diff --git a/include/pops/numerics/spatial/operators/cartesian_operator.hpp b/include/pops/numerics/spatial/operators/cartesian_operator.hpp index 04d6b09e3..037115c60 100644 --- a/include/pops/numerics/spatial/operators/cartesian_operator.hpp +++ b/include/pops/numerics/spatial/operators/cartesian_operator.hpp @@ -67,38 +67,44 @@ struct AssembleRhsKernel { const Aux Ac = load_aux()>(ax, i, j); // x faces: reconstruction of the states on either side of each face - const auto Lxm = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxm = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lxp = - reconstruct_pp(model, u, i, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxp = - reconstruct_pp(model, u, i + 1, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto Lxm = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxm = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lxp = reconstruct_pp_recovered(model, u, i, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxp = reconstruct_pp_recovered(model, u, i + 1, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); const FaceContext xface = FaceContext::axis_aligned(0); - const auto evaluation_xm = - evaluate_numerical_flux_at(nflux, model, Lxm, ax, i - 1, j, Rxm, ax, i, j, xface); - const auto evaluation_xp = - evaluate_numerical_flux_at(nflux, model, Lxp, ax, i, j, Rxp, ax, i + 1, j, xface); + const auto Lym = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rym = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lyp = reconstruct_pp_recovered(model, u, i, j, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Ryp = reconstruct_pp_recovered(model, u, i, j + 1, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, Lxm, Rxm, Lxp, Rxp, Lym, Rym, Lyp, + Ryp)) { + for (int c = 0; c < Model::n_vars; ++c) + r(i, j, c) = Real(0); + return; + } + const auto evaluation_xm = evaluate_numerical_flux_at(nflux, model, Lxm.value, ax, i - 1, j, + Rxm.value, ax, i, j, xface); + const auto evaluation_xp = evaluate_numerical_flux_at(nflux, model, Lxp.value, ax, i, j, + Rxp.value, ax, i + 1, j, xface); failures.record(evaluation_xm, failure); failures.record(evaluation_xp, failure); const auto Fxm = apply_face_measure(evaluation_xm.checked_density(), xface).value; const auto Fxp = apply_face_measure(evaluation_xp.checked_density(), xface).value; // y faces - const auto Lym = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rym = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lyp = - reconstruct_pp(model, u, i, j, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Ryp = - reconstruct_pp(model, u, i, j + 1, 1, -1, lim, recon_prim, pos_floor, pos_comp); const FaceContext yface = FaceContext::axis_aligned(1); - const auto evaluation_ym = - evaluate_numerical_flux_at(nflux, model, Lym, ax, i, j - 1, Rym, ax, i, j, yface); - const auto evaluation_yp = - evaluate_numerical_flux_at(nflux, model, Lyp, ax, i, j, Ryp, ax, i, j + 1, yface); + const auto evaluation_ym = evaluate_numerical_flux_at(nflux, model, Lym.value, ax, i, j - 1, + Rym.value, ax, i, j, yface); + const auto evaluation_yp = evaluate_numerical_flux_at(nflux, model, Lyp.value, ax, i, j, + Ryp.value, ax, i, j + 1, yface); failures.record(evaluation_ym, failure); failures.record(evaluation_yp, failure); const auto Fym = apply_face_measure(evaluation_ym.checked_density(), yface).value; @@ -170,17 +176,23 @@ struct HllFaceSpeedXKernel { bool recon_prim; Real pos_floor; int pos_comp; + FluxEvaluationRecorder failures; - POPS_HD void operator()(int i, int j) const { - const auto left = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { + const auto left = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + ws(i, j, 0) = Real(0); + ws(i, j, 1) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0); const PhysicalFluxView physical{model}; Real lower, upper; - hll_speeds(physical, make_face_trace_at(left, ax, i - 1, j), - make_face_trace_at(right, ax, i, j), face, lower, upper); + hll_speeds(physical, make_face_trace_at(left.value, ax, i - 1, j), + make_face_trace_at(right.value, ax, i, j), face, lower, upper); ws(i, j, 0) = lower; ws(i, j, 1) = upper; } @@ -197,17 +209,23 @@ struct HllFaceSpeedYKernel { bool recon_prim; Real pos_floor; int pos_comp; + FluxEvaluationRecorder failures; - POPS_HD void operator()(int i, int j) const { - const auto left = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { + const auto left = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + ws(i, j, 2) = Real(0); + ws(i, j, 3) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1); const PhysicalFluxView physical{model}; Real lower, upper; - hll_speeds(physical, make_face_trace_at(left, ax, i, j - 1), - make_face_trace_at(right, ax, i, j), face, lower, upper); + hll_speeds(physical, make_face_trace_at(left.value, ax, i, j - 1), + make_face_trace_at(right.value, ax, i, j), face, lower, upper); ws(i, j, 2) = lower; ws(i, j, 3) = upper; } @@ -216,17 +234,20 @@ struct HllFaceSpeedYKernel { template inline void fill_hll_face_speed_cache(const Model& model, const MultiFab& U, const MultiFab& aux, MultiFab& cache, const Limiter& limiter, bool recon_prim, - Real pos_floor, int pos_comp) { + Real pos_floor, int pos_comp, + FluxEvaluationTracker& failures) { for (int local = 0; local < U.local_size(); ++local) { const ConstArray4 state = U.fab(local).const_array(); const ConstArray4 providers = aux.fab(local).const_array(); Array4 speeds = cache.fab(local).array(); - for_each_cell(xface_box(U.box(local)), - HllFaceSpeedXKernel{model, state, providers, speeds, limiter, - recon_prim, pos_floor, pos_comp}); - for_each_cell(yface_box(U.box(local)), - HllFaceSpeedYKernel{model, state, providers, speeds, limiter, - recon_prim, pos_floor, pos_comp}); + failures.merge(reduce_max_uint64_cell( + xface_box(U.box(local)), + HllFaceSpeedXKernel{model, state, providers, speeds, limiter, recon_prim, + pos_floor, pos_comp, failures.recorder()})); + failures.merge(reduce_max_uint64_cell( + yface_box(U.box(local)), + HllFaceSpeedYKernel{model, state, providers, speeds, limiter, recon_prim, + pos_floor, pos_comp, failures.recorder()})); } } @@ -248,47 +269,53 @@ struct AssembleRhsHllCachedKernel { const Aux Ac = load_aux()>(ax, i, j); // x faces: reconstruction of the states on both sides of each face - const auto Lxm = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxm = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lxp = - reconstruct_pp(model, u, i, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxp = - reconstruct_pp(model, u, i + 1, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto Lxm = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxm = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lxp = reconstruct_pp_recovered(model, u, i, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxp = reconstruct_pp_recovered(model, u, i + 1, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); const Real sLxm = ws(i, j, 0), sRxm = ws(i, j, 1); const Real sLxp = ws(i + 1, j, 0), sRxp = ws(i + 1, j, 1); const FaceContext xface = FaceContext::axis_aligned(0); const PhysicalFluxView physical{model}; + const auto Lym = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rym = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + const auto Lyp = reconstruct_pp_recovered(model, u, i, j, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Ryp = reconstruct_pp_recovered(model, u, i, j + 1, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, Lxm, Rxm, Lxp, Rxp, Lym, Rym, Lyp, + Ryp)) { + for (int c = 0; c < Model::n_vars; ++c) + r(i, j, c) = Real(0); + return; + } const auto evaluation_xm = - hll_flux_with_speeds(physical, make_face_trace_at(Lxm, ax, i - 1, j), - make_face_trace_at(Rxm, ax, i, j), xface, sLxm, sRxm); + hll_flux_with_speeds(physical, make_face_trace_at(Lxm.value, ax, i - 1, j), + make_face_trace_at(Rxm.value, ax, i, j), xface, sLxm, sRxm); const auto evaluation_xp = - hll_flux_with_speeds(physical, make_face_trace_at(Lxp, ax, i, j), - make_face_trace_at(Rxp, ax, i + 1, j), xface, sLxp, sRxp); + hll_flux_with_speeds(physical, make_face_trace_at(Lxp.value, ax, i, j), + make_face_trace_at(Rxp.value, ax, i + 1, j), xface, sLxp, sRxp); failures.record(evaluation_xm, failure); failures.record(evaluation_xp, failure); const auto Fxm = apply_face_measure(evaluation_xm.checked_density(), xface).value; const auto Fxp = apply_face_measure(evaluation_xp.checked_density(), xface).value; // y faces (components 2/3 hold the exact interval of the indexed y-normal face) - const auto Lym = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rym = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto Lyp = - reconstruct_pp(model, u, i, j, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Ryp = - reconstruct_pp(model, u, i, j + 1, 1, -1, lim, recon_prim, pos_floor, pos_comp); const Real sLym = ws(i, j, 2), sRym = ws(i, j, 3); const Real sLyp = ws(i, j + 1, 2), sRyp = ws(i, j + 1, 3); const FaceContext yface = FaceContext::axis_aligned(1); const auto evaluation_ym = - hll_flux_with_speeds(physical, make_face_trace_at(Lym, ax, i, j - 1), - make_face_trace_at(Rym, ax, i, j), yface, sLym, sRym); + hll_flux_with_speeds(physical, make_face_trace_at(Lym.value, ax, i, j - 1), + make_face_trace_at(Rym.value, ax, i, j), yface, sLym, sRym); const auto evaluation_yp = - hll_flux_with_speeds(physical, make_face_trace_at(Lyp, ax, i, j), - make_face_trace_at(Ryp, ax, i, j + 1), yface, sLyp, sRyp); + hll_flux_with_speeds(physical, make_face_trace_at(Lyp.value, ax, i, j), + make_face_trace_at(Ryp.value, ax, i, j + 1), yface, sLyp, sRyp); failures.record(evaluation_ym, failure); failures.record(evaluation_yp, failure); const auto Fym = apply_face_measure(evaluation_ym.checked_density(), yface).value; @@ -326,16 +353,21 @@ struct FaceFluxHllCachedXKernel { FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto left = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto left = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + for (int component = 0; component < Model::n_vars; ++component) + flux(i, j, component) = Real(0); + return; + } const Real speed_left = ws(i, j, 0), speed_right = ws(i, j, 1); const FaceContext face = FaceContext::axis_aligned(0); const PhysicalFluxView physical{model}; const auto evaluation = hll_flux_with_speeds( - physical, make_face_trace_at(left, ax, i - 1, j), - make_face_trace_at(right, ax, i, j), face, speed_left, speed_right); + physical, make_face_trace_at(left.value, ax, i - 1, j), + make_face_trace_at(right.value, ax, i, j), face, speed_left, speed_right); failures.record(evaluation, failure); const auto value = apply_face_measure(evaluation.checked_density(), face).value; for (int component = 0; component < Model::n_vars; ++component) @@ -364,16 +396,21 @@ struct FaceFluxHllCachedYKernel { FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto left = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto right = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto left = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto right = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, left, right)) { + for (int component = 0; component < Model::n_vars; ++component) + flux(i, j, component) = Real(0); + return; + } const Real speed_left = ws(i, j, 2), speed_right = ws(i, j, 3); const FaceContext face = FaceContext::axis_aligned(1); const PhysicalFluxView physical{model}; const auto evaluation = hll_flux_with_speeds( - physical, make_face_trace_at(left, ax, i, j - 1), - make_face_trace_at(right, ax, i, j), face, speed_left, speed_right); + physical, make_face_trace_at(left.value, ax, i, j - 1), + make_face_trace_at(right.value, ax, i, j), face, speed_left, speed_right); failures.record(evaluation, failure); const auto value = apply_face_measure(evaluation.checked_density(), face).value; for (int component = 0; component < Model::n_vars; ++component) @@ -408,8 +445,9 @@ void assemble_rhs_hll_cached(const Model& model, const MultiFab& U, const MultiF const Real dx = geom.dx(), dy = geom.dy(); Limiter lim = configured_reconstruction(weno_eps); const int pos_comp = detail::positivity_comp(pos_floor); - detail::fill_hll_face_speed_cache(model, U, aux, cache, lim, recon_prim, pos_floor, pos_comp); FluxEvaluationTracker failures{process_world_flux_collective}; + detail::fill_hll_face_speed_cache(model, U, aux, cache, lim, recon_prim, pos_floor, pos_comp, + failures); for (int li = 0; li < U.local_size(); ++li) { const ConstArray4 u = U.fab(li).const_array(); const ConstArray4 ax = aux.fab(li).const_array(); @@ -436,8 +474,9 @@ void compute_face_fluxes_hll_cached(const Model& model, const MultiFab& U, const cache = MultiFab(U.box_array(), U.dmap(), 4, 1); Limiter limiter = configured_reconstruction(weno_eps); const int pos_comp = detail::positivity_comp(pos_floor); - detail::fill_hll_face_speed_cache(model, U, aux, cache, limiter, recon_prim, pos_floor, pos_comp); FluxEvaluationTracker failures{process_world_flux_collective}; + detail::fill_hll_face_speed_cache(model, U, aux, cache, limiter, recon_prim, pos_floor, pos_comp, + failures); for (int local = 0; local < U.local_size(); ++local) { const ConstArray4 state = U.fab(local).const_array(); const ConstArray4 providers = aux.fab(local).const_array(); diff --git a/include/pops/numerics/spatial/operators/masked_operator.hpp b/include/pops/numerics/spatial/operators/masked_operator.hpp index 9de6b8213..fba84607b 100644 --- a/include/pops/numerics/spatial/operators/masked_operator.hpp +++ b/include/pops/numerics/spatial/operators/masked_operator.hpp @@ -105,52 +105,68 @@ struct AssembleRhsMaskedKernel { const FaceContext xface = FaceContext::axis_aligned(0); typename Model::State Fxm{}, Fxp{}; if (!omission.omit(0, -1, i, j) && mask_active(mask, i - 1, j)) { - const auto Lxm = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxm = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lxm, ax, i - 1, j, Rxm, ax, i, j, xface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fxm = apply_face_measure(evaluation.checked_density(), xface).value; + const auto Lxm = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxm = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lxm, Rxm)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lxm.value, ax, i - 1, j, + Rxm.value, ax, i, j, xface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fxm = apply_face_measure(evaluation.checked_density(), xface).value; + } else { + evaluations_succeeded = false; + } } if (!omission.omit(0, +1, i, j) && mask_active(mask, i + 1, j)) { - const auto Lxp = - reconstruct_pp(model, u, i, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rxp = - reconstruct_pp(model, u, i + 1, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lxp, ax, i, j, Rxp, ax, i + 1, j, xface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fxp = apply_face_measure(evaluation.checked_density(), xface).value; + const auto Lxp = reconstruct_pp_recovered(model, u, i, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rxp = reconstruct_pp_recovered(model, u, i + 1, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lxp, Rxp)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lxp.value, ax, i, j, + Rxp.value, ax, i + 1, j, xface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fxp = apply_face_measure(evaluation.checked_density(), xface).value; + } else { + evaluations_succeeded = false; + } } // y faces const FaceContext yface = FaceContext::axis_aligned(1); typename Model::State Fym{}, Fyp{}; if (!omission.omit(1, -1, i, j) && mask_active(mask, i, j - 1)) { - const auto Lym = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rym = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lym, ax, i, j - 1, Rym, ax, i, j, yface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fym = apply_face_measure(evaluation.checked_density(), yface).value; + const auto Lym = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rym = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lym, Rym)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lym.value, ax, i, j - 1, + Rym.value, ax, i, j, yface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fym = apply_face_measure(evaluation.checked_density(), yface).value; + } else { + evaluations_succeeded = false; + } } if (!omission.omit(1, +1, i, j) && mask_active(mask, i, j + 1)) { - const auto Lyp = - reconstruct_pp(model, u, i, j, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Ryp = - reconstruct_pp(model, u, i, j + 1, 1, -1, lim, recon_prim, pos_floor, pos_comp); - const auto evaluation = - evaluate_numerical_flux_at(nflux, model, Lyp, ax, i, j, Ryp, ax, i, j + 1, yface); - failures.record(evaluation, failure); - evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); - Fyp = apply_face_measure(evaluation.checked_density(), yface).value; + const auto Lyp = reconstruct_pp_recovered(model, u, i, j, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Ryp = reconstruct_pp_recovered(model, u, i, j + 1, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (record_reconstruction_recoveries(failures, failure, Lyp, Ryp)) { + const auto evaluation = evaluate_numerical_flux_at(nflux, model, Lyp.value, ax, i, j, + Ryp.value, ax, i, j + 1, yface); + failures.record(evaluation, failure); + evaluations_succeeded = evaluations_succeeded && evaluation.succeeded(); + Fyp = apply_face_measure(evaluation.checked_density(), yface).value; + } else { + evaluations_succeeded = false; + } } const auto S = model.source(load_state(u, i, j), Ac); diff --git a/include/pops/numerics/spatial/operators/polar_operator.hpp b/include/pops/numerics/spatial/operators/polar_operator.hpp index 9d60f9192..4c7f990fc 100644 --- a/include/pops/numerics/spatial/operators/polar_operator.hpp +++ b/include/pops/numerics/spatial/operators/polar_operator.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -106,8 +107,8 @@ POPS_HD inline typename Model::State polar_geom_source(const Model& m, /// PolarFaceFluxRKernel: device kernel of the flux at the radial face i (weighting by r_face(i)). /// /// Stores r_face(i) * Fr at the face between i-1 and i, so the discrete divergence is a simple -/// difference (cf. formula in @file). If wall_radial == true, forces the flux to zero at the -/// physical boundary faces (no-penetration wall, mass conservation to machine precision). +/// difference (cf. formula in @file). The two radial closure bits are derived by the host caller +/// from one PreparedBoundaryPlan and force only its authored NoFlux faces to zero. /// Named functor, device-clean cross-TU. POPS_HD. template struct PolarFaceFluxRKernel { @@ -119,21 +120,15 @@ struct PolarFaceFluxRKernel { Limiter lim; NumericalFlux nflux; bool recon_prim; - // Optional RADIAL WALL (no-penetration). wall_radial == false (default): no effect, boundary flux - // computed like the interior (BIT-IDENTICAL to the history: MMS, azimuthal conservation). true: - // the radial flux at BOTH physical boundary faces (i = i_lo_face = lo, i = i_hi_face = hi+1) is - // forced to ZERO -> the radial term telescopes EXACTLY (each interior face is shared, the - // boundaries no longer count) -> mass Sum n r dr dtheta conserved to machine precision, whatever - // v_r (solid wall). - bool wall_radial; - int i_lo_face, - i_hi_face; // FACE indices of physical boundaries (lo and hi+1); ignored if !wall_radial + bool close_low_radial_flux; + bool close_high_radial_flux; + int i_lo_face, i_hi_face; Real pos_floor = Real(0); ///< Zhang-Shu positivity limiter (<= 0: inactive, bit-identical) int pos_comp = 0; ///< component of the Density role (resolved by the host caller) FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { const Real rf = r_min + (Real(i) - Real(radial_index_origin)) * dr; - if (wall_radial && (i == i_lo_face || i == i_hi_face)) { + if ((close_low_radial_flux && i == i_lo_face) || (close_high_radial_flux && i == i_hi_face)) { for (int c = 0; c < Model::n_vars; ++c) fr(i, j, c) = Real(0); // wall: zero radial flux return; @@ -141,13 +136,18 @@ struct PolarFaceFluxRKernel { // Reconstructed states on either side of the radial face i (REUSES Cartesian reconstruct_pp<>, // dir == 0). L = extrapolation from cell i-1 toward its + face; R = from cell i toward its // - face. - const auto L = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + fr(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0, rf); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i - 1, j, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i - 1, j, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { for (int c = 0; c < Model::n_vars; ++c) @@ -180,13 +180,18 @@ struct PolarFaceFluxThetaKernel { int pos_comp = 0; ///< component of the Density role (resolved by the host caller) FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto L = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + if (!record_reconstruction_recoveries(failures, failure, L, Rr)) { + for (int c = 0; c < Model::n_vars; ++c) + ft(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i, j - 1, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i, j - 1, Rr.value, ax, i, j, face); failures.record(evaluation, failure); if (!evaluation.succeeded()) { for (int c = 0; c < Model::n_vars; ++c) @@ -250,15 +255,14 @@ struct PolarAssembleRhsKernel { /// BOUNDARY CONDITIONS: theta PERIODIC (the caller fills the azimuthal ghosts via periodic /// fill_boundary). r PHYSICAL: the caller fills the radial ghosts (wall / outflow). The radial /// fluxes at the r_min (i = lo) and r_max (i = hi+1) faces are computed from the ghost states (free -/// outflow), EXCEPT if @p wall_radial == true: then the radial flux at both physical boundary faces -/// is forced to ZERO (SOLID no-penetration WALL), which makes the mass Sum n r dr dtheta conserved -/// TO MACHINE precision whatever v_r (the radial term telescopes exactly). @p wall_radial == false -/// (default) reproduces EXACTLY the history (MMS, azimuthal conservation -- cf. -/// test_polar_transport_mms). +/// outflow), except on faces whose immutable PreparedBoundaryPlan law is NoFlux. Those already +/// evaluated numerical fluxes are forced to zero before divergence, so the radial term telescopes +/// exactly while outflow faces retain their ordinary Riemann flux. template void assemble_rhs_polar(const Model& model, const MultiFab& U, const MultiFab& aux, - const PolarGeometry& geom, MultiFab& R, bool recon_prim = false, - bool wall_radial = false, Real pos_floor = Real(0)) { + const PolarGeometry& geom, MultiFab& R, + const PreparedBoundaryPlan& boundary_plan, bool recon_prim = false, + Real pos_floor = Real(0)) { // STATE-GHOST WIDTH: exactly Limiter::n_ghost, like the Cartesian operator. The polar face kernels // (PolarFaceFluxRKernel / PolarFaceFluxThetaKernel) reuse reconstruct_pp<> VERBATIM at the SAME // i-1/i (radial) and j-1/j (azimuthal) offsets over the SAME face boxes (xface_box/yface_box, up @@ -267,6 +271,19 @@ void assemble_rhs_polar(const Model& model, const MultiFab& U, const MultiFab& a // INDICES, never read from U, so it adds NO state-ghost width; aux is read at i+-1 only (1 ghost, // narrower). HOST-only guard, BEFORE the pass-1/pass-2 loops -- never inside a kernel. detail::require_reconstruction_ghosts(U); // state ghosts >= stencil (otherwise OOB) + if (boundary_plan.ncomp() != U.ncomp()) + throw std::invalid_argument( + "polar boundary plan component count differs from the transport state"); + if (boundary_plan.has_component_boundaries() || boundary_plan.has_omitted_faces()) + throw std::invalid_argument( + "polar transport does not yet support native boundary components or shared-interface " + "face omission"); + const auto periodicity = boundary_plan.axis_aligned_periodicity(); + if (!periodicity || periodicity->x || !periodicity->y) + throw std::invalid_argument( + "polar transport requires non-periodic radial and periodic azimuthal prepared faces"); + const bool close_low_radial_flux = boundary_plan.zeroes_face(0, -1); + const bool close_high_radial_flux = boundary_plan.zeroes_face(0, 1); const int pos_comp = detail::positivity_comp(pos_floor); const Real r_min = geom.r_min, dr = geom.dr(), dtheta = geom.dtheta(); // Physical radial boundary faces (wall): r_min at the lo face of the index domain, r_max at the @@ -297,10 +314,10 @@ void assemble_rhs_polar(const Model& model, const MultiFab& U, const MultiFab& a const Box2D v = R.box(li); // Radial faces: i in [lo..hi+1], j in [lo..hi] (cf. xface_box). failures.merge(reduce_max_uint64_cell( - xface_box(v), - detail::PolarFaceFluxRKernel{ - model, u, ax, fr, r_min, dr, geom.domain.lo[0], lim, nflux, recon_prim, wall_radial, - i_lo_face, i_hi_face, pos_floor, pos_comp, failures.recorder()})); + xface_box(v), detail::PolarFaceFluxRKernel{ + model, u, ax, fr, r_min, dr, geom.domain.lo[0], lim, nflux, recon_prim, + close_low_radial_flux, close_high_radial_flux, i_lo_face, i_hi_face, + pos_floor, pos_comp, failures.recorder()})); // Azimuthal faces: i in [lo..hi], j in [lo..hi+1] (cf. yface_box). failures.merge(reduce_max_uint64_cell( yface_box(v), diff --git a/include/pops/numerics/spatial/operators/prepared_cartesian_nd.hpp b/include/pops/numerics/spatial/operators/prepared_cartesian_nd.hpp new file mode 100644 index 000000000..575f87791 --- /dev/null +++ b/include/pops/numerics/spatial/operators/prepared_cartesian_nd.hpp @@ -0,0 +1,240 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/// @file +/// @brief Prepared, periodic Cartesian finite-volume residual for compile-time dimensions 1..3. +/// +/// This is the dimension-generic local spatial-provider kernel. It deliberately does not claim +/// that the current Box2D/MultiFab/AMR runtime can carry a 1D or 3D hierarchy: callers provide one +/// contiguous cell-major patch. Metric preparation, reconstruction, typed Riemann evaluation and +/// conservative divergence are shared for every dimension. Periodic indexing makes conservation +/// an executable kernel property without introducing a second physical-boundary authority. + +namespace pops { + +template +struct PreparedCartesianMetric { + static_assert(Dimension >= 1 && Dimension <= 3, + "PreparedCartesianMetric supports compile-time dimensions 1..3"); + + std::array extents{}; + std::array spacing{}; + std::array face_measure{}; + Real cell_measure = Real(0); + std::size_t cells = 0; +}; + +namespace detail { + +template +consteval int periodic_reconstruction_minimum_extent() { + if constexpr (CellValueReconstruction) { + return 1; + } else if constexpr (SlopeReconstruction) { + return 3; + } else { + return Reconstruction::stencil_max_offset - Reconstruction::stencil_min_offset + 1; + } +} + +template +PreparedCartesianMetric prepare_cartesian_metric( + const std::array& extents, const std::array& lower, + const std::array& upper, int minimum_extent) { + PreparedCartesianMetric metric; + metric.extents = extents; + metric.cells = 1; + metric.cell_measure = Real(1); + for (int axis = 0; axis < Dimension; ++axis) { + if (extents[axis] < minimum_extent) + throw std::invalid_argument( + "prepared Cartesian residual extent is smaller than its reconstruction stencil"); + if (!std::isfinite(lower[axis]) || !std::isfinite(upper[axis]) || !(upper[axis] > lower[axis])) + throw std::invalid_argument( + "prepared Cartesian residual requires finite strictly ordered metric bounds"); + metric.spacing[axis] = (upper[axis] - lower[axis]) / static_cast(extents[axis]); + metric.cell_measure *= metric.spacing[axis]; + metric.cells *= static_cast(extents[axis]); + } + for (int axis = 0; axis < Dimension; ++axis) + metric.face_measure[axis] = metric.cell_measure / metric.spacing[axis]; + return metric; +} + +template +std::array cartesian_index(std::size_t linear, + const std::array& extents) { + std::array index{}; + for (std::size_t axis = 0; axis < Dimension; ++axis) { + index[axis] = static_cast(linear % static_cast(extents[axis])); + linear /= static_cast(extents[axis]); + } + return index; +} + +template +std::size_t cartesian_linear(const std::array& index, + const std::array& extents) { + std::size_t linear = 0; + std::size_t stride = 1; + for (std::size_t axis = 0; axis < Dimension; ++axis) { + linear += static_cast(index[axis]) * stride; + stride *= static_cast(extents[axis]); + } + return linear; +} + +inline int periodic_coordinate(int coordinate, int extent) { + const int wrapped = coordinate % extent; + return wrapped < 0 ? wrapped + extent : wrapped; +} + +template +typename Model::State load_periodic_state(std::span state, + const std::array& extents, + std::array index, int axis = 0, + int offset = 0) { + index[axis] = periodic_coordinate(index[axis] + offset, extents[axis]); + const std::size_t cell = cartesian_linear(index, extents); + typename Model::State value{}; + for (int component = 0; component < Model::n_vars; ++component) + value[component] = + state[cell * static_cast(Model::n_vars) + static_cast(component)]; + return value; +} + +template +typename Model::State reconstruct_periodic_state(std::span state, + const std::array& extents, + const std::array& index, int axis, + int orientation, + const Reconstruction& reconstruction) { + typename Model::State result = load_periodic_state(state, extents, index); + for (int component = 0; component < Model::n_vars; ++component) { + const auto sample = [&](int offset) { + return load_periodic_state(state, extents, index, axis, offset)[component]; + }; + const Real center = sample(0); + if constexpr (CellValueReconstruction) { + result[component] = reconstruction.cell_face_value(center); + } else if constexpr (SlopeReconstruction) { + result[component] = + center + static_cast(orientation) * Real(0.5) * + reconstruction.limited_slope(center - sample(-1), sample(1) - center); + } else if constexpr (StencilReconstruction) { + const auto oriented_sample = [&](int offset) { return sample(orientation * offset); }; + result[component] = reconstruction.stencil_face_value(oriented_sample); + } + } + return result; +} + +} // namespace detail + +template +class PreparedPeriodicCartesianResidual { + public: + static_assert(Dimension >= 1 && Dimension <= 3, + "PreparedPeriodicCartesianResidual supports dimensions 1..3"); + static_assert(ReconstructionPolicy, + "PreparedPeriodicCartesianResidual requires one typed reconstruction policy"); + + using State = typename Model::State; + + PreparedPeriodicCartesianResidual(const std::array& extents, + const std::array& lower, + const std::array& upper, Model model, + Reconstruction reconstruction = {}, + NumericalFluxPolicy numerical_flux = {}, + FluxProviderValues constant_providers = {}) + : metric_(detail::prepare_cartesian_metric( + extents, lower, upper, + detail::periodic_reconstruction_minimum_extent())), + model_(std::move(model)), + reconstruction_(std::move(reconstruction)), + numerical_flux_(std::move(numerical_flux)), + constant_providers_(constant_providers) {} + + [[nodiscard]] static constexpr SpatialProviderCapabilities capabilities() { + return make_cartesian_spatial_provider(Dimension); + } + + [[nodiscard]] const PreparedCartesianMetric& metric() const noexcept { + return metric_; + } + + [[nodiscard]] std::size_t scalar_count() const noexcept { + return metric_.cells * static_cast(Model::n_vars); + } + + /// Evaluate a conservative periodic residual. State and residual may not alias. A Riemann + /// refusal clears the candidate residual before throwing; the accepted state is never mutated. + void execute(std::span state, std::span residual) const { + if (state.size() != scalar_count() || residual.size() != scalar_count()) + throw std::invalid_argument( + "prepared Cartesian residual buffers do not match extents x model components"); + if (state.data() == residual.data()) + throw std::invalid_argument( + "prepared Cartesian residual requires distinct immutable state and output buffers"); + + std::fill(residual.begin(), residual.end(), Real(0)); + const auto providers = bind_flux_providers(constant_providers_); + for (std::size_t linear = 0; linear < metric_.cells; ++linear) { + const auto index = detail::cartesian_index(linear, metric_.extents); + for (int axis = 0; axis < Dimension; ++axis) { + auto previous = index; + auto next = index; + previous[axis] = detail::periodic_coordinate(previous[axis] - 1, metric_.extents[axis]); + next[axis] = detail::periodic_coordinate(next[axis] + 1, metric_.extents[axis]); + + const State minus_left = detail::reconstruct_periodic_state( + state, metric_.extents, previous, axis, +1, reconstruction_); + const State minus_right = detail::reconstruct_periodic_state( + state, metric_.extents, index, axis, -1, reconstruction_); + const State plus_left = detail::reconstruct_periodic_state( + state, metric_.extents, index, axis, +1, reconstruction_); + const State plus_right = detail::reconstruct_periodic_state( + state, metric_.extents, next, axis, -1, reconstruction_); + const FaceContext face = FaceContext::axis_aligned( + axis, metric_.face_measure[axis], FaceOrientation::kPositive, metric_.cell_measure); + const auto minus = evaluate_numerical_flux(numerical_flux_, model_, minus_left, providers, + minus_right, providers, face); + const auto plus = evaluate_numerical_flux(numerical_flux_, model_, plus_left, providers, + plus_right, providers, face); + if (!minus.succeeded() || !plus.succeeded()) { + std::fill(residual.begin(), residual.end(), Real(0)); + throw std::runtime_error("prepared Cartesian residual numerical flux refused a face"); + } + const State minus_integrated = apply_face_measure(minus.checked_density(), face).value; + const State plus_integrated = apply_face_measure(plus.checked_density(), face).value; + for (int component = 0; component < Model::n_vars; ++component) + residual[linear * static_cast(Model::n_vars) + + static_cast(component)] -= + (plus_integrated[component] - minus_integrated[component]) / metric_.cell_measure; + } + } + } + + private: + PreparedCartesianMetric metric_; + Model model_; + Reconstruction reconstruction_; + NumericalFluxPolicy numerical_flux_; + FluxProviderValues constant_providers_{}; +}; + +} // namespace pops diff --git a/include/pops/numerics/spatial/primitives/face_flux.hpp b/include/pops/numerics/spatial/primitives/face_flux.hpp index 60ebb652a..087677534 100644 --- a/include/pops/numerics/spatial/primitives/face_flux.hpp +++ b/include/pops/numerics/spatial/primitives/face_flux.hpp @@ -22,9 +22,11 @@ #include #include #include +#include #include #include +#include #include // require_reconstruction_ghosts: state without the stencil width -> clear error namespace pops { @@ -75,6 +77,77 @@ struct CachedPrimitiveComponentSampler { } // namespace detail +/// Typed result of one face-state reconstruction. +/// +/// `value` is consumable only when `recovery.publication_permitted()` is true. On a recovery +/// refusal it contains the conservative source-cell average solely so a device kernel can keep its +/// scratch finite while the report travels through the transport reduction. Production kernels +/// must consume the report before evaluating a numerical flux. +template +struct ReconstructedFaceState { + typename Model::State value{}; + RecoveryReport recovery{}; + + POPS_HD bool publication_permitted() const { return recovery.publication_permitted(); } +}; + +template +struct RecoveredFacePrimitive { + typename Model::Prim value{}; + RecoveryReport recovery{}; +}; + +template +POPS_HD inline bool record_reconstruction_recoveries(const FluxEvaluationRecorder& failures, + std::uint64_t& failure, + const Reconstructed&... reconstructed) { + (failures.record_recovery(reconstructed.recovery, failure), ...); + return (reconstructed.publication_permitted() && ...); +} + +template +POPS_HD inline ReconstructedFaceState recovered_face_state( + const typename Model::State& value) { + RecoveryReport report; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return {value, report}; +} + +template +POPS_HD inline typename Model::State value_only_face_state( + const ReconstructedFaceState& reconstructed) { + if (reconstructed.publication_permitted()) + return reconstructed.value; + typename Model::State invalid{}; + for (int component = 0; component < Model::n_vars; ++component) + invalid[component] = std::numeric_limits::quiet_NaN(); + return invalid; +} + +template +POPS_HD inline auto recover_face_primitive(const Model& model, + const typename Model::State& conservative) { + constexpr int N = Model::n_vars; + Real conserved[N] = {}; + Real initial_guess[N] = {}; + for (int component = 0; component < N; ++component) + conserved[component] = initial_guess[component] = conservative[component]; + + // This compatibility plan is a fixed-size aggregate. Construction and execution are both + // device-inline, allocation-free and callback-free. Model-specific prepared chains can replace + // this plan without changing the reconstruction protocol. + const auto plan = prepare_model_variable_recovery(model); + const RecoveryOutcome outcome = recover_prepared_variable(plan, conserved, initial_guess); + + RecoveredFacePrimitive result; + result.recovery = recovery_report(outcome); + if (outcome.publication_permitted()) + for (int component = 0; component < N; ++component) + result.value[component] = outcome.value[component]; + return result; +} + /// reconstruct: face value at (i,j) extrapolated in direction dir. /// /// sgn = +1 -> +dir face of (i,j); sgn = -1 -> -dir face. Reconstructs in PRIMITIVE @@ -84,9 +157,10 @@ struct CachedPrimitiveComponentSampler { /// n_ghost is used only to validate the storage envelope. /// INVARIANT: POINTWISE function, does NOT loop over the grid. POPS_HD. template -POPS_HD inline typename Model::State reconstruct(const Model& model, const ConstArray4& u, int i, - int j, int dir, Real sgn, const Limiter& lim, - bool prim) { +POPS_HD inline ReconstructedFaceState reconstruct_recovered(const Model& model, + const ConstArray4& u, int i, + int j, int dir, Real sgn, + const Limiter& lim, bool prim) { static_assert( ReconstructionPolicy, "a reconstruction policy must declare positive formal_order/n_ghost metadata and implement " @@ -98,13 +172,21 @@ POPS_HD inline typename Model::State reconstruct(const Model& model, const Const using Prim = typename Model::Prim; Prim Pf{}; if constexpr (SlopeReconstruction) { - const Prim P0 = model.to_primitive(load_state(u, i, j)); - const Prim Pm = - model.to_primitive(load_state(u, dir == 0 ? i - 1 : i, dir == 0 ? j : j - 1)); - const Prim Pp = - model.to_primitive(load_state(u, dir == 0 ? i + 1 : i, dir == 0 ? j : j + 1)); + const auto P0 = recover_face_primitive(model, load_state(u, i, j)); + if (!P0.recovery.publication_permitted()) + return {load_state(u, i, j), P0.recovery}; + const auto Pm = recover_face_primitive( + model, load_state(u, dir == 0 ? i - 1 : i, dir == 0 ? j : j - 1)); + if (!Pm.recovery.publication_permitted()) + return {load_state(u, i, j), Pm.recovery}; + const auto Pp = recover_face_primitive( + model, load_state(u, dir == 0 ? i + 1 : i, dir == 0 ? j : j + 1)); + if (!Pp.recovery.publication_permitted()) + return {load_state(u, i, j), Pp.recovery}; for (int c = 0; c < Model::n_vars; ++c) - Pf[c] = P0[c] + sgn * Real(0.5) * lim.limited_slope(P0[c] - Pm[c], Pp[c] - P0[c]); + Pf[c] = P0.value[c] + + sgn * Real(0.5) * + lim.limited_slope(P0.value[c] - Pm.value[c], Pp.value[c] - P0.value[c]); } else if constexpr (StencilReconstruction) { const int orientation = (sgn > Real(0)) ? 1 : -1; detail::PrimitiveStencilCache cache{}; @@ -113,14 +195,17 @@ POPS_HD inline typename Model::State reconstruct(const Model& model, const Const const int displacement = orientation * offset; const auto state = load_state(u, dir == 0 ? i + displacement : i, dir == 0 ? j : j + displacement); - cache.at(offset) = model.to_primitive(state); + const auto primitive = recover_face_primitive(model, state); + if (!primitive.recovery.publication_permitted()) + return {load_state(u, i, j), primitive.recovery}; + cache.at(offset) = primitive.value; } for (int c = 0; c < Model::n_vars; ++c) { const detail::CachedPrimitiveComponentSampler sample{&cache, c}; Pf[c] = lim.stencil_face_value(sample); } } - return model.to_conservative(Pf); + return recovered_face_state(model.to_conservative(Pf)); } } (void)model; @@ -145,20 +230,41 @@ POPS_HD inline typename Model::State reconstruct(const Model& model, const Const s[c] = lim.stencil_face_value(sample); } } - return s; + return recovered_face_state(s); +} + +/// Compatibility value-only entry point. Production spatial kernels use +/// reconstruct_recovered() and consume its RecoveryReport before any flux evaluation. This +/// wrapper preserves the low-level API for callers that only need conservative reconstruction and +/// returns an explicit non-finite sentinel if a primitive recovery is refused; it never exposes the +/// finite transactional scratch as a valid candidate. +template +POPS_HD inline typename Model::State reconstruct(const Model& model, const ConstArray4& u, int i, + int j, int dir, Real sgn, const Limiter& lim, + bool prim) { + return value_only_face_state(reconstruct_recovered(model, u, i, j, dir, sgn, lim, prim)); } /// reconstruct_pp: reconstruct + zhang_shu_scale positivity limiter on the returned state. /// /// (i, j) is the SOURCE cell of the reconstruction: it is to ITS average that the face state is /// brought back. pos_floor <= 0 -> strictly identical to reconstruct (short-circuit). POPS_HD. +template +POPS_HD inline ReconstructedFaceState reconstruct_pp_recovered( + const Model& model, const ConstArray4& u, int i, int j, int dir, Real sgn, const Limiter& lim, + bool prim, Real pos_floor, int pos_comp) { + auto reconstructed = reconstruct_recovered(model, u, i, j, dir, sgn, lim, prim); + if (reconstructed.publication_permitted()) + zhang_shu_scale(reconstructed.value, u, i, j, pos_floor, pos_comp); + return reconstructed; +} + template POPS_HD inline typename Model::State reconstruct_pp(const Model& model, const ConstArray4& u, int i, int j, int dir, Real sgn, const Limiter& lim, bool prim, Real pos_floor, int pos_comp) { - typename Model::State s = reconstruct(model, u, i, j, dir, sgn, lim, prim); - zhang_shu_scale(s, u, i, j, pos_floor, pos_comp); - return s; + return value_only_face_state( + reconstruct_pp_recovered(model, u, i, j, dir, sgn, lim, prim, pos_floor, pos_comp)); } namespace detail { @@ -218,13 +324,20 @@ struct FaceFluxXKernel { int pos_comp = 0; ///< component of the Density role (resolved by the host caller) FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto L = - reconstruct_pp(model, u, i - 1, j, 0, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 0, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i - 1, j, 0, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 0, -1, lim, recon_prim, + pos_floor, pos_comp); + failures.record_recovery(L.recovery, failure); + failures.record_recovery(Rr.recovery, failure); + if (!L.publication_permitted() || !Rr.publication_permitted()) { + for (int c = 0; c < Model::n_vars; ++c) + fx(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(0); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i - 1, j, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i - 1, j, Rr.value, ax, i, j, face); failures.record(evaluation, failure); const auto F = apply_face_measure(evaluation.checked_density(), face).value; for (int c = 0; c < Model::n_vars; ++c) @@ -255,13 +368,20 @@ struct FaceFluxYKernel { int pos_comp = 0; ///< component of the Density role (resolved by the host caller) FluxEvaluationRecorder failures; POPS_HD void operator()(int i, int j, std::uint64_t& failure) const { - const auto L = - reconstruct_pp(model, u, i, j - 1, 1, +1, lim, recon_prim, pos_floor, pos_comp); - const auto Rr = - reconstruct_pp(model, u, i, j, 1, -1, lim, recon_prim, pos_floor, pos_comp); + const auto L = reconstruct_pp_recovered(model, u, i, j - 1, 1, +1, lim, recon_prim, + pos_floor, pos_comp); + const auto Rr = reconstruct_pp_recovered(model, u, i, j, 1, -1, lim, recon_prim, + pos_floor, pos_comp); + failures.record_recovery(L.recovery, failure); + failures.record_recovery(Rr.recovery, failure); + if (!L.publication_permitted() || !Rr.publication_permitted()) { + for (int c = 0; c < Model::n_vars; ++c) + fy(i, j, c) = Real(0); + return; + } const FaceContext face = FaceContext::axis_aligned(1); const auto evaluation = - evaluate_numerical_flux_at(nflux, model, L, ax, i, j - 1, Rr, ax, i, j, face); + evaluate_numerical_flux_at(nflux, model, L.value, ax, i, j - 1, Rr.value, ax, i, j, face); failures.record(evaluation, failure); const auto F = apply_face_measure(evaluation.checked_density(), face).value; for (int c = 0; c < Model::n_vars; ++c) diff --git a/include/pops/numerics/spatial/primitives/state_access.hpp b/include/pops/numerics/spatial/primitives/state_access.hpp index 7530ea03b..ec13204c4 100644 --- a/include/pops/numerics/spatial/primitives/state_access.hpp +++ b/include/pops/numerics/spatial/primitives/state_access.hpp @@ -53,9 +53,13 @@ struct SourceFreeModel { static constexpr int n_vars = M::n_vars; static constexpr int n_aux = aux_comps(); // transparent to the wrapped model's aux width M m; - POPS_HD State flux(const State& u, const Aux& a, int dir) const { return m.flux(u, a, dir); } - POPS_HD Real max_wave_speed(const State& u, const Aux& a, int dir) const { - return m.max_wave_speed(u, a, dir); + template + POPS_HD State flux(const State& u, const Providers& providers, int dir) const { + return m.flux(u, providers, dir); + } + template + POPS_HD Real max_wave_speed(const State& u, const Providers& providers, int dir) const { + return m.max_wave_speed(u, providers, dir); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State& u) const { return m.elliptic_rhs(u); } @@ -69,12 +73,14 @@ struct SourceFreeModel { { return m.pressure(u); } - POPS_HD void wave_speeds(const State& u, const Aux& a, int dir, Real& smin, Real& smax) const - requires requires(const M& mm, const State& s, const Aux& aa, int d, Real& lo, Real& hi) { - mm.wave_speeds(s, aa, d, lo, hi); + template + POPS_HD void wave_speeds(const State& u, const Providers& providers, int dir, Real& smin, + Real& smax) const + requires requires(const M& mm, const State& s, const Providers& p, int d, Real& lo, Real& hi) { + mm.wave_speeds(s, p, d, lo, hi); } { - m.wave_speeds(u, a, dir, smin, smax); + m.wave_speeds(u, providers, dir, smin, smax); } // Roe / HLLC CAPABILITIES (HasRoeDissipation / HasHLLCStructure): forwarded ONLY if M exposes // them (requires clause), exactly like pressure / wave_speeds above and like composite.hpp. @@ -93,12 +99,14 @@ struct SourceFreeModel { { return m.hllc_star_state(u, p, s, sStar, dir); } - POPS_HD State roe_dissipation(const State& ul, const Aux& al, const State& ur, const Aux& ar, + template + POPS_HD State roe_dissipation(const State& ul, const LeftProviders& left_providers, + const State& ur, const RightProviders& right_providers, int dir) const - requires requires(const M& mm, const State a_, const Aux x_, const State b_, const Aux y_, - int d) { mm.roe_dissipation(a_, x_, b_, y_, d); } + requires requires(const M& mm, const State a_, const LeftProviders& x_, const State b_, + const RightProviders& y_, int d) { mm.roe_dissipation(a_, x_, b_, y_, d); } { - return m.roe_dissipation(ul, al, ur, ar, dir); + return m.roe_dissipation(ul, left_providers, ur, right_providers, dir); } // Forward the VariableSet introspection (HOST): lets positivity_comp resolve the Density role // through the explicit IMEX half-step. Conditional (requires), like pressure / wave_speeds. diff --git a/include/pops/numerics/spatial/provider_matrix.hpp b/include/pops/numerics/spatial/provider_matrix.hpp new file mode 100644 index 000000000..e2c11f614 --- /dev/null +++ b/include/pops/numerics/spatial/provider_matrix.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include +#include +#include + +/// @file +/// @brief Exact compile-time/runtime qualification matrix for native spatial providers. +/// +/// A reusable numerical kernel may be dimension-generic while a concrete runtime remains 2D. +/// Likewise, a block may own an embedded-boundary residual without owning a metric-aware +/// characteristic ghost producer or boundary linearization. This small value type records those +/// facts independently; callers must qualify the complete request and may never infer one +/// capability from another non-empty closure. + +namespace pops { + +enum class SpatialProviderGeometry : std::uint8_t { + Cartesian = 0, + Staircase = 1, + CutCell = 2, + Polar = 3, +}; + +enum class SpatialProviderOperation : std::uint8_t { + Residual = 0, + CharacteristicNoInflow = 1, + BoundaryLinearization = 2, +}; + +enum class SpatialProviderRefusal : std::uint8_t { + None = 0, + UnsupportedDimension = 1, + UnsupportedGeometry = 2, + UnsupportedOperation = 3, +}; + +constexpr std::size_t spatial_geometry_index(SpatialProviderGeometry geometry) { + return static_cast(geometry); +} + +constexpr std::uint8_t spatial_operation_flag(SpatialProviderOperation operation) { + return static_cast(1U << static_cast(operation)); +} + +constexpr bool valid_spatial_dimension(int dimension) { + return dimension >= 1 && dimension <= 3; +} + +constexpr std::size_t spatial_dimension_index(int dimension) { + return static_cast(dimension - 1); +} + +struct SpatialProviderRequest { + int dimension = 0; + SpatialProviderGeometry geometry = SpatialProviderGeometry::Cartesian; + SpatialProviderOperation operation = SpatialProviderOperation::Residual; +}; + +struct SpatialProviderCapabilities { + static constexpr std::size_t dimension_count = 3; + static constexpr std::size_t geometry_count = 4; + + std::array, dimension_count> operations{}; + + constexpr void enable(int dimension, SpatialProviderGeometry geometry, + SpatialProviderOperation operation) { + if (!valid_spatial_dimension(dimension)) + return; + auto& cell = operations[spatial_dimension_index(dimension)][spatial_geometry_index(geometry)]; + cell = static_cast(cell | spatial_operation_flag(operation)); + } + + [[nodiscard]] constexpr bool supports_dimension(int dimension) const { + if (!valid_spatial_dimension(dimension)) + return false; + for (const std::uint8_t cell : operations[spatial_dimension_index(dimension)]) + if (cell != 0) + return true; + return false; + } + + [[nodiscard]] constexpr bool supports_geometry(int dimension, + SpatialProviderGeometry geometry) const { + return valid_spatial_dimension(dimension) && + operations[spatial_dimension_index(dimension)][spatial_geometry_index(geometry)] != 0; + } + + [[nodiscard]] constexpr bool supports(const SpatialProviderRequest& request) const { + return valid_spatial_dimension(request.dimension) && + (operations[spatial_dimension_index(request.dimension)] + [spatial_geometry_index(request.geometry)] & + spatial_operation_flag(request.operation)) != 0; + } +}; + +struct SpatialProviderQualification { + bool executable = false; + SpatialProviderRefusal refusal = SpatialProviderRefusal::UnsupportedDimension; +}; + +[[nodiscard]] constexpr SpatialProviderQualification qualify_spatial_provider( + const SpatialProviderCapabilities& capabilities, const SpatialProviderRequest& request) { + if (!capabilities.supports_dimension(request.dimension)) + return {false, SpatialProviderRefusal::UnsupportedDimension}; + if (!capabilities.supports_geometry(request.dimension, request.geometry)) + return {false, SpatialProviderRefusal::UnsupportedGeometry}; + if (!capabilities.supports(request)) + return {false, SpatialProviderRefusal::UnsupportedOperation}; + return {true, SpatialProviderRefusal::None}; +} + +[[nodiscard]] constexpr SpatialProviderCapabilities make_cartesian_spatial_provider( + int dimension, bool characteristic_no_inflow = false, bool boundary_linearization = false) { + SpatialProviderCapabilities capabilities; + capabilities.enable(dimension, SpatialProviderGeometry::Cartesian, + SpatialProviderOperation::Residual); + if (characteristic_no_inflow) + capabilities.enable(dimension, SpatialProviderGeometry::Cartesian, + SpatialProviderOperation::CharacteristicNoInflow); + if (boundary_linearization) + capabilities.enable(dimension, SpatialProviderGeometry::Cartesian, + SpatialProviderOperation::BoundaryLinearization); + return capabilities; +} + +[[nodiscard]] constexpr SpatialProviderCapabilities with_embedded_boundary_residuals( + SpatialProviderCapabilities capabilities) { + for (int dimension = 1; dimension <= 3; ++dimension) { + if (!capabilities.supports( + {dimension, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual})) + continue; + capabilities.enable(dimension, SpatialProviderGeometry::Staircase, + SpatialProviderOperation::Residual); + capabilities.enable(dimension, SpatialProviderGeometry::CutCell, + SpatialProviderOperation::Residual); + } + return capabilities; +} + +[[nodiscard]] constexpr SpatialProviderCapabilities make_polar_spatial_provider(int dimension) { + SpatialProviderCapabilities capabilities; + capabilities.enable(dimension, SpatialProviderGeometry::Polar, + SpatialProviderOperation::Residual); + return capabilities; +} + +} // namespace pops diff --git a/include/pops/numerics/spatial_operator.hpp b/include/pops/numerics/spatial_operator.hpp index f695ad02a..7bc898868 100644 --- a/include/pops/numerics/spatial_operator.hpp +++ b/include/pops/numerics/spatial_operator.hpp @@ -12,7 +12,7 @@ /// Modules (one-way dependency DAG, bottom to top): /// - spatial/state_access.hpp DiffusiveModel, SourceFreeModel, load_state, load_aux. /// - spatial/positivity.hpp zhang_shu_scale, detail::positivity_comp. -/// - spatial/face_flux.hpp reconstruct, reconstruct_pp, require_reconstruction_ghosts, +/// - spatial/face_flux.hpp typed/fallible face reconstruction, positivity, /// xface_box / yface_box, compute_face_fluxes. /// - spatial/wave_speed.hpp max_wave_speed_mf and step-bound reductions, the hotspot /// diagnostic. diff --git a/include/pops/numerics/time/amr/levels/amr_patch_range.hpp b/include/pops/numerics/time/amr/levels/amr_patch_range.hpp index 02a52a46b..e285a442b 100644 --- a/include/pops/numerics/time/amr/levels/amr_patch_range.hpp +++ b/include/pops/numerics/time/amr/levels/amr_patch_range.hpp @@ -468,6 +468,23 @@ struct FluxRegister { device_fence(); all_reduce_sum_inplace(buf.data(), buf.size(), communicator); } + /// Sum the already-gathered sparse correction by conservative component. + /// + /// RefluxStorage is pinned host storage shared with device kernels. The fence makes the gathered + /// register host-readable; every communicator rank then traverses the same compact global order, + /// so this adds no second collective and produces the exact state increment applied below. + [[nodiscard]] std::vector component_sums(Real cell_measure) const { + if (!std::isfinite(static_cast(cell_measure)) || cell_measure <= Real(0)) + throw std::invalid_argument( + "FluxRegister component sum requires a finite positive cell measure"); + device_fence(); + std::vector result(static_cast(nc), Real(0)); + const std::size_t components = static_cast(nc); + for (std::size_t offset = 0; offset < buf.size(); offset += components) + for (std::size_t component = 0; component < components; ++component) + result[component] += cell_measure * buf[offset + component]; + return result; + } [[nodiscard]] std::size_t lookup_capacity() const noexcept { return cell_lookup.capacity(); } [[nodiscard]] std::size_t covered_cell_count() const noexcept { return cell_lookup.size(); } @@ -593,6 +610,29 @@ struct RefluxStripConstView { int components = 0; }; +/// Four already-computed, signed coarse-cell corrections around one fine-patch footprint. +/// +/// A native Reflux component may fill these contiguous face buffers, but it never receives the +/// sparse global register, coverage mask, periodicity or MPI communicator. PoPS alone maps the +/// values onto canonical uncovered parent cells. +struct RefluxFaceCorrectionView { + int I0 = 0, I1 = -1, J0 = 0, J1 = -1; + Real* x_low = nullptr; + Real* x_high = nullptr; + Real* y_low = nullptr; + Real* y_high = nullptr; + int components = 0; +}; + +struct RefluxFaceCorrectionConstView { + int I0 = 0, I1 = -1, J0 = 0, J1 = -1; + const Real* x_low = nullptr; + const Real* x_high = nullptr; + const Real* y_low = nullptr; + const Real* y_high = nullptr; + int components = 0; +}; + template inline RefluxStripView reflux_strip_view(Strip& strip, int components) { return {strip.I0, strip.I1, strip.J0, strip.J1, strip.cL.data(), @@ -821,6 +861,64 @@ struct RouteRefluxStripKernel { } }; +/// Deposit one external/local Reflux result into PoPS' sparse correction authority. The provider +/// has already applied side*(fine-coarse)/spacing; this kernel owns only topology canonicalisation, +/// coverage exclusion and deterministic face order. +struct RoutePreparedRefluxCorrectionKernel { + RefluxFaceCorrectionConstView faces; + FluxRegisterView correction; + CoverageMaskView coverage; + Box2D coarse_domain; + Periodicity periodicity; + + POPS_HD static int wrap_index(int value, int lo, int extent) { + const std::int64_t relative = static_cast(value) - lo; + std::int64_t quotient = relative / extent; + if (relative % extent < 0) + --quotient; + return static_cast(static_cast(lo) + relative - quotient * extent); + } + + POPS_HD bool canonicalize(int& I, int& J) const { + if (I < coarse_domain.lo[0] || I > coarse_domain.hi[0]) { + if (!periodicity.x) + return false; + I = wrap_index(I, coarse_domain.lo[0], coarse_domain.nx()); + } + if (J < coarse_domain.lo[1] || J > coarse_domain.hi[1]) { + if (!periodicity.y) + return false; + J = wrap_index(J, coarse_domain.lo[1], coarse_domain.ny()); + } + return true; + } + + POPS_HD void add_if_uncovered(int I, int J, int component, Real amount) const { + if (!canonicalize(I, J) || coverage.covered(I, J)) + return; + correction.add(I, J, component, amount); + } + + POPS_HD void operator()(int, int) const { + for (int J = faces.J0; J <= faces.J1; ++J) + for (int component = 0; component < faces.components; ++component) { + const std::size_t index = + static_cast(J - faces.J0) * static_cast(faces.components) + + static_cast(component); + add_if_uncovered(faces.I0 - 1, J, component, faces.x_low[index]); + add_if_uncovered(faces.I1 + 1, J, component, faces.x_high[index]); + } + for (int I = faces.I0; I <= faces.I1; ++I) + for (int component = 0; component < faces.components; ++component) { + const std::size_t index = + static_cast(I - faces.I0) * static_cast(faces.components) + + static_cast(component); + add_if_uncovered(I, faces.J0 - 1, component, faces.y_low[index]); + add_if_uncovered(I, faces.J1 + 1, component, faces.y_high[index]); + } + } +}; + } // namespace detail inline void sample_coarse_x_strip(const ConstArray4& left, const ConstArray4& right, @@ -1087,6 +1185,17 @@ struct CoarseFineInterface { Real(1) / dx, Real(1) / dy, Real(1)}); } + void route_prepared_reflux_correction_(const RefluxFaceCorrectionConstView& faces, + FluxRegister& ref, int nc) const { + if (nc <= 0 || ref.nc != nc || faces.components != nc || faces.I1 < faces.I0 || + faces.J1 < faces.J0 || faces.x_low == nullptr || faces.x_high == nullptr || + faces.y_low == nullptr || faces.y_high == nullptr) + throw std::invalid_argument("prepared Reflux correction view is incomplete"); + for_each_cell(Box2D{{0, 0}, {0, 0}}, + detail::RoutePreparedRefluxCorrectionKernel{faces, ref.view(), cmask.view(), + coarse_region, periodicity}); + } + template static void validate_route_inputs_(const Reg& coarse, const Reg& fine, Real dx, Real dy, Real coarse_scale, const FluxRegister& ref, int nc, diff --git a/include/pops/numerics/time/amr/levels/amr_subcycling.hpp b/include/pops/numerics/time/amr/levels/amr_subcycling.hpp index 12292338a..b1130d99b 100644 --- a/include/pops/numerics/time/amr/levels/amr_subcycling.hpp +++ b/include/pops/numerics/time/amr/levels/amr_subcycling.hpp @@ -2,11 +2,12 @@ #include #include #include // coarsen, parallel_copy -#include +#include #include #include #include +#include #include #include #include @@ -49,88 +50,6 @@ inline Box2D amr_level_index_domain(Box2D base_domain, int level) { return base_domain; } -struct AmrBoundaryFillContext { - Box2D domain; - int level = 0; - Real dx = Real(1); - Real dy = Real(1); -}; - -using AmrPhysicalBoundaryFill = std::function; - -/// Exact host-side authority for physical AMR ghosts. Same-level and periodic exchange remain -/// native runtime responsibilities; this callback owns only faces where periodicity is false. -/// A bounded external provider certifies provided_depth; a provider whose algorithm explicitly -/// handles arbitrary allocated depth certifies fills_all_allocated_ghosts instead. Neither value -/// is inferred from a BC enum or a reconstruction name. -struct AmrBoundaryFillAuthority { - Periodicity periodicity{}; - int provided_depth = 0; - bool fills_all_allocated_ghosts = false; - AmrPhysicalBoundaryFill fill_physical{}; -}; - -inline AmrBoundaryFillAuthority make_amr_boundary_fill_authority(const BCRec& boundary) { - detail::validate_periodic_pairs(boundary); - BCRec prepared = boundary; - return AmrBoundaryFillAuthority{ - Periodicity{boundary.xlo == BCType::Periodic, boundary.ylo == BCType::Periodic}, 0, true, - [prepared](MultiFab& state, const AmrBoundaryFillContext& context) mutable { - prepared.dx = context.dx; - prepared.dy = context.dy; - fill_physical_bc(state, context.domain, prepared); - }}; -} - -inline void validate_amr_boundary_fill_authority(Periodicity periodicity, - const AmrBoundaryFillAuthority* authority) { - const bool has_physical_face = !periodicity.x || !periodicity.y; - if (authority == nullptr) { - if (has_physical_face) - throw std::runtime_error( - "non-periodic AMR advance requires an explicit physical boundary-fill authority"); - return; - } - if (!same_periodicity(periodicity, authority->periodicity)) - throw std::runtime_error( - "AMR boundary-fill authority periodicity disagrees with the hierarchy"); - if (authority->provided_depth < 0 || (has_physical_face && !authority->fill_physical)) - throw std::runtime_error("AMR boundary-fill authority is incomplete"); -} - -template -inline void validate_amr_boundary_fill_authority(Periodicity periodicity, - const AmrBoundaryFillAuthority* authority, - const Levels& levels) { - validate_amr_boundary_fill_authority(periodicity, authority); - if (authority == nullptr) - return; - for (const auto& level : levels) - if (!authority->fills_all_allocated_ghosts && authority->provided_depth < level.U.n_grow()) - throw std::runtime_error("AMR boundary-fill authority does not cover all state ghosts"); -} - -inline void fill_amr_same_level_and_physical(MultiFab& state, const Box2D& domain, int level, - Real dx, Real dy, Periodicity periodicity, - const AmrBoundaryFillAuthority* authority) { - fill_boundary(state, domain, periodicity); - if ((!periodicity.x || !periodicity.y) && authority != nullptr) { - std::string local_error; - try { - authority->fill_physical(state, AmrBoundaryFillContext{domain, level, dx, dy}); - } catch (const std::exception& error) { - local_error = error.what(); - } catch (...) { - local_error = "physical boundary callback raised a non-standard exception"; - } - if (all_reduce_max(local_error.empty() ? 0L : 1L) != 0) { - if (n_ranks() == 1) - throw std::runtime_error(local_error); - throw std::runtime_error("physical AMR boundary callback failed on at least one MPI rank"); - } - } -} - // --- MULTI-PATCH (several fine boxes per level) --- // The fine level is a MultiFab with N boxes. Reflux is COVERAGE-AWARE: it corrects a coarse // cell adjacent to a fine box only if it is NOT covered by another fine box (real fine-coarse @@ -796,6 +715,90 @@ inline void clear_reflux_storage_on_device(RefluxStorage& values) { } // namespace detail +/// Persistent, patch-local output storage for one external Reflux invocation. It is allocated with +/// the topology plan, poisoned before every callback and consumed by PoPS only after all entries are +/// finite. Non-owning ABI views never outlive this workspace. +struct PreparedAmrRefluxFaceWorkspace { + int I0 = 0, I1 = -1, J0 = 0, J1 = -1; + int components = 0; + RefluxStorage x_low; + RefluxStorage x_high; + RefluxStorage y_low; + RefluxStorage y_high; + std::string patch_identity; + std::array interface_identities; + + static PreparedAmrRefluxFaceWorkspace prepare(const Box2D& footprint, int ncomp, + std::string transition_identity, + std::size_t global_child) { + if (footprint.empty() || ncomp <= 0 || transition_identity.empty()) + throw std::invalid_argument("prepared external Reflux workspace is incomplete"); + const auto checked_size = [ncomp](std::int64_t extent) { + const std::size_t components = static_cast(ncomp); + if (extent <= 0 || + static_cast(extent) > std::numeric_limits::max() / components) + throw std::overflow_error("prepared external Reflux face size overflow"); + return static_cast(extent) * components; + }; + PreparedAmrRefluxFaceWorkspace result; + result.I0 = footprint.lo[0]; + result.I1 = footprint.hi[0]; + result.J0 = footprint.lo[1]; + result.J1 = footprint.hi[1]; + result.components = ncomp; + result.x_low.resize(checked_size(footprint.ny())); + result.x_high.resize(result.x_low.size()); + result.y_low.resize(checked_size(footprint.nx())); + result.y_high.resize(result.y_low.size()); + result.patch_identity = transition_identity + "/patch=" + std::to_string(global_child); + result.interface_identities = { + result.patch_identity + "/x-low", result.patch_identity + "/x-high", + result.patch_identity + "/y-low", result.patch_identity + "/y-high"}; + return result; + } + + void poison() { + const Real sentinel = std::numeric_limits::quiet_NaN(); + for (auto* values : {&x_low, &x_high, &y_low, &y_high}) + std::fill(values->begin(), values->end(), sentinel); + } + + [[nodiscard]] bool all_finite() const { + for (const auto* values : {&x_low, &x_high, &y_low, &y_high}) + if (std::any_of(values->begin(), values->end(), + [](Real value) { return !std::isfinite(static_cast(value)); })) + return false; + return true; + } + + [[nodiscard]] RefluxFaceCorrectionView view() { + return {I0, I1, J0, J1, x_low.data(), x_high.data(), y_low.data(), y_high.data(), components}; + } + + [[nodiscard]] RefluxFaceCorrectionConstView view() const { + return {I0, I1, J0, J1, x_low.data(), x_high.data(), y_low.data(), y_high.data(), components}; + } +}; + +/// Complete local/noncollective invocation data. Flux strips are already integrated in time and +/// averaged onto coarse faces; the callback may only fill `correction`. +struct PreparedAmrRefluxLocalRequest { + const std::string* transition_identity = nullptr; + const std::string* patch_identity = nullptr; + std::array interface_identities{}; + int parent_level = -1; + int child_level = -1; + std::size_t global_child = 0; + RefluxStripConstView coarse; + RefluxStripConstView fine; + RefluxFaceCorrectionView correction; + amr::ClockStamp logical_time; + Real dx = Real(0); + Real dy = Real(0); +}; + +using PreparedAmrRefluxLocalKernel = std::function; + /// Prepared spatial reflux storage for one exact Program-owned parent/child transition. It owns /// only the interface topology and collective correction register; ProgramGraph supplies the /// already time-integrated coarse/fine flux strips. @@ -812,19 +815,43 @@ class PreparedAmrProgramRefluxTransition { const Box2D& parent_domain, Periodicity periodicity, const CommunicatorView& communicator) { + return prepare_with_local_kernel(parent, child, parent_domain, periodicity, 0, + "pops://runtime/amr/program-reflux/parent=0/child=1", {}, + communicator); + } + + static PreparedAmrProgramRefluxTransition prepare_with_local_kernel( + const AmrLevelMP& parent, const AmrLevelMP& child, const Box2D& parent_domain, + Periodicity periodicity, int parent_level, std::string transition_identity, + PreparedAmrRefluxLocalKernel local_kernel, const CommunicatorView& communicator) { if (parent.U.ncomp() != child.U.ncomp()) throw std::invalid_argument("prepared AMR Program reflux transition component mismatch"); + if (parent_level < 0 || transition_identity.empty()) + throw std::invalid_argument("prepared AMR Program reflux transition identity is incomplete"); validate_ratio_aligned_disjoint_fine_layout(child.U.box_array(), &parent_domain); CoarseFineInterface interface(parent_domain, child.U.box_array(), periodicity); std::vector correction_regions = interface.reflux_register_regions(child.U.box_array()); - return PreparedAmrProgramRefluxTransition(parent, child, communicator, std::move(interface), - std::move(correction_regions)); + std::vector local_workspaces( + static_cast(child.U.box_array().size())); + if (local_kernel) + for (int global_child = 0; global_child < child.U.box_array().size(); ++global_child) + if (child.U.dmap()[global_child] == communicator.rank()) + local_workspaces[static_cast(global_child)] = + PreparedAmrRefluxFaceWorkspace::prepare( + PatchRange(child.U.box_array()[global_child]).box(), parent.U.ncomp(), + transition_identity, static_cast(global_child)); + return PreparedAmrProgramRefluxTransition(parent, child, communicator, parent_level, + std::move(transition_identity), + std::move(local_kernel), std::move(local_workspaces), + std::move(interface), std::move(correction_regions)); } template void synchronize_integrated(MultiFab& parent_state, Real dx, Real dy, const CoarseStripRange& coarse_role, const FineStripRange& fine_role, - const CommunicatorView& communicator) { + const CommunicatorView& communicator, + const amr::ClockStamp* logical_time = nullptr, + std::vector* integrated_state_correction = nullptr) { validate_communicator_(communicator); using CoarseStrip = typename CoarseStripRange::value_type; using FineStrip = typename FineStripRange::value_type; @@ -836,6 +863,11 @@ class PreparedAmrProgramRefluxTransition { // enter the correction Allreduce while its peer unwinds. std::exception_ptr local_failure; try { + if (local_kernel_ && + (logical_time == nullptr || logical_time->level != parent_level_ || + logical_time->macro_step < 0 || !std::isfinite(logical_time->physical_time))) + throw std::invalid_argument( + "prepared external Reflux requires the exact parent logical time"); validate_parent_state_(parent_state); if (coarse_role.size() != child_global_size_ || fine_role.size() != child_global_size_) throw std::runtime_error( @@ -863,15 +895,69 @@ class PreparedAmrProgramRefluxTransition { } catch (...) { local_failure = std::current_exception(); } - const std::uint64_t rejected = - all_reduce_max(local_failure ? std::uint64_t(1) : std::uint64_t(0), communicator); - if (rejected != 0) { + // Presence, rank-local preflight failure and the later execution branch are decided by one + // collective bitmask. A rank can therefore never enter the builtin gather while a peer invokes + // an external callback. + constexpr char kExternalSelected = char{1}; + constexpr char kBuiltinSelected = char{2}; + constexpr char kPreflightFailed = char{4}; + char preflight_consensus = local_kernel_ ? kExternalSelected : kBuiltinSelected; + if (local_failure) + preflight_consensus |= kPreflightFailed; + all_reduce_or_inplace(&preflight_consensus, std::size_t{1}, communicator); + const bool provider_mismatch = (preflight_consensus & kExternalSelected) != 0 && + (preflight_consensus & kBuiltinSelected) != 0; + if ((preflight_consensus & kPreflightFailed) != 0 || provider_mismatch) { if (local_failure) std::rethrow_exception(local_failure); - throw std::runtime_error("AMR Program reflux preflight failed on another communicator rank"); + throw std::runtime_error(provider_mismatch + ? "prepared Reflux provider differs between communicator ranks" + : "AMR Program reflux preflight failed on another " + "communicator rank"); } + const bool use_external = (preflight_consensus & kExternalSelected) != 0; - try { + if (use_external) { + std::exception_ptr local_failure; + try { + correction_.clear_on_device(); + device_fence(); + for (std::size_t global_child = 0; global_child < child_global_size_; ++global_child) { + const CoarseStrip& coarse = coarse_role[global_child]; + const FineStrip& fine = fine_role[global_child]; + if (!coarse_role_present_(coarse)) + continue; + PreparedAmrRefluxFaceWorkspace& workspace = local_workspaces_[global_child]; + workspace.poison(); + std::array interface_identities; + for (std::size_t face = 0; face < interface_identities.size(); ++face) + interface_identities[face] = &workspace.interface_identities[face]; + local_kernel_(PreparedAmrRefluxLocalRequest{ + &transition_identity_, &workspace.patch_identity, interface_identities, parent_level_, + parent_level_ + 1, global_child, reflux_strip_const_view(coarse, ncomp_), + reflux_strip_const_view(fine, ncomp_), workspace.view(), *logical_time, dx, dy}); + if (!workspace.all_finite()) + throw std::runtime_error( + "native Reflux component left a non-finite or unwritten correction"); + const PreparedAmrRefluxFaceWorkspace& completed = workspace; + interface_.route_prepared_reflux_correction_(completed.view(), correction_, ncomp_); + } + device_fence(); + } catch (...) { + local_failure = std::current_exception(); + try { + device_fence(); + } catch (...) { + } + } + const std::uint64_t rejected = + all_reduce_max(local_failure ? std::uint64_t(1) : std::uint64_t(0), communicator); + if (rejected != 0) { + if (local_failure) + std::rethrow_exception(local_failure); + throw std::runtime_error("native Reflux component failed on another communicator rank"); + } + } else { correction_.clear_on_device(); for (std::size_t global_child = 0; global_child < child_global_size_; ++global_child) { const CoarseStrip& coarse = coarse_role[global_child]; @@ -881,7 +967,11 @@ class PreparedAmrProgramRefluxTransition { interface_.route_reflux_integrated_pair_prevalidated_(coarse, fine, dx, dy, correction_, ncomp_); } + } + try { correction_.gather(communicator); + if (integrated_state_correction != nullptr) + *integrated_state_correction = correction_.component_sums(dx * dy); for (int local_parent = 0; local_parent < parent_state.local_size(); ++local_parent) for_each_cell(parent_state.box(local_parent), detail::ApplyRefluxRegisterKernel{parent_state.fab(local_parent).array(), @@ -900,7 +990,10 @@ class PreparedAmrProgramRefluxTransition { private: PreparedAmrProgramRefluxTransition(const AmrLevelMP& parent, const AmrLevelMP& child, - const CommunicatorView& communicator, + const CommunicatorView& communicator, int parent_level, + std::string transition_identity, + PreparedAmrRefluxLocalKernel local_kernel, + std::vector local_workspaces, CoarseFineInterface interface, std::vector correction_regions) : parent_boxes_(parent.U.box_array().boxes()), @@ -913,6 +1006,10 @@ class PreparedAmrProgramRefluxTransition { communicator_size_(communicator.size()), communicator_rank_(communicator.rank()), communicator_identity_(detail::parallel_copy_communicator_identity(communicator)), + parent_level_(parent_level), + transition_identity_(std::move(transition_identity)), + local_kernel_(std::move(local_kernel)), + local_workspaces_(std::move(local_workspaces)), interface_(std::move(interface)), correction_(std::move(correction_regions), ncomp_) { if (child_footprints_.size() != child_global_size_ || child_ranks_.size() != child_global_size_) @@ -922,6 +1019,10 @@ class PreparedAmrProgramRefluxTransition { if (owner < 0 || owner >= communicator_size_) throw std::invalid_argument( "prepared AMR Program reflux child owner lies outside the communicator"); + if (parent_level_ < 0 || transition_identity_.empty() || + local_workspaces_.size() != child_global_size_) + throw std::invalid_argument( + "prepared AMR Program reflux local-provider metadata is inconsistent"); } static std::vector make_child_footprints_(const BoxArray& child_boxes) { @@ -972,6 +1073,10 @@ class PreparedAmrProgramRefluxTransition { int communicator_size_ = 1; int communicator_rank_ = 0; std::int64_t communicator_identity_ = 0; + int parent_level_ = 0; + std::string transition_identity_; + PreparedAmrRefluxLocalKernel local_kernel_; + std::vector local_workspaces_; CoarseFineInterface interface_; FluxRegister correction_; }; @@ -988,16 +1093,23 @@ class PreparedAmrProgramRefluxPlan { static PreparedAmrProgramRefluxPlan prepare( const std::vector& levels, const Box2D& base_domain, Periodicity periodicity, std::uint64_t topology_generation, - const CommunicatorView& communicator = world_communicator_view()) { + const CommunicatorView& communicator = world_communicator_view(), + PreparedAmrRefluxLocalKernel local_kernel = {}, std::string block_identity = {}) { if (levels.empty() || base_domain.empty()) throw std::invalid_argument("prepared AMR Program reflux requires a non-empty hierarchy"); + if (local_kernel && block_identity.empty()) + throw std::invalid_argument("prepared external Reflux requires one qualified block identity"); std::vector transitions; transitions.reserve(levels.size() - 1); - for (std::size_t parent = 0; parent + 1 < levels.size(); ++parent) - transitions.push_back(PreparedAmrProgramRefluxTransition::prepare( + for (std::size_t parent = 0; parent + 1 < levels.size(); ++parent) { + const std::string transition_identity = + (block_identity.empty() ? "pops://runtime/amr/program-reflux" : block_identity) + + "/parent=" + std::to_string(parent) + "/child=" + std::to_string(parent + 1); + transitions.push_back(PreparedAmrProgramRefluxTransition::prepare_with_local_kernel( levels[parent], levels[parent + 1], amr_level_index_domain(base_domain, static_cast(parent)), periodicity, - communicator)); + static_cast(parent), transition_identity, local_kernel, communicator)); + } return PreparedAmrProgramRefluxPlan(static_cast(levels.size()), topology_generation, std::move(transitions)); } diff --git a/include/pops/numerics/time/integrators/implicit_stepper.hpp b/include/pops/numerics/time/integrators/implicit_stepper.hpp index 51f8a57cb..915530e86 100644 --- a/include/pops/numerics/time/integrators/implicit_stepper.hpp +++ b/include/pops/numerics/time/integrators/implicit_stepper.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -363,7 +364,7 @@ struct PreparedImplicitSourceKernel { statistics(i, j, 6) = solved.condition_evidence; statistics(i, j, 7) = static_cast(solved.safeguard_steps); if (!solved.solved()) { - statistics(i, j, 8) = encode_local_nonlinear_failure(i, j, solved.failing_component); + statistics(i, j, 8) = static_cast(solved.failing_component); statistics(i, j, 9) = Real(1); statistics(i, j, 10) = static_cast((solved.reason_code >> 16) & 0xffffu); statistics(i, j, 11) = static_cast(solved.reason_code & 0xffffu); @@ -391,23 +392,13 @@ struct LocalStatSum { POPS_HD void operator()(int i, int j, Real& result) const { result += values(i, j, component); } }; -struct LocalStatMaxForStatus { - ConstArray4 values; - int status = 0; - int component = 0; - POPS_HD void operator()(int i, int j, Real& result) const { - if (static_cast(values(i, j, 0)) == status) - if (const Real value = values(i, j, component); value > result) - result = value; - } -}; - struct LocalStatReasonHighForLocation { ConstArray4 values; int status = 0; - Real location = Real(0); + int selected_i = 0; + int selected_j = 0; POPS_HD void operator()(int i, int j, Real& result) const { - if (static_cast(values(i, j, 0)) == status && values(i, j, 8) == location) + if (i == selected_i && j == selected_j && static_cast(values(i, j, 0)) == status) if (const Real value = values(i, j, 10); value > result) result = value; } @@ -416,10 +407,11 @@ struct LocalStatReasonHighForLocation { struct LocalStatReasonLowForLocation { ConstArray4 values; int status = 0; - Real location = Real(0); + int selected_i = 0; + int selected_j = 0; int reason_high = 0; POPS_HD void operator()(int i, int j, Real& result) const { - if (static_cast(values(i, j, 0)) == status && values(i, j, 8) == location && + if (i == selected_i && j == selected_j && static_cast(values(i, j, 0)) == status && static_cast(values(i, j, 10)) == reason_high) if (const Real value = values(i, j, 11); value > result) result = value; @@ -445,35 +437,27 @@ inline double collective_sum_component(const MultiFab& statistics, int component return all_reduce_sum(static_cast(local)); } -inline Real collective_max_for_status(const MultiFab& statistics, int status, int component) { +inline Real collective_reason_high(const MultiFab& statistics, int status, int selected_i, + int selected_j) { Real local = Real(0); for (int local_index = 0; local_index < statistics.local_size(); ++local_index) { const ConstArray4 values = statistics.fab(local_index).const_array(); local = std::max(local, reduce_max_cell(statistics.box(local_index), - LocalStatMaxForStatus{values, status, component})); + LocalStatReasonHighForLocation{ + values, status, selected_i, selected_j})); } return static_cast(all_reduce_max(static_cast(local))); } -inline Real collective_reason_high(const MultiFab& statistics, int status, Real location) { +inline Real collective_reason_low(const MultiFab& statistics, int status, int selected_i, + int selected_j, int reason_high) { Real local = Real(0); for (int local_index = 0; local_index < statistics.local_size(); ++local_index) { const ConstArray4 values = statistics.fab(local_index).const_array(); local = std::max(local, reduce_max_cell(statistics.box(local_index), - LocalStatReasonHighForLocation{values, status, location})); - } - return static_cast(all_reduce_max(static_cast(local))); -} - -inline Real collective_reason_low(const MultiFab& statistics, int status, Real location, - int reason_high) { - Real local = Real(0); - for (int local_index = 0; local_index < statistics.local_size(); ++local_index) { - const ConstArray4 values = statistics.fab(local_index).const_array(); - local = std::max(local, reduce_max_cell(statistics.box(local_index), - LocalStatReasonLowForLocation{values, status, location, - reason_high})); + LocalStatReasonLowForLocation{values, status, selected_i, + selected_j, reason_high})); } return static_cast(all_reduce_max(static_cast(local))); } @@ -599,12 +583,17 @@ template int failed_component = -1; std::uint32_t reason_code = 0; if (failed_cells > 0) { - const Real encoded = detail::collective_max_for_status(statistics, status_code, 8); - detail::decode_local_nonlinear_failure(encoded, failed_i, failed_j, failed_component); - const int reason_high = - static_cast(detail::collective_reason_high(statistics, status_code, encoded)); + const LocalNonlinearFailureLocation location = + collective_first_local_nonlinear_failure(statistics, status_priority, 12, 8); + if (!location.found || location.priority != status_priority) + throw std::runtime_error("implicit source collective status/location precedence mismatch"); + failed_i = location.i; + failed_j = location.j; + failed_component = location.component; + const int reason_high = static_cast( + detail::collective_reason_high(statistics, status_code, failed_i, failed_j)); const int reason_low = static_cast( - detail::collective_reason_low(statistics, status_code, encoded, reason_high)); + detail::collective_reason_low(statistics, status_code, failed_i, failed_j, reason_high)); reason_code = (static_cast(reason_high) << 16) | static_cast(reason_low); } diff --git a/include/pops/parallel/comm.hpp b/include/pops/parallel/comm.hpp index 02b6e8e06..66d009dbb 100644 --- a/include/pops/parallel/comm.hpp +++ b/include/pops/parallel/comm.hpp @@ -96,6 +96,17 @@ inline void require_mpi_success(int code, std::string_view operation) { throw_mpi_error(code, operation); } +inline int chunk_capacity(int ranks) { + const int divisor = std::max(1, ranks); + return std::max(1, std::numeric_limits::max() / divisor); +} + +inline const char* chunk_pointer(const std::string& payload, unsigned long long offset, int count) { + if (count == 0) + return nullptr; + return payload.data() + static_cast(offset); +} + inline bool comm_active_unlocked() noexcept { int initialized = 0; int finalized = 0; diff --git a/include/pops/parallel/execution_lane.hpp b/include/pops/parallel/execution_lane.hpp index b811d904e..7d3fc634f 100644 --- a/include/pops/parallel/execution_lane.hpp +++ b/include/pops/parallel/execution_lane.hpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -115,6 +116,34 @@ class ExecutionCommunicator { class ExecutionLane { public: + /// Non-copyable stable-address pin for a borrower that retains a lane pointer. A lane with an + /// active pin cannot be moved or destroyed: moving would invalidate the borrowed object address + /// even though its communicator remains valid. + class ImmutableBorrow { + public: + ImmutableBorrow() = delete; + ImmutableBorrow(const ImmutableBorrow&) = delete; + ImmutableBorrow& operator=(const ImmutableBorrow&) = delete; + + ImmutableBorrow(ImmutableBorrow&& other) noexcept + : lane_(std::exchange(other.lane_, nullptr)) {} + ImmutableBorrow& operator=(ImmutableBorrow&&) = delete; + + ~ImmutableBorrow() { + if (lane_ != nullptr) + lane_->release_immutable_borrow_(); + } + + private: + friend class ExecutionLane; + + explicit ImmutableBorrow(const ExecutionLane& lane) noexcept : lane_(&lane) { + lane_->acquire_immutable_borrow_(); + } + + const ExecutionLane* lane_ = nullptr; + }; + /// Collective-lifetime object. Owning lanes must be materialized and destroyed in the same /// canonical order on every parent rank. PoPS runtime owners keep them in deterministic object /// graphs and convert every post-duplication construction failure into a uniform collective @@ -124,6 +153,7 @@ class ExecutionLane { /// the same values are safe in concurrent lanes without a process-global tag allocator. static constexpr int halo_message_tag = 0; static constexpr int parallel_copy_message_tag = 1; + static constexpr int translation_message_tag = 2; /// Non-owning sequential view of MPI_COMM_WORLD for preparation/control paths. This explicitly /// initializes or validates MPI, but its destructor never frees the process communicator. @@ -258,11 +288,16 @@ class ExecutionLane { ExecutionLane& operator=(const ExecutionLane&) = delete; ExecutionLane(ExecutionLane&& other) noexcept { move_from_(std::move(other)); } + /// Replacement preserves the historical movable-lane API only while neither object is borrowed. + /// A borrowed lane has a stable address contract, so replacing either endpoint fails closed. ExecutionLane& operator=(ExecutionLane&& other) noexcept { - if (this != &other) { - release_(); - move_from_(std::move(other)); - } + if (this == &other) + return *this; + if (immutable_borrow_count_.load(std::memory_order_acquire) != 0 || + other.immutable_borrow_count_.load(std::memory_order_acquire) != 0) + std::terminate(); + release_(); + move_from_(std::move(other)); return *this; } @@ -279,6 +314,16 @@ class ExecutionLane { #endif } [[nodiscard]] bool active() const noexcept { return communicator().active(); } + /// True only for a collectively duplicated MPI communicator. World and serial lanes borrow none. + [[nodiscard]] bool owns_communicator() const noexcept { +#ifdef POPS_HAS_MPI + return owns_communicator_; +#else + return false; +#endif + } + /// Pins this exact lane object against move/destruction until the returned guard dies. + [[nodiscard]] ImmutableBorrow borrow_immutably() const noexcept { return ImmutableBorrow(*this); } [[nodiscard]] int rank() const { return communicator().rank(); } [[nodiscard]] int size() const { return communicator().size(); } @@ -323,6 +368,8 @@ class ExecutionLane { #endif void move_from_(ExecutionLane&& other) noexcept { + if (other.immutable_borrow_count_.load(std::memory_order_acquire) != 0) + std::terminate(); identity_ = std::move(other.identity_); static_identity_ = std::exchange(other.static_identity_, std::string_view{}); #ifdef POPS_HAS_MPI @@ -332,6 +379,8 @@ class ExecutionLane { } void release_() noexcept { + if (immutable_borrow_count_.load(std::memory_order_acquire) != 0) + std::terminate(); #ifdef POPS_HAS_MPI if (communicator_ != MPI_COMM_NULL && owns_communicator_) { if (detail::comm_active_unlocked()) @@ -342,8 +391,26 @@ class ExecutionLane { #endif } + void acquire_immutable_borrow_() const noexcept { + std::size_t current = immutable_borrow_count_.load(std::memory_order_relaxed); + for (;;) { + if (current == std::numeric_limits::max()) + std::terminate(); + if (immutable_borrow_count_.compare_exchange_weak( + current, current + 1, std::memory_order_acq_rel, std::memory_order_relaxed)) + return; + } + } + + void release_immutable_borrow_() const noexcept { + const std::size_t previous = immutable_borrow_count_.fetch_sub(1, std::memory_order_acq_rel); + if (previous == 0) + std::terminate(); + } + std::string identity_; std::string_view static_identity_; + mutable std::atomic immutable_borrow_count_{0}; #ifdef POPS_HAS_MPI MPI_Comm communicator_ = MPI_COMM_NULL; bool owns_communicator_ = false; @@ -560,11 +627,20 @@ class ObserverMpiLane { throw std::out_of_range("observer collective root is outside the lane"); const int me = lane.rank(); - std::optional> result; + long length_overflow = 0; + if constexpr (sizeof(std::size_t) > sizeof(unsigned long long)) { + if (payload.size() > static_cast(std::numeric_limits::max())) + length_overflow = 1; + } + if (all_reduce_max(length_overflow, lane) != 0) + throw std::overflow_error("consumer gather payload exceeds the MPI length domain"); + const unsigned long long local_length = static_cast(payload.size()); + + std::vector lengths; long allocation_failed = 0; if (me == root) { try { - result.emplace(static_cast(ranks)); + lengths.resize(static_cast(ranks), 0ULL); } catch (const std::bad_alloc&) { allocation_failed = 1; } catch (const std::length_error&) { @@ -572,25 +648,94 @@ class ObserverMpiLane { } } if (all_reduce_max(allocation_failed, lane) != 0) - throw std::runtime_error("observer root could not allocate gathered results"); + throw std::runtime_error("consumer root could not allocate gathered lengths"); + detail::require_mpi_success( + MPI_Gather(&local_length, 1, MPI_UNSIGNED_LONG_LONG, me == root ? lengths.data() : nullptr, + 1, MPI_UNSIGNED_LONG_LONG, root, lane.native_handle()), + "MPI_Gather(consumer payload lengths)"); - for (int source = 0; source < ranks; ++source) { - std::string source_payload; - long copy_failed = 0; - if (me == source) { + unsigned long long maximum_length = local_length; + detail::require_mpi_success( + MPI_Allreduce(MPI_IN_PLACE, &maximum_length, 1, MPI_UNSIGNED_LONG_LONG, MPI_MAX, + lane.native_handle()), + "MPI_Allreduce(maximum consumer gather length)"); + + std::optional> result; + std::vector counts; + std::vector displacements; + allocation_failed = 0; + if (me == root) { + try { + result.emplace(static_cast(ranks)); + counts.resize(static_cast(ranks), 0); + displacements.resize(static_cast(ranks), 0); + for (int rank = 0; rank < ranks; ++rank) { + const unsigned long long length = lengths[static_cast(rank)]; + if (length > static_cast(std::numeric_limits::max())) { + allocation_failed = 1; + break; + } + (*result)[static_cast(rank)].resize(static_cast(length)); + } + } catch (const std::bad_alloc&) { + allocation_failed = 1; + } catch (const std::length_error&) { + allocation_failed = 1; + } + } + if (all_reduce_max(allocation_failed, lane) != 0) + throw std::runtime_error("consumer root could not allocate gathered payloads"); + + const int capacity = detail::chunk_capacity(ranks); + for (unsigned long long offset = 0; offset < maximum_length; + offset += static_cast(capacity)) { + int total = 0; + if (me == root) { + for (int rank = 0; rank < ranks; ++rank) { + const unsigned long long length = lengths[static_cast(rank)]; + const int count = offset < length + ? static_cast(std::min( + length - offset, static_cast(capacity))) + : 0; + counts[static_cast(rank)] = count; + displacements[static_cast(rank)] = total; + total += count; + } + } + std::vector round; + long round_allocation_failed = 0; + if (me == root) { try { - source_payload = payload; + round.resize(static_cast(total)); } catch (const std::bad_alloc&) { - copy_failed = 1; + round_allocation_failed = 1; } catch (const std::length_error&) { - copy_failed = 1; + round_allocation_failed = 1; } } - if (all_reduce_max(copy_failed, lane) != 0) - throw std::runtime_error("an observer rank could not stage its gather payload"); - std::string received = broadcast_bytes(std::move(source_payload), source); - if (me == root) - (*result)[static_cast(source)] = std::move(received); + if (all_reduce_max(round_allocation_failed, lane) != 0) + throw std::runtime_error("consumer root could not allocate a gathered chunk"); + const int send_count = + offset < local_length + ? static_cast(std::min( + local_length - offset, static_cast(capacity))) + : 0; + detail::require_mpi_success( + MPI_Gatherv(detail::chunk_pointer(payload, offset, send_count), send_count, MPI_BYTE, + me == root ? round.data() : nullptr, me == root ? counts.data() : nullptr, + me == root ? displacements.data() : nullptr, MPI_BYTE, root, + lane.native_handle()), + "MPI_Gatherv(consumer payload chunk)"); + if (me != root) + continue; + for (int rank = 0; rank < ranks; ++rank) { + const int count = counts[static_cast(rank)]; + if (count == 0) + continue; + std::copy_n( + round.data() + displacements[static_cast(rank)], count, + (*result)[static_cast(rank)].data() + static_cast(offset)); + } } return result; #else diff --git a/include/pops/parallel/prepared_load_balance.hpp b/include/pops/parallel/prepared_load_balance.hpp index 5863eb2bd..8dc38ea89 100644 --- a/include/pops/parallel/prepared_load_balance.hpp +++ b/include/pops/parallel/prepared_load_balance.hpp @@ -8,7 +8,10 @@ #include #include +#include +#include #include +#include #include #include #include @@ -17,6 +20,7 @@ #include #include #include +#include namespace pops { @@ -24,6 +28,63 @@ using LoadBalanceWeights = std::span; using PreparedLoadBalanceProvider = PreparedProvider; +/// Measured, topology-qualified resource cost for one AMR patch. +/// +/// Integer counters keep the collective contract bit-exact across ranks. Compute and +/// communication time are accumulated over ``samples`` observations; resident bytes are the +/// amount that must move if ownership changes. No field is an optional hint: a rebalance request +/// with stale or incomplete evidence is rejected before invoking a policy. +struct ResourceEstimate { + std::uint64_t topology_epoch = 0; + std::uint64_t materialization_generation = 0; + std::int64_t samples = 0; + std::int64_t cell_updates = 0; + std::int64_t compute_nanoseconds = 0; + std::int64_t memory_bytes = 0; + std::int64_t communication_bytes = 0; + std::int64_t communication_nanoseconds = 0; + std::int64_t resident_bytes = 0; +}; + +using ResourceEstimates = std::span; + +/// Policy used to decide whether a measured candidate repays its migration cost. +struct RebalancePolicy { + /// Required net reduction over the complete amortization horizon, in parts per million. + std::int64_t minimum_improvement_ppm = 50'000; + std::int64_t amortization_steps = 20; + std::int64_t migration_bandwidth_bytes_per_second = 1'000'000'000; + std::int64_t per_patch_migration_latency_nanoseconds = 0; +}; + +enum class RebalanceReason : std::uint8_t { + EmptyHierarchy = 0, + MappingUnchanged = 1, + InsufficientNetBenefit = 2, + NetBenefit = 3, +}; + +/// Immutable measured decision. ``proposed_mapping`` is never silently applied; the hierarchy +/// must consume it through its migration transaction when ``accepted`` is true. +struct RebalanceDecision { + std::uint64_t topology_epoch = 0; + std::uint64_t materialization_generation = 0; + /// Exact prepared-authority, level, BoxArray and current-owner identity consumed by migration. + std::string source_contract; + DistributionMapping proposed_mapping; + RebalanceReason reason = RebalanceReason::EmptyHierarchy; + bool accepted = false; + std::int64_t moved_patches = 0; + std::int64_t migration_bytes = 0; + std::int64_t migration_nanoseconds = 0; + std::int64_t current_max_nanoseconds_per_step = 0; + std::int64_t proposed_max_nanoseconds_per_step = 0; + double current_imbalance = 1.0; + double proposed_imbalance = 1.0; + double predicted_net_speedup = 1.0; + std::string exact_contract; +}; + namespace detail { inline std::string exact_load_balance_request(const BoxArray& boxes, int rank_count, @@ -54,6 +115,135 @@ inline std::string exact_load_balance_mapping(const DistributionMapping& mapping return std::move(contract).release(); } +inline std::int64_t checked_add_cost(std::int64_t lhs, std::int64_t rhs, std::string_view context) { + if (lhs < 0 || rhs < 0 || rhs > std::numeric_limits::max() - lhs) + throw std::overflow_error(std::string(context) + " exceeds int64_t"); + return lhs + rhs; +} + +inline std::int64_t estimate_weight(const ResourceEstimate& estimate, std::uint64_t topology_epoch, + std::uint64_t materialization_generation) { + if (estimate.topology_epoch != topology_epoch || + estimate.materialization_generation != materialization_generation) + throw std::invalid_argument("load-balance resource estimate is stale for the live topology"); + if (estimate.samples <= 0 || estimate.cell_updates <= 0 || estimate.compute_nanoseconds < 0 || + estimate.memory_bytes < 0 || estimate.communication_bytes < 0 || + estimate.communication_nanoseconds < 0 || estimate.resident_bytes <= 0) + throw std::invalid_argument("load-balance resource estimate is incomplete or negative"); + const std::int64_t total = + checked_add_cost(estimate.compute_nanoseconds, estimate.communication_nanoseconds, + "load-balance measured time"); + if (total <= 0) + throw std::invalid_argument("load-balance resource estimate has no measured time"); + const std::int64_t quotient = total / estimate.samples; + const std::int64_t remainder = total % estimate.samples; + return checked_add_cost(quotient, remainder == 0 ? 0 : 1, "load-balance per-sample weight"); +} + +inline std::string exact_rebalance_request(const DistributionMapping& current, + std::uint64_t topology_epoch, + std::uint64_t materialization_generation, + ResourceEstimates estimates, + const RebalancePolicy& policy) { + ExactContractBuilder contract; + contract.text("pops.rebalance-request") + .scalar(std::uint32_t{1}) + .scalar(topology_epoch) + .scalar(materialization_generation) + .scalar(policy.minimum_improvement_ppm) + .scalar(policy.amortization_steps) + .scalar(policy.migration_bandwidth_bytes_per_second) + .scalar(policy.per_patch_migration_latency_nanoseconds) + .sequence(current.ranks()); + contract.scalar(static_cast(estimates.size())); + for (const ResourceEstimate& estimate : estimates) { + contract.scalar(estimate.topology_epoch) + .scalar(estimate.materialization_generation) + .scalar(estimate.samples) + .scalar(estimate.cell_updates) + .scalar(estimate.compute_nanoseconds) + .scalar(estimate.memory_bytes) + .scalar(estimate.communication_bytes) + .scalar(estimate.communication_nanoseconds) + .scalar(estimate.resident_bytes); + } + return std::move(contract).release(); +} + +inline std::string exact_rebalance_source(std::string_view authority_identity, + std::string_view authority_collective_contract, + int source_level, int source_rank_count, + std::uint64_t topology_epoch, + std::uint64_t materialization_generation, + const BoxArray& source_boxes, + const DistributionMapping& source_mapping) { + ExactContractBuilder contract; + contract.text("pops.rebalance-source") + .scalar(std::uint32_t{1}) + .text(authority_identity) + .text(authority_collective_contract) + .scalar(source_level) + .scalar(source_rank_count) + .scalar(topology_epoch) + .scalar(materialization_generation) + .scalar(static_cast(source_boxes.size())); + for (const Box2D& box : source_boxes.boxes()) + contract.scalar(box.lo[0]).scalar(box.lo[1]).scalar(box.hi[0]).scalar(box.hi[1]); + contract.sequence(source_mapping.ranks()); + return std::move(contract).release(); +} + +inline std::int64_t maximum_rank_cost(const DistributionMapping& mapping, int rank_count, + LoadBalanceWeights weights) { + std::vector costs(static_cast(rank_count), 0); + for (int index = 0; index < mapping.size(); ++index) { + const int owner = mapping[index]; + if (owner < 0 || owner >= rank_count) + throw std::invalid_argument("rebalance mapping contains an invalid owner rank"); + costs[static_cast(owner)] = checked_add_cost( + costs[static_cast(owner)], weights[static_cast(index)], + "rebalance per-rank measured cost"); + } + return costs.empty() ? 0 : *std::max_element(costs.begin(), costs.end()); +} + +inline std::int64_t migration_time_nanoseconds(std::int64_t bytes, std::int64_t moved_patches, + const RebalancePolicy& policy) { + if (bytes < 0 || moved_patches < 0 || policy.migration_bandwidth_bytes_per_second <= 0 || + policy.per_patch_migration_latency_nanoseconds < 0) + throw std::invalid_argument("rebalance migration cost model is invalid"); + const long double transfer = + std::ceil(static_cast(bytes) * 1.0e9L / + static_cast(policy.migration_bandwidth_bytes_per_second)); + const long double latency = + static_cast(moved_patches) * policy.per_patch_migration_latency_nanoseconds; + const long double total = transfer + latency; + if (total > static_cast(std::numeric_limits::max())) + throw std::overflow_error("rebalance migration time exceeds int64_t"); + return static_cast(total); +} + +inline std::string exact_rebalance_decision(const RebalanceDecision& decision) { + ExactContractBuilder contract; + contract.text("pops.rebalance-decision") + .scalar(std::uint32_t{2}) + .scalar(decision.topology_epoch) + .scalar(decision.materialization_generation) + .text(decision.source_contract) + .scalar(static_cast(decision.reason)) + .scalar(static_cast(decision.accepted ? 1 : 0)) + .scalar(decision.moved_patches) + .scalar(decision.migration_bytes) + .scalar(decision.migration_nanoseconds) + .scalar(decision.current_max_nanoseconds_per_step) + .scalar(decision.proposed_max_nanoseconds_per_step) + .scalar(decision.current_imbalance) + .scalar(decision.proposed_imbalance) + .scalar(decision.predicted_net_speedup) + .sequence(decision.proposed_mapping.ranks()); + return std::move(contract).release(); +} + template inline void collective_load_balance_preflight(std::string_view context, const CommunicatorView& communicator, @@ -81,6 +271,41 @@ inline void require_empty_load_balance_options(const PreparedProviderOptions& op throw std::invalid_argument("builtin load-balance provider options are not canonical"); } +inline std::int64_t require_signed_option(const PreparedProviderOptions& options, + std::string_view key) { + const auto found = options.values.find(std::string(key)); + if (found == options.values.end() || !std::holds_alternative(found->second)) + throw std::invalid_argument("measured load-balance option '" + std::string(key) + + "' must be one exact int64"); + return std::get(found->second); +} + +inline RebalancePolicy measured_rebalance_policy(const PreparedProviderOptions& options) { + static const std::string schema = "pops.amr.load-balance.measured-knapsack@1"; + static const std::array keys{ + "minimum_improvement_ppm", + "amortization_steps", + "migration_bandwidth_bytes_per_second", + "per_patch_migration_latency_nanoseconds", + }; + if (options.schema_identity != schema || options.values.size() != keys.size()) + throw std::invalid_argument("measured knapsack options are not canonical"); + for (const std::string_view key : keys) + if (!options.values.contains(std::string(key))) + throw std::invalid_argument("measured knapsack options are not canonical"); + RebalancePolicy policy{ + .minimum_improvement_ppm = require_signed_option(options, keys[0]), + .amortization_steps = require_signed_option(options, keys[1]), + .migration_bandwidth_bytes_per_second = require_signed_option(options, keys[2]), + .per_patch_migration_latency_nanoseconds = require_signed_option(options, keys[3]), + }; + if (policy.minimum_improvement_ppm < 0 || policy.minimum_improvement_ppm >= 1'000'000 || + policy.amortization_steps <= 0 || policy.migration_bandwidth_bytes_per_second <= 0 || + policy.per_patch_migration_latency_nanoseconds < 0) + throw std::invalid_argument("measured knapsack policy is outside its bounded envelope"); + return policy; +} + struct SpaceFillingCurveLoadBalance { [[nodiscard]] static constexpr PreparedProviderIdentity provider_identity() noexcept { return {"pops.load_balance.space_filling_curve", 1}; @@ -107,6 +332,26 @@ struct KnapsackLoadBalance { } }; +struct MeasuredKnapsackLoadBalance { + RebalancePolicy policy; + + [[nodiscard]] static constexpr PreparedProviderIdentity provider_identity() noexcept { + return {"pops.load_balance.measured_knapsack", 1}; + } + void serialize_exact_parameters(ExactContractBuilder& contract) const { + contract.text("measured-knapsack") + .scalar(std::uint32_t{1}) + .scalar(policy.minimum_improvement_ppm) + .scalar(policy.amortization_steps) + .scalar(policy.migration_bandwidth_bytes_per_second) + .scalar(policy.per_patch_migration_latency_nanoseconds); + } + DistributionMapping operator()(const BoxArray& boxes, int ranks, + LoadBalanceWeights weights) const { + return make_knapsack_distribution(boxes, ranks, weights); + } +}; + struct RoundRobinLoadBalance { [[nodiscard]] static constexpr PreparedProviderIdentity provider_identity() noexcept { return {"pops.load_balance.round_robin", 1}; @@ -128,15 +373,102 @@ struct RoundRobinLoadBalance { } // namespace detail +/// Evaluate one proposed ownership map without mutating hierarchy state. +/// +/// This pure host routine is also the executable specification used by the collective authority and +/// by migration transactions: every cost is measured, every estimate is tied to the live topology, +/// and migration must be repaid over the declared horizon before adoption is allowed. +inline RebalanceDecision make_rebalance_decision( + const BoxArray& boxes, const DistributionMapping& current, const DistributionMapping& proposed, + int rank_count, std::uint64_t topology_epoch, std::uint64_t materialization_generation, + ResourceEstimates estimates, const RebalancePolicy& policy, std::string source_contract) { + if (rank_count <= 0 || current.size() != boxes.size() || proposed.size() != boxes.size() || + estimates.size() != static_cast(boxes.size()) || source_contract.empty()) + throw std::invalid_argument( + "rebalance mappings, estimates and source contract must match a positive-rank BoxArray"); + if (policy.minimum_improvement_ppm < 0 || policy.minimum_improvement_ppm >= 1'000'000 || + policy.amortization_steps <= 0 || policy.migration_bandwidth_bytes_per_second <= 0 || + policy.per_patch_migration_latency_nanoseconds < 0) + throw std::invalid_argument("rebalance policy is outside its exact bounded envelope"); + + std::vector weights; + weights.reserve(estimates.size()); + for (const ResourceEstimate& estimate : estimates) + weights.push_back( + detail::estimate_weight(estimate, topology_epoch, materialization_generation)); + + RebalanceDecision decision; + decision.topology_epoch = topology_epoch; + decision.materialization_generation = materialization_generation; + decision.source_contract = std::move(source_contract); + decision.proposed_mapping = proposed; + if (boxes.size() == 0) { + decision.reason = RebalanceReason::EmptyHierarchy; + decision.exact_contract = detail::exact_rebalance_decision(decision); + return decision; + } + + decision.current_max_nanoseconds_per_step = + detail::maximum_rank_cost(current, rank_count, weights); + decision.proposed_max_nanoseconds_per_step = + detail::maximum_rank_cost(proposed, rank_count, weights); + decision.current_imbalance = load_imbalance(boxes, current, rank_count, weights); + decision.proposed_imbalance = load_imbalance(boxes, proposed, rank_count, weights); + for (int index = 0; index < boxes.size(); ++index) { + if (current[index] == proposed[index]) + continue; + ++decision.moved_patches; + decision.migration_bytes = detail::checked_add_cost( + decision.migration_bytes, estimates[static_cast(index)].resident_bytes, + "rebalance migration bytes"); + } + decision.migration_nanoseconds = + detail::migration_time_nanoseconds(decision.migration_bytes, decision.moved_patches, policy); + + const long double current_horizon = + static_cast(decision.current_max_nanoseconds_per_step) * + policy.amortization_steps; + const long double proposed_horizon = + static_cast(decision.proposed_max_nanoseconds_per_step) * + policy.amortization_steps + + decision.migration_nanoseconds; + if (!(current_horizon > 0.0L) || !(proposed_horizon > 0.0L)) + throw std::invalid_argument("rebalance measured horizon must be strictly positive"); + decision.predicted_net_speedup = static_cast(current_horizon / proposed_horizon); + const long double required_fraction = + 1.0L - static_cast(policy.minimum_improvement_ppm) / 1.0e6L; + decision.accepted = + decision.moved_patches > 0 && proposed_horizon <= current_horizon * required_fraction; + if (decision.moved_patches == 0) + decision.reason = RebalanceReason::MappingUnchanged; + else if (decision.accepted) + decision.reason = RebalanceReason::NetBenefit; + else + decision.reason = RebalanceReason::InsufficientNetBenefit; + decision.exact_contract = detail::exact_rebalance_decision(decision); + return decision; +} + /// Immutable authority prepared before hierarchy materialization. Every invocation validates the /// same provider/request/result contract collectively; regrid consumers call this object directly /// and never inspect an implementation name. class PreparedLoadBalanceAuthority { public: - PreparedLoadBalanceAuthority(std::string semantic_identity, PreparedLoadBalanceProvider provider) - : semantic_identity_(std::move(semantic_identity)), provider_(std::move(provider)) { + PreparedLoadBalanceAuthority( + std::string semantic_identity, PreparedLoadBalanceProvider provider, + std::optional default_rebalance_policy = std::nullopt) + : semantic_identity_(std::move(semantic_identity)), + provider_(std::move(provider)), + default_rebalance_policy_(std::move(default_rebalance_policy)) { if (semantic_identity_.empty() || !provider_) throw std::invalid_argument("prepared load-balance authority is incomplete"); + if (default_rebalance_policy_) { + const RebalancePolicy& policy = *default_rebalance_policy_; + if (policy.minimum_improvement_ppm < 0 || policy.minimum_improvement_ppm >= 1'000'000 || + policy.amortization_steps <= 0 || policy.migration_bandwidth_bytes_per_second <= 0 || + policy.per_patch_migration_latency_nanoseconds < 0) + throw std::invalid_argument("prepared load-balance default rebalance policy is invalid"); + } } [[nodiscard]] const std::string& semantic_identity() const noexcept { return semantic_identity_; } @@ -146,6 +478,14 @@ class PreparedLoadBalanceAuthority { [[nodiscard]] std::string_view collective_contract() const noexcept { return provider_.collective_contract(); } + [[nodiscard]] bool has_default_rebalance_policy() const noexcept { + return default_rebalance_policy_.has_value(); + } + [[nodiscard]] const RebalancePolicy& default_rebalance_policy() const { + if (!default_rebalance_policy_) + throw std::logic_error("load-balance authority has no measured rebalance policy"); + return *default_rebalance_policy_; + } [[nodiscard]] DistributionMapping distribute( const BoxArray& boxes, int rank_count, LoadBalanceWeights weights = {}, @@ -192,9 +532,83 @@ class PreparedLoadBalanceAuthority { return std::move(*mapping); } + /// Produce one collective, topology-qualified migration decision from measured patch costs. + /// + /// The method does not mutate hierarchy ownership. It authenticates the observations, prepares + /// a policy candidate through the same immutable authority, accounts for migration over the + /// configured horizon, and returns a decision that a hierarchy migration transaction may consume. + [[nodiscard]] RebalanceDecision decide_rebalance( + int source_level, const BoxArray& boxes, const DistributionMapping& current, int rank_count, + std::uint64_t topology_epoch, std::uint64_t materialization_generation, + ResourceEstimates estimates, const RebalancePolicy& policy, + const CommunicatorView& communicator = world_communicator_view()) const { + std::vector weights; + std::string request_contract; + std::string source_contract; + detail::collective_load_balance_preflight("rebalance request", communicator, [&] { + if (source_level < 0 || rank_count <= 0 || rank_count != communicator.size()) + throw std::invalid_argument( + "rebalance source level must be nonnegative and rank count must equal the execution " + "communicator size"); + if (current.size() != boxes.size() || + estimates.size() != static_cast(boxes.size())) + throw std::invalid_argument( + "rebalance current mapping and resource estimates must match the BoxArray"); + if (policy.minimum_improvement_ppm < 0 || policy.minimum_improvement_ppm >= 1'000'000 || + policy.amortization_steps <= 0 || policy.migration_bandwidth_bytes_per_second <= 0 || + policy.per_patch_migration_latency_nanoseconds < 0) + throw std::invalid_argument("rebalance policy is outside its exact bounded envelope"); + for (const int owner : current.ranks()) + if (owner < 0 || owner >= rank_count) + throw std::invalid_argument("rebalance current mapping contains an invalid owner rank"); + weights.reserve(estimates.size()); + for (const ResourceEstimate& estimate : estimates) + weights.push_back( + detail::estimate_weight(estimate, topology_epoch, materialization_generation)); + request_contract = detail::exact_rebalance_request( + current, topology_epoch, materialization_generation, estimates, policy); + source_contract = detail::exact_rebalance_source( + semantic_identity_, provider_.collective_contract(), source_level, rank_count, + topology_epoch, materialization_generation, boxes, current); + }); + + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{semantic_identity_, provider_.collective_contract()}, + {"rebalance-source", source_contract}, + {"rebalance-request", request_contract}}, + communicator)) + throw std::invalid_argument( + "rebalance provider identity or measured request differs across MPI ranks"); + + DistributionMapping proposed = distribute(boxes, rank_count, weights, communicator); + std::optional result; + detail::collective_load_balance_preflight("rebalance decision", communicator, [&] { + result.emplace(make_rebalance_decision(boxes, current, proposed, rank_count, topology_epoch, + materialization_generation, estimates, policy, + source_contract)); + }); + if (!result) + throw std::logic_error("rebalance decision was not materialized"); + if (!all_ranks_agree_exact_ordered_byte_pairs({{semantic_identity_, result->exact_contract}}, + communicator)) + throw std::invalid_argument("rebalance decision differs across MPI ranks"); + return std::move(*result); + } + + [[nodiscard]] RebalanceDecision decide_rebalance( + int source_level, const BoxArray& boxes, const DistributionMapping& current, int rank_count, + std::uint64_t topology_epoch, std::uint64_t materialization_generation, + ResourceEstimates estimates, + const CommunicatorView& communicator = world_communicator_view()) const { + return decide_rebalance(source_level, boxes, current, rank_count, topology_epoch, + materialization_generation, estimates, default_rebalance_policy(), + communicator); + } + private: std::string semantic_identity_; PreparedLoadBalanceProvider provider_; + std::optional default_rebalance_policy_; }; using LoadBalanceAuthorityFactory = std::function::max() / divisor); -} - -inline const char* chunk_pointer(const std::string& payload, unsigned long long offset, int count) { - if (count == 0) - return nullptr; - return payload.data() + static_cast(offset); -} - #endif } // namespace detail diff --git a/include/pops/physics/bricks/hyperbolic.hpp b/include/pops/physics/bricks/hyperbolic.hpp index 687cc6ebb..025366728 100644 --- a/include/pops/physics/bricks/hyperbolic.hpp +++ b/include/pops/physics/bricks/hyperbolic.hpp @@ -28,22 +28,24 @@ struct ExBVelocity { static constexpr int n_vars = 1; using State = StateVec<1>; Real B0 = 1; - POPS_HD Real velocity(const Aux& a, int dir) const { - return (dir == 0) ? (-a.grad_y / B0) : (a.grad_x / B0); + POPS_HD Real velocity(const auto& providers, int dir) const { + const Real grad_x = providers.template flux_provider<1>(); + const Real grad_y = providers.template flux_provider<2>(); + return (dir == 0) ? (-grad_y / B0) : (grad_x / B0); } - POPS_HD StateVec<1> flux(const StateVec<1>& u, const Aux& a, int dir) const { + POPS_HD StateVec<1> flux(const StateVec<1>& u, const auto& providers, int dir) const { StateVec<1> f{}; - f[0] = u[0] * velocity(a, dir); + f[0] = u[0] * velocity(providers, dir); return f; } - POPS_HD Real max_wave_speed(const StateVec<1>&, const Aux& a, int dir) const { - const Real d = velocity(a, dir); + POPS_HD Real max_wave_speed(const StateVec<1>&, const auto& providers, int dir) const { + const Real d = velocity(providers, dir); return d < 0 ? -d : d; } /// Spectrum: one wave, the drift speed in direction dir. - POPS_HD StateVec<1> eigenvalues(const StateVec<1>&, const Aux& a, int dir) const { + POPS_HD StateVec<1> eigenvalues(const StateVec<1>&, const auto& providers, int dir) const { StateVec<1> e{}; - e[0] = velocity(a, dir); + e[0] = velocity(providers, dir); return e; } // Scalar: primitive variables = conservative (transported density). @@ -83,22 +85,24 @@ struct ExBVelocityPolar { using State = StateVec<1>; Real B0 = 1; /// PHYSICAL component of the drift velocity in direction index dir (0 = r, 1 = theta). - POPS_HD Real velocity(const Aux& a, int dir) const { - return (dir == 0) ? (-a.grad_y / B0) : (a.grad_x / B0); + POPS_HD Real velocity(const auto& providers, int dir) const { + const Real grad_x = providers.template flux_provider<1>(); + const Real grad_y = providers.template flux_provider<2>(); + return (dir == 0) ? (-grad_y / B0) : (grad_x / B0); } - POPS_HD StateVec<1> flux(const StateVec<1>& u, const Aux& a, int dir) const { + POPS_HD StateVec<1> flux(const StateVec<1>& u, const auto& providers, int dir) const { StateVec<1> f{}; - f[0] = u[0] * velocity(a, dir); + f[0] = u[0] * velocity(providers, dir); return f; } - POPS_HD Real max_wave_speed(const StateVec<1>&, const Aux& a, int dir) const { - const Real d = velocity(a, dir); + POPS_HD Real max_wave_speed(const StateVec<1>&, const auto& providers, int dir) const { + const Real d = velocity(providers, dir); return d < 0 ? -d : d; } /// Spectrum: one wave, the drift speed in direction dir. - POPS_HD StateVec<1> eigenvalues(const StateVec<1>&, const Aux& a, int dir) const { + POPS_HD StateVec<1> eigenvalues(const StateVec<1>&, const auto& providers, int dir) const { StateVec<1> e{}; - e[0] = velocity(a, dir); + e[0] = velocity(providers, dir); return e; } // Scalar: primitive variables = conservative (transported density). @@ -141,7 +145,7 @@ struct IsothermalFlux { POPS_HD Real velocity_rho(Real rho) const { return (vacuum_floor > Real(0) && rho < vacuum_floor) ? vacuum_floor : rho; } - POPS_HD StateVec<3> flux(const StateVec<3>& u, const Aux&, int dir) const { + POPS_HD StateVec<3> flux(const StateVec<3>& u, const auto&, int dir) const { const Real rho = u[0]; const Real vn = (dir == 0 ? u[1] : u[2]) / velocity_rho(rho); const Real p = cs2 * rho; @@ -169,14 +173,14 @@ struct IsothermalFlux { u[2] = p[0] * p[2]; return u; } - POPS_HD Real max_wave_speed(const StateVec<3>& u, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const StateVec<3>& u, const auto&, int dir) const { const Prim p = to_primitive(u); const Real vn = (dir == 0 ? p[1] : p[2]); const Real a = vn < 0 ? -vn : vn; return a + std::sqrt(cs2); } /// Full spectrum: (v_dir - c, v_dir, v_dir + c), c = sqrt(cs2). - POPS_HD StateVec<3> eigenvalues(const StateVec<3>& u, const Aux&, int dir) const { + POPS_HD StateVec<3> eigenvalues(const StateVec<3>& u, const auto&, int dir) const { const Prim p = to_primitive(u); const Real vn = (dir == 0 ? p[1] : p[2]); const Real c = std::sqrt(cs2); @@ -187,7 +191,7 @@ struct IsothermalFlux { return e; } /// Signed speeds (HLL/HLLC): v_dir -+ c_s. - POPS_HD void wave_speeds(const StateVec<3>& u, const Aux&, int dir, Real& smin, + POPS_HD void wave_speeds(const StateVec<3>& u, const auto&, int dir, Real& smin, Real& smax) const { const Prim p = to_primitive(u); const Real vn = (dir == 0 ? p[1] : p[2]); @@ -195,6 +199,93 @@ struct IsothermalFlux { smin = vn - c; smax = vn + c; } + + // ------------------------------------------------------------------------------------------- + // RIEMANN CAPABILITIES: the isothermal closure owns its contact construction and Roe action. + // HLLCFlux / RoeFlux remain layout-blind and consume these hooks through the same + // HasHLLCStructure / HasRoeDissipation contracts as every other physical provider. + // ------------------------------------------------------------------------------------------- + + /// Barotropic pressure p = c_s^2 rho used by the HLLC physical provider. + POPS_HD Real pressure(const State& u) const { return cs2 * u[0]; } + + /// Contact-wave speed for the isothermal Euler closure. + POPS_HD Real contact_speed(const State& left, const State& right, Real pressure_left, + Real pressure_right, Real lower, Real upper, int dir) const { + const int normal = dir == 0 ? 1 : 2; + const Real density_left = left[0]; + const Real density_right = right[0]; + const Real velocity_left = left[normal] / velocity_rho(density_left); + const Real velocity_right = right[normal] / velocity_rho(density_right); + return (pressure_right - pressure_left + + density_left * velocity_left * (lower - velocity_left) - + density_right * velocity_right * (upper - velocity_right)) / + (density_left * (lower - velocity_left) - + density_right * (upper - velocity_right)); + } + + /// HLLC star state for a barotropic state (rho, rho u, rho v). + POPS_HD State hllc_star_state(const State& value, Real, Real speed, Real contact, + int dir) const { + const int normal = dir == 0 ? 1 : 2; + const int tangent = dir == 0 ? 2 : 1; + const Real density = value[0]; + const Real normal_velocity = value[normal] / velocity_rho(density); + const Real star_density = density * (speed - normal_velocity) / (speed - contact); + State result{}; + result[0] = star_density; + result[normal] = star_density * contact; + result[tangent] = star_density * (value[tangent] / velocity_rho(density)); + return result; + } + + /// Roe action |A_roe| dU for the isothermal Euler closure. + POPS_HD State roe_dissipation(const State& left, const auto&, const State& right, const auto&, + int dir) const { + const int normal = dir == 0 ? 1 : 2; + const int tangent = dir == 0 ? 2 : 1; + const Real density_left = left[0]; + const Real density_right = right[0]; + const Real velocity_left = left[normal] / velocity_rho(density_left); + const Real velocity_right = right[normal] / velocity_rho(density_right); + const Real tangent_left = left[tangent] / velocity_rho(density_left); + const Real tangent_right = right[tangent] / velocity_rho(density_right); + + const Real root_left = std::sqrt(density_left); + const Real root_right = std::sqrt(density_right); + const Real denominator = root_left + root_right; + const Real normal_velocity = + (root_left * velocity_left + root_right * velocity_right) / denominator; + const Real tangent_velocity = + (root_left * tangent_left + root_right * tangent_right) / denominator; + const Real roe_density = root_left * root_right; + const Real sound_speed = std::sqrt(cs2); + + const Real density_jump = density_right - density_left; + const Real normal_jump = velocity_right - velocity_left; + const Real tangent_jump = tangent_right - tangent_left; + const Real acoustic_minus = + (cs2 * density_jump - roe_density * sound_speed * normal_jump) / + (Real(2) * cs2); + const Real acoustic_plus = + (cs2 * density_jump + roe_density * sound_speed * normal_jump) / + (Real(2) * cs2); + const Real shear = roe_density * tangent_jump; + + const HartenEntropyFix entropy_fix{Real(0.1)}; + const Real lambda_minus = entropy_fix(normal_velocity - sound_speed, sound_speed); + const Real lambda_shear = normal_velocity < Real(0) ? -normal_velocity : normal_velocity; + const Real lambda_plus = entropy_fix(normal_velocity + sound_speed, sound_speed); + + State result{}; + result[0] = lambda_minus * acoustic_minus + lambda_plus * acoustic_plus; + result[normal] = lambda_minus * acoustic_minus * (normal_velocity - sound_speed) + + lambda_plus * acoustic_plus * (normal_velocity + sound_speed); + result[tangent] = lambda_minus * acoustic_minus * tangent_velocity + + lambda_shear * shear + + lambda_plus * acoustic_plus * tangent_velocity; + return result; + } static VariableSet conservative_vars() { return {VariableKind::Conservative, {"rho", "rho_u", "rho_v"}, diff --git a/include/pops/physics/composition/composite.hpp b/include/pops/physics/composition/composite.hpp index aacb38bd6..07760bbb5 100644 --- a/include/pops/physics/composition/composite.hpp +++ b/include/pops/physics/composition/composite.hpp @@ -52,9 +52,13 @@ struct CompositeModel { Source src{}; Elliptic ell{}; - POPS_HD State flux(const State& u, const Aux& a, int dir) const { return hyp.flux(u, a, dir); } - POPS_HD Real max_wave_speed(const State& u, const Aux& a, int dir) const { - return hyp.max_wave_speed(u, a, dir); + template + POPS_HD State flux(const State& u, const Providers& providers, int dir) const { + return hyp.flux(u, providers, dir); + } + template + POPS_HD Real max_wave_speed(const State& u, const Providers& providers, int dir) const { + return hyp.max_wave_speed(u, providers, dir); } POPS_HD State source(const State& u, const Aux& a) const { return src.apply(u, a); } POPS_HD Real elliptic_rhs(const State& u) const { return ell.rhs(u); } @@ -63,17 +67,29 @@ struct CompositeModel { static VariableSet conservative_vars() { return Hyperbolic::conservative_vars(); } static VariableSet primitive_vars() { return Hyperbolic::primitive_vars(); } + /// Optional primitive-recovery admissibility, forwarded from the hyperbolic brick. Keeping the + /// method concept-gated preserves the historical finite-only recovery path for every brick that + /// does not declare a physical policy. + POPS_HD bool recovery_admissible(const Prim& p, int* failing_component) const + requires requires(const Hyperbolic h, const Prim q, int* component) { + { h.recovery_admissible(q, component) } -> std::same_as; + } + { + return hyp.recovery_admissible(p, failing_component); + } + POPS_HD Real pressure(const State& u) const requires requires(const Hyperbolic h, const State s) { h.pressure(s); } { return hyp.pressure(u); } - POPS_HD void wave_speeds(const State& u, const Aux& a, int dir, Real& smin, Real& smax) const - requires requires(const Hyperbolic h, const State s, const Aux aa, int d, Real& lo, Real& hi) { - h.wave_speeds(s, aa, d, lo, hi); - } + template + POPS_HD void wave_speeds(const State& u, const Providers& providers, int dir, Real& smin, + Real& smax) const + requires requires(const Hyperbolic h, const State s, const Providers& p, int d, Real& lo, + Real& hi) { h.wave_speeds(s, p, d, lo, hi); } { - hyp.wave_speeds(u, a, dir, smin, smax); + hyp.wave_speeds(u, providers, dir, smin, smax); } /// Riemann CAPABILITIES (audit wave 3): HLLC hooks (contact_speed + hllc_star_state) and Roe @@ -95,12 +111,22 @@ struct CompositeModel { { return hyp.hllc_star_state(u, p, s, sStar, dir); } - POPS_HD State roe_dissipation(const State& ul, const Aux& al, const State& ur, const Aux& ar, + template + POPS_HD State roe_dissipation(const State& ul, const LeftProviders& left_providers, + const State& ur, const RightProviders& right_providers, int dir) const - requires requires(const Hyperbolic h, const State a_, const Aux x_, const State b_, - const Aux y_, int d) { h.roe_dissipation(a_, x_, b_, y_, d); } + requires requires(const Hyperbolic h, const State a_, const LeftProviders& x_, const State b_, + const RightProviders& y_, int d) { h.roe_dissipation(a_, x_, b_, y_, d); } + { + return hyp.roe_dissipation(ul, left_providers, ur, right_providers, dir); + } + + POPS_HD bool characteristic_no_inflow(const State& interior, const State& reference, int dir, + int outward_sign, State& ghost) const + requires requires(const Hyperbolic h, const State a_, const State b_, int d, int side, + State& out) { h.characteristic_no_inflow(a_, b_, d, side, out); } { - return hyp.roe_dissipation(ul, al, ur, ar, dir); + return hyp.characteristic_no_inflow(interior, reference, dir, outward_sign, ghost); } /// GEOMETRIC source term of polar curvature, delegated to the hyperbolic brick when it diff --git a/include/pops/physics/fluids/euler.hpp b/include/pops/physics/fluids/euler.hpp index a1c1932d1..b4e2d92dc 100644 --- a/include/pops/physics/fluids/euler.hpp +++ b/include/pops/physics/fluids/euler.hpp @@ -81,7 +81,7 @@ struct Euler { * @param[out] smin leftmost wave speed v_dir - c * @param[out] smax rightmost wave speed v_dir + c */ - POPS_HD void wave_speeds(const State& u, const Aux&, int dir, Real& smin, Real& smax) const { + POPS_HD void wave_speeds(const State& u, const auto&, int dir, Real& smin, Real& smax) const { const Prim p = to_primitive(u); const Real vn = (dir == 0 ? p[1] : p[2]); const Real c = std::sqrt(gamma * p[3] / p[0]); @@ -90,7 +90,7 @@ struct Euler { } /// Compressible convective flux in direction dir. - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { const Real rho = u[0]; const Real vn = (dir == 0 ? u[1] : u[2]) / rho; // velocity normal to the face const Real p = pressure(u); @@ -139,7 +139,7 @@ struct Euler { /// eigenwave decomposition (F_R - F_L = A_roe (U_R - U_L) exactly), sqrt(rho) Roe average, gamma-1 /// from the ideal-gas EOS, and a typed Harten entropy policy on the acoustic waves. RoeFlux /// (HasRoeDissipation) then does F = 1/2 (F_L + F_R) - 1/2 d. - POPS_HD State roe_dissipation(const State& UL, const Aux&, const State& UR, const Aux&, + POPS_HD State roe_dissipation(const State& UL, const auto&, const State& UR, const auto&, int dir) const { const int in = (dir == 0) ? 1 : 2; // normal momentum const int it = (dir == 0) ? 2 : 1; // tangential @@ -186,7 +186,7 @@ struct Euler { /// Full spectrum in direction dir: (v_dir - c, v_dir, v_dir, v_dir + c). Vector counterpart /// of wave_speeds (which only gives the signed extremes); useful for spectrum schemes (Roe). - POPS_HD State eigenvalues(const State& u, const Aux&, int dir) const { + POPS_HD State eigenvalues(const State& u, const auto&, int dir) const { const Prim p = to_primitive(u); const Real vn = (dir == 0 ? p[1] : p[2]); const Real c = std::sqrt(gamma * p[3] / p[0]); @@ -199,7 +199,7 @@ struct Euler { } /// Maximum wave speed |v_dir| + c (Rusanov estimate), computed in primitive variables. - POPS_HD Real max_wave_speed(const State& u, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const State& u, const auto&, int dir) const { const Prim p = to_primitive(u); const Real vn = (dir == 0 ? p[1] : p[2]); const Real a = vn < 0 ? -vn : vn; // |v_dir| device-safe diff --git a/include/pops/runtime/accelerator/prepared_stream_executor.hpp b/include/pops/runtime/accelerator/prepared_stream_executor.hpp new file mode 100644 index 000000000..05ab30922 --- /dev/null +++ b/include/pops/runtime/accelerator/prepared_stream_executor.hpp @@ -0,0 +1,397 @@ +#pragma once + +/// @file +/// @brief Prepared, fail-closed accelerator stream partition with lane-private scratch. + +#include +#include + +#include +#if __has_include() +#include +#define POPS_KOKKOS_HAS_PARTITION_SPACE 1 +#else +#define POPS_KOKKOS_HAS_PARTITION_SPACE 0 +#endif +#if defined(KOKKOS_ENABLE_CUDA) +#include +#endif +#if defined(KOKKOS_ENABLE_HIP) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::runtime::accelerator { + +/// Raised when a caller asks PoPS to claim independent accelerator streams without proof. +class PreparedStreamPartitionError : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +namespace detail { + +template +inline constexpr bool authentic_partitioned_stream_backend = +#if defined(KOKKOS_ENABLE_CUDA) + std::is_same_v || +#endif +#if defined(KOKKOS_ENABLE_HIP) + std::is_same_v || +#endif +#if defined(KOKKOS_ENABLE_SYCL) +#if POPS_KOKKOS_HAS_PARTITION_SPACE + std::is_same_v || +#endif +#endif + false; + +template +[[nodiscard]] constexpr const char* stream_backend_name() noexcept { +#if defined(KOKKOS_ENABLE_CUDA) + if constexpr (std::is_same_v) + return "cuda"; +#endif +#if defined(KOKKOS_ENABLE_HIP) + if constexpr (std::is_same_v) + return "hip"; +#endif +#if defined(KOKKOS_ENABLE_SYCL) + if constexpr (std::is_same_v) + return "sycl"; +#endif + return "unsupported"; +} + +template +concept InstanceIdentifiedExecutionSpace = requires(const ExecutionSpace& instance) { + { instance.impl_instance_id() } -> std::convertible_to; +}; + +/// RAII ownership for the CUDA/HIP compatibility route used before Kokkos exposed +/// ``Experimental::partition_space``. Kokkos instances wrap, but do not own, these streams. +template +class OwnedNativeStream { + public: + OwnedNativeStream() = default; + OwnedNativeStream(const OwnedNativeStream&) = delete; + OwnedNativeStream& operator=(const OwnedNativeStream&) = delete; + OwnedNativeStream(OwnedNativeStream&& other) noexcept + : handle_(std::exchange(other.handle_, 0)) {} + OwnedNativeStream& operator=(OwnedNativeStream&& other) noexcept { + if (this == &other) + return *this; + reset_(); + handle_ = std::exchange(other.handle_, 0); + return *this; + } + ~OwnedNativeStream() { reset_(); } + + [[nodiscard]] static OwnedNativeStream create() { + OwnedNativeStream owner; +#if defined(KOKKOS_ENABLE_CUDA) + if constexpr (std::is_same_v) { + cudaStream_t stream = nullptr; + const cudaError_t status = cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking); + if (status != cudaSuccess) + throw PreparedStreamPartitionError(std::string("cudaStreamCreateWithFlags failed: ") + + cudaGetErrorString(status)); + owner.handle_ = reinterpret_cast(stream); + return owner; + } +#endif +#if defined(KOKKOS_ENABLE_HIP) + if constexpr (std::is_same_v) { + hipStream_t stream = nullptr; + const hipError_t status = hipStreamCreateWithFlags(&stream, hipStreamNonBlocking); + if (status != hipSuccess) + throw PreparedStreamPartitionError(std::string("hipStreamCreateWithFlags failed: ") + + hipGetErrorString(status)); + owner.handle_ = reinterpret_cast(stream); + return owner; + } +#endif + throw PreparedStreamPartitionError( + "this Kokkos release cannot materialize native streams for the selected backend"); + } + + [[nodiscard]] ExecutionSpace execution_space() const { + if (handle_ == 0) + throw PreparedStreamPartitionError("cannot wrap an empty native accelerator stream"); +#if defined(KOKKOS_ENABLE_CUDA) + if constexpr (std::is_same_v) + return Kokkos::Cuda(reinterpret_cast(handle_)); +#endif +#if defined(KOKKOS_ENABLE_HIP) + if constexpr (std::is_same_v) + return Kokkos::HIP(reinterpret_cast(handle_)); +#endif + throw PreparedStreamPartitionError( + "native stream cannot be wrapped by the selected Kokkos execution space"); + } + + private: + void reset_() noexcept { + if (handle_ == 0) + return; + if (!Kokkos::is_initialized()) { + handle_ = 0; + return; + } +#if defined(KOKKOS_ENABLE_CUDA) + if constexpr (std::is_same_v) + (void)cudaStreamDestroy(reinterpret_cast(handle_)); +#endif +#if defined(KOKKOS_ENABLE_HIP) + if constexpr (std::is_same_v) + (void)hipStreamDestroy(reinterpret_cast(handle_)); +#endif + handle_ = 0; + } + + std::uintptr_t handle_ = 0; +}; + +template +struct PreparedExecutionInstances { + std::vector> owned_native_streams; + std::vector instances; + const char* mechanism = "unavailable"; +}; + +template +[[nodiscard]] PreparedExecutionInstances prepare_execution_instances( + const ExecutionSpace& base_instance, const std::vector& weights) { + PreparedExecutionInstances prepared; +#if POPS_KOKKOS_HAS_PARTITION_SPACE + prepared.instances = Kokkos::Experimental::partition_space(base_instance, weights); + prepared.mechanism = "Kokkos::Experimental::partition_space"; +#else + (void)base_instance; + prepared.instances.reserve(weights.size()); + prepared.owned_native_streams.reserve(weights.size()); + for (std::size_t lane = 0; lane < weights.size(); ++lane) { + OwnedNativeStream owner = OwnedNativeStream::create(); + prepared.instances.push_back(owner.execution_space()); + prepared.owned_native_streams.push_back(std::move(owner)); + } + prepared.mechanism = "Kokkos-native-stream-wrapper"; +#endif + return prepared; +} + +} // namespace detail + +/// Reviewable facts established while the stream/workspace partition is prepared. +/// +/// ``independent_streams`` is deliberately narrower than "partition_space returned N objects": it +/// is true only for a Kokkos accelerator backend that creates native queues/streams and only after +/// every returned instance identifier has been proved distinct. Runtime overlap is not claimed +/// here; it must be measured by the out-of-CI hardware campaign. +struct PreparedStreamPartitionEvidence { + std::string backend; + std::vector stream_identities; + bool independent_streams = false; + bool disjoint_workspaces = false; + std::size_t workspace_values_per_stream = 0; + std::string partition_mechanism; +}; + +/// Prepared authority for concurrent accelerator kernels. +/// +/// The authority owns every execution-space instance and one device-memory workspace per lane. +/// Both are materialized before execution. ``launch_for`` takes a lane explicitly and performs no +/// PoPS allocation or global fence, allowing independent lanes to overlap. Callers synchronize +/// with ``fence(lane)`` or ``fence_all()`` only at their dependency boundary. +template +class PreparedAcceleratorStreamExecutor { + public: + using scalar_type = Scalar; + using execution_space = ExecutionSpace; + using memory_space = typename execution_space::memory_space; + using workspace_type = Kokkos::View; + + static_assert(Kokkos::is_execution_space::value, + "PreparedAcceleratorStreamExecutor requires a Kokkos execution space"); + + PreparedAcceleratorStreamExecutor(const PreparedAcceleratorStreamExecutor&) = delete; + PreparedAcceleratorStreamExecutor& operator=(const PreparedAcceleratorStreamExecutor&) = delete; + PreparedAcceleratorStreamExecutor(PreparedAcceleratorStreamExecutor&&) noexcept = default; + PreparedAcceleratorStreamExecutor& operator=(PreparedAcceleratorStreamExecutor&&) = delete; + + /// Materialize an exact stream partition and all lane-private workspaces. + /// + /// CPU execution spaces and accelerator backends for which Kokkos does not create independent + /// native queues are refused. Returning aliased instance identities is also a hard error. + [[nodiscard]] static PreparedAcceleratorStreamExecutor prepare( + std::size_t stream_count, std::size_t workspace_values_per_stream, + std::vector weights = {}) { + if (stream_count < 2) + throw std::invalid_argument("accelerator stream partition requires at least two streams"); + if (workspace_values_per_stream == 0) + throw std::invalid_argument("accelerator stream workspaces must be non-empty"); + if (workspace_values_per_stream > std::numeric_limits::max() / sizeof(scalar_type)) + throw std::overflow_error("accelerator stream workspace byte extent overflows size_t"); + if (stream_count > static_cast(std::numeric_limits::max())) + throw std::overflow_error("accelerator stream count exceeds the supported integer range"); + if (!weights.empty() && weights.size() != stream_count) + throw std::invalid_argument("accelerator stream weights must match the stream count"); + if (weights.empty()) + weights.assign(stream_count, 1.0); + if (std::any_of(weights.begin(), weights.end(), [](double weight) { return !(weight > 0.0); })) + throw std::invalid_argument("accelerator stream weights must be strictly positive"); + + if constexpr (!detail::authentic_partitioned_stream_backend) { + throw PreparedStreamPartitionError(std::string("Kokkos execution space '") + + execution_space::name() + + "' cannot prove independent accelerator streams"); + } else { + static_assert(detail::InstanceIdentifiedExecutionSpace, + "authenticated stream backends must expose an instance identifier"); + pops::detail::ensure_kokkos_initialized(); + const execution_space base_instance{}; + auto prepared = detail::prepare_execution_instances(base_instance, weights); + if (prepared.instances.size() != stream_count) + throw PreparedStreamPartitionError( + "Kokkos returned an incomplete accelerator stream partition"); + return PreparedAcceleratorStreamExecutor(std::move(prepared.owned_native_streams), + std::move(prepared.instances), + workspace_values_per_stream, prepared.mechanism); + } + } + + [[nodiscard]] static constexpr bool backend_can_partition_authentic_streams() noexcept { + return detail::authentic_partitioned_stream_backend; + } + + [[nodiscard]] std::size_t size() const noexcept { return lanes_.size(); } + [[nodiscard]] std::size_t workspace_values_per_stream() const noexcept { + return evidence_.workspace_values_per_stream; + } + [[nodiscard]] const PreparedStreamPartitionEvidence& evidence() const noexcept { + return evidence_; + } + + [[nodiscard]] const execution_space& instance(std::size_t lane) const { + return lane_(lane).instance; + } + [[nodiscard]] const workspace_type& workspace(std::size_t lane) const { + return lane_(lane).workspace; + } + [[nodiscard]] scalar_type* workspace_data(std::size_t lane) const { + return lane_(lane).workspace.data(); + } + [[nodiscard]] std::uintptr_t workspace_address(std::size_t lane) const { + return reinterpret_cast(workspace_data(lane)); + } + [[nodiscard]] const std::string& stream_identity(std::size_t lane) const { + return lane_(lane).identity; + } + + /// Submit a kernel to one exact prepared lane. This call intentionally does not fence. + template + void launch_for(std::size_t lane, const char* label, std::int64_t count, Functor functor) const { + if (label == nullptr || *label == '\0') + throw std::invalid_argument("accelerator stream kernel label must be non-empty"); + if (count < 0) + throw std::invalid_argument("accelerator stream kernel extent must be non-negative"); + if (count == 0) + return; + const Lane& selected = lane_(lane); + using policy_type = Kokkos::RangePolicy>; + Kokkos::parallel_for(label, policy_type(selected.instance, 0, count), std::move(functor)); + } + + void fence(std::size_t lane, const std::string& label = "PoPS prepared stream fence") const { + lane_(lane).instance.fence(label); + } + void fence_all() const { + for (std::size_t lane = 0; lane < lanes_.size(); ++lane) + fence(lane, "PoPS prepared stream partition fence"); + } + + private: + struct Lane { + execution_space instance; + workspace_type workspace; + std::string identity; + }; + + PreparedAcceleratorStreamExecutor( + std::vector> owned_native_streams, + std::vector instances, std::size_t workspace_values_per_stream, + const char* partition_mechanism) + : owned_native_streams_(std::move(owned_native_streams)) { + lanes_.reserve(instances.size()); + evidence_.backend = detail::stream_backend_name(); + evidence_.workspace_values_per_stream = workspace_values_per_stream; + evidence_.partition_mechanism = partition_mechanism; + evidence_.stream_identities.reserve(instances.size()); + + std::vector instance_ids; + instance_ids.reserve(instances.size()); + for (std::size_t lane = 0; lane < instances.size(); ++lane) { + const std::uint32_t instance_id = + static_cast(instances[lane].impl_instance_id()); + const std::string identity = evidence_.backend + ":instance=" + std::to_string(instance_id) + + ":lane=" + std::to_string(lane); + const std::string workspace_label = "pops_prepared_stream_workspace_" + std::to_string(lane); + workspace_type workspace(workspace_label, workspace_values_per_stream); + Kokkos::deep_copy(instances[lane], workspace, scalar_type{}); + lanes_.push_back({std::move(instances[lane]), std::move(workspace), identity}); + instance_ids.push_back(instance_id); + evidence_.stream_identities.push_back(identity); + } + fence_all(); + + std::sort(instance_ids.begin(), instance_ids.end()); + evidence_.independent_streams = + std::adjacent_find(instance_ids.begin(), instance_ids.end()) == instance_ids.end(); + evidence_.disjoint_workspaces = workspaces_are_disjoint_(); + if (!evidence_.independent_streams) + throw PreparedStreamPartitionError( + "Kokkos partition_space returned aliased accelerator instances"); + if (!evidence_.disjoint_workspaces) + throw PreparedStreamPartitionError("prepared accelerator stream workspaces overlap"); + } + + [[nodiscard]] const Lane& lane_(std::size_t lane) const { + if (lane >= lanes_.size()) + throw std::out_of_range("accelerator stream lane is out of range"); + return lanes_[lane]; + } + + [[nodiscard]] bool workspaces_are_disjoint_() const noexcept { + for (std::size_t lhs = 0; lhs < lanes_.size(); ++lhs) + for (std::size_t rhs = lhs + 1; rhs < lanes_.size(); ++rhs) { + const auto lhs_begin = reinterpret_cast(lanes_[lhs].workspace.data()); + const auto rhs_begin = reinterpret_cast(lanes_[rhs].workspace.data()); + const std::size_t bytes = evidence_.workspace_values_per_stream * sizeof(scalar_type); + const auto lhs_end = lhs_begin + static_cast(bytes); + const auto rhs_end = rhs_begin + static_cast(bytes); + if (lhs_begin < rhs_end && rhs_begin < lhs_end) + return false; + } + return true; + } + + // Declared before ``lanes_`` so lane-owned Kokkos instances are destroyed before their external + // CUDA/HIP streams when the compatibility route for pre-partition_space Kokkos is active. + std::vector> owned_native_streams_; + std::vector lanes_; + PreparedStreamPartitionEvidence evidence_; +}; + +} // namespace pops::runtime::accelerator + +#undef POPS_KOKKOS_HAS_PARTITION_SPACE diff --git a/include/pops/runtime/amr/amr_field_solve_transaction.hpp b/include/pops/runtime/amr/amr_field_solve_transaction.hpp index 3587c00c6..f8a79d29e 100644 --- a/include/pops/runtime/amr/amr_field_solve_transaction.hpp +++ b/include/pops/runtime/amr/amr_field_solve_transaction.hpp @@ -54,6 +54,19 @@ inline std::vector AmrRuntime::named_aux_components(const std::string* sele return {components.begin(), components.end()}; } +inline std::vector AmrRuntime::named_aux_components( + const std::vector& selected) const { + std::set components; + for (const auto& [name, field] : named_fields_) { + if (std::find(selected.begin(), selected.end(), name) == selected.end()) + continue; + detail::add_aux_component(components, field.phi_comp, aux_ncomp_); + detail::add_aux_component(components, field.gx_comp, aux_ncomp_); + detail::add_aux_component(components, field.gy_comp, aux_ncomp_); + } + return {components.begin(), components.end()}; +} + inline std::vector AmrRuntime::field_solve_aux_components(const FieldSolveScope& scope) const { std::set components; if (scope.default_field) { @@ -61,10 +74,9 @@ inline std::vector AmrRuntime::field_solve_aux_components(const FieldSolveS components.insert(defaults.begin(), defaults.end()); } if (scope.named_fields != NamedFieldSnapshotScope::kNone) { - const std::string* selected = scope.named_fields == NamedFieldSnapshotScope::kSelected - ? scope.selected_named_field - : nullptr; - const std::vector named = named_aux_components(selected); + const std::vector named = scope.named_fields == NamedFieldSnapshotScope::kSelected + ? named_aux_components(scope.selected_named_fields) + : named_aux_components(nullptr); components.insert(named.begin(), named.end()); } return {components.begin(), components.end()}; @@ -239,20 +251,21 @@ inline AmrRuntime::FieldSolveSnapshot& AmrRuntime::capture_field_solve_snapshot( ? "AmrRuntime field candidate capture requires an active transaction" : "AmrRuntime field solves are sequential and cannot be re-entered"); if (scope.named_fields == NamedFieldSnapshotScope::kSelected && - scope.selected_named_field == nullptr) - throw std::invalid_argument("selected field-solve scope requires an exact field identity"); + scope.selected_named_fields.empty()) + throw std::invalid_argument( + "selected field-solve scope requires a non-empty dependency closure"); - const std::string selected = - scope.selected_named_field == nullptr ? std::string{} : *scope.selected_named_field; const std::vector components = field_solve_aux_components(scope); const auto includes = [&](const std::string& name) { return scope.named_fields == NamedFieldSnapshotScope::kAll || - (scope.named_fields == NamedFieldSnapshotScope::kSelected && name == selected); + (scope.named_fields == NamedFieldSnapshotScope::kSelected && + std::find(scope.selected_named_fields.begin(), scope.selected_named_fields.end(), + name) != scope.selected_named_fields.end()); }; const auto same_scope = [&](const FieldSolveSnapshot& snapshot) { return snapshot.scope_default_field == scope.default_field && snapshot.scope_named_fields == scope.named_fields && - snapshot.scope_selected_named_field == selected && + snapshot.scope_selected_named_fields == scope.selected_named_fields && snapshot.candidate_slot == candidate_slot; }; const auto compatible = [&](const FieldSolveSnapshot& snapshot) { @@ -314,7 +327,7 @@ inline AmrRuntime::FieldSolveSnapshot& AmrRuntime::capture_field_solve_snapshot( candidate.topology_generation = topology_materialization_generation_; candidate.scope_default_field = scope.default_field; candidate.scope_named_fields = scope.named_fields; - candidate.scope_selected_named_field = selected; + candidate.scope_selected_named_fields = scope.selected_named_fields; candidate.candidate_slot = candidate_slot; candidate.aux_components = components; candidate.packed_aux = allocate_aux_component_carriers_(components); @@ -441,7 +454,9 @@ inline void AmrRuntime::validate_field_solve_snapshot(const FieldSolveSnapshot& const auto includes = [&](const std::string& name) { return snapshot.scope_named_fields == NamedFieldSnapshotScope::kAll || (snapshot.scope_named_fields == NamedFieldSnapshotScope::kSelected && - name == snapshot.scope_selected_named_field); + std::find(snapshot.scope_selected_named_fields.begin(), + snapshot.scope_selected_named_fields.end(), + name) != snapshot.scope_selected_named_fields.end()); }; std::size_t expected_named = 0; for (auto& [name, field] : named_fields_) { @@ -481,30 +496,36 @@ inline SolveOutcome AmrRuntime::run_field_solve_transaction(const FieldSolveScop throw std::logic_error( "AmrRuntime field solves are sequential until their prior SolveOutcome is consumed"); const long invalid_scope = scope.named_fields == NamedFieldSnapshotScope::kSelected && - scope.selected_named_field == nullptr + scope.selected_named_fields.empty() ? 1L : 0L; if (all_reduce_max(invalid_scope) != 0) - throw std::invalid_argument("selected field-solve scope requires an exact field identity"); - - bool selected_found = scope.named_fields != NamedFieldSnapshotScope::kSelected; - for (const auto& entry : named_fields_) { - const std::string& name = entry.first; - const bool included = scope.named_fields == NamedFieldSnapshotScope::kAll || - (scope.named_fields == NamedFieldSnapshotScope::kSelected && - name == *scope.selected_named_field); - selected_found = selected_found || included; + throw std::invalid_argument( + "selected field-solve scope requires a non-empty dependency closure"); + + long selection_invalid_local = 0; + if (scope.named_fields == NamedFieldSnapshotScope::kSelected) { + std::set unique; + for (const std::string& name : scope.selected_named_fields) { + selection_invalid_local = std::max(selection_invalid_local, + named_fields_.find(name) == named_fields_.end() ? 1L : 0L); + selection_invalid_local = + std::max(selection_invalid_local, unique.insert(name).second ? 0L : 2L); + } } - if (all_reduce_min(selected_found ? 1L : 0L) == 0) - throw std::runtime_error("selected field-solve scope names an unknown AMR field"); + if (all_reduce_max(selection_invalid_local) != 0) + throw std::runtime_error( + "selected field-solve scope has an unknown or duplicate AMR dependency"); std::exception_ptr materialization_error; long materialization_failed_local = 0; try { for (auto& [name, field] : named_fields_) { - const bool included = scope.named_fields == NamedFieldSnapshotScope::kAll || - (scope.named_fields == NamedFieldSnapshotScope::kSelected && - name == *scope.selected_named_field); + const bool included = + scope.named_fields == NamedFieldSnapshotScope::kAll || + (scope.named_fields == NamedFieldSnapshotScope::kSelected && + std::find(scope.selected_named_fields.begin(), scope.selected_named_fields.end(), + name) != scope.selected_named_fields.end()); if (!included) continue; // Accept must be a copy-only operation. Materialize lazy providers before the immutable diff --git a/include/pops/runtime/amr/amr_history.hpp b/include/pops/runtime/amr/amr_history.hpp index f42cde8a6..db489f09e 100644 --- a/include/pops/runtime/amr/amr_history.hpp +++ b/include/pops/runtime/amr/amr_history.hpp @@ -509,47 +509,118 @@ struct AmrHistoryOps { // (level pk) is stable. No-op when no ring exists. static void remap_rings(AmrRuntime& eng, const BoxArray& fb, const DistributionMapping& dmap, int fk, int pk, bool prolong) { + const CommunicatorView communicator = world_communicator_view(); + std::string registry_contract; + regrid_detail::collective_stage("AMR history remap registry", communicator, [&] { + const std::size_t registry_size = eng.hist_rings_.size(); + if (eng.hist_depth_.size() != registry_size || + eng.hist_block_owner_.size() != registry_size || eng.hist_init_.size() != registry_size || + eng.hist_fill_count_.size() != registry_size || + eng.hist_store_pending_.size() != registry_size || + eng.hist_slot_dt_.size() != registry_size) + throw std::runtime_error("AMR history remap registry is incomplete"); + if (pk < 0 || fk != pk + 1 || + eng.hierarchy_.refinement_ratios.size() <= static_cast(pk)) + throw std::runtime_error("AMR history remap transition is invalid"); + ExactContractBuilder contract; + contract.text("pops.amr.history-remap-registry") + .scalar(std::uint32_t{1}) + .scalar(fk) + .scalar(pk) + .scalar(static_cast(prolong ? 1 : 0)) + .scalar(static_cast(eng.hist_rings_.size())); + for (const auto& [name, ring] : eng.hist_rings_) { + const std::size_t owner = eng.hist_block_owner_.at(name); + const std::size_t depth = static_cast(eng.hist_depth_.at(name)); + const std::size_t metadata_levels = eng.hist_init_.at(name).size(); + if (owner >= eng.blocks_.size() || depth < 2 || ring.size() != depth || + eng.hist_fill_count_.at(name).size() != metadata_levels || + eng.hist_store_pending_.at(name).size() != metadata_levels || + eng.hist_slot_dt_.at(name).size() != depth) + throw std::runtime_error("AMR history remap entry is incomplete"); + std::optional level_existed; + std::optional slot_levels; + for (const auto& slot : ring) { + if (slot.size() <= static_cast(pk)) + throw std::runtime_error("AMR history remap slot is missing its parent level"); + const bool slot_existed = slot.size() > static_cast(fk); + if (level_existed && *level_existed != slot_existed) + throw std::runtime_error("AMR history remap slots disagree on active levels"); + if (slot_levels && *slot_levels != slot.size()) + throw std::runtime_error("AMR history remap slots disagree on level count"); + level_existed = slot_existed; + slot_levels = slot.size(); + } + if (slot_levels && metadata_levels != *slot_levels) + throw std::runtime_error("AMR history remap metadata disagrees with its slots"); + contract.text(name) + .scalar(static_cast(owner)) + .scalar(eng.hist_depth_.at(name)) + .scalar(static_cast(ring.size())); + for (const auto& slot : ring) { + contract.scalar(static_cast(slot.size())); + for (const MultiFab& field : slot) { + contract.scalar(field.ncomp()) + .scalar(field.n_grow()) + .scalar(static_cast(field.box_array().size())); + for (const Box2D& box : field.box_array().boxes()) + contract.scalar(box.lo[0]).scalar(box.lo[1]).scalar(box.hi[0]).scalar(box.hi[1]); + contract.sequence(field.dmap().ranks()); + } + } + contract.sequence(eng.hist_init_.at(name)) + .sequence(eng.hist_fill_count_.at(name)) + .sequence(eng.hist_store_pending_.at(name)) + .scalar(static_cast(eng.hist_slot_dt_.at(name).size())); + } + registry_contract = std::move(contract).release(); + }); + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{"pops.amr.history-remap-registry", registry_contract}}, communicator)) + throw std::runtime_error("AMR history remap registry differs between MPI ranks"); + for (auto& [name, ring] : eng.hist_rings_) { - const auto owner = eng.hist_block_owner_.find(name); - if (owner == eng.hist_block_owner_.end() || owner->second >= eng.blocks_.size()) - throw std::runtime_error("AMR history ring lost its owner-qualified transfer authority"); - const std::size_t block = owner->second; + const std::size_t block = eng.hist_block_owner_.at(name); bool appended_level = false; for (auto& slot : ring) { // slot = per-level vector - if (slot.size() <= static_cast(pk)) - throw std::runtime_error("AMR history ring is missing its parent level during regrid"); const bool existed = slot.size() > static_cast(fk); const int ngf = existed ? slot[static_cast(fk)].n_grow() : slot[static_cast(pk)].n_grow(); const int ncomp = slot[static_cast(pk)].ncomp(); if (!existed) { - slot.emplace_back(BoxArray{}, DistributionMapping{}, ncomp, ngf); + regrid_detail::collective_stage("AMR history remap level activation", communicator, [&] { + slot.emplace_back(BoxArray{}, DistributionMapping{}, ncomp, ngf); + }); appended_level = true; } MultiFab& fine = slot[static_cast(fk)]; if (prolong) { const int ratio = eng.hierarchy_.refinement_ratios[static_cast(pk)]; - fine = eng.regrid_block_field(block, fb, dmap, slot[static_cast(pk)], fine, - pk, ngf, ratio); + MultiFab candidate = eng.regrid_block_field( + block, fb, dmap, slot[static_cast(pk)], fine, pk, ngf, ratio); + regrid_detail::collective_stage("AMR history remap slot publication", communicator, + [&] { fine = std::move(candidate); }); } else { - fine = MultiFab(fb, dmap, ncomp, ngf); + regrid_detail::collective_stage("AMR history remap slot allocation", communicator, + [&] { fine = MultiFab(fb, dmap, ncomp, ngf); }); } } if (appended_level) { - auto initialized = eng.hist_init_.find(name); - auto fill_count = eng.hist_fill_count_.find(name); - auto pending = eng.hist_store_pending_.find(name); - if (initialized == eng.hist_init_.end() || fill_count == eng.hist_fill_count_.end() || - pending == eng.hist_store_pending_.end() || - initialized->second.size() != static_cast(pk + 1) || - fill_count->second.size() != static_cast(pk + 1) || - pending->second.size() != static_cast(pk + 1)) - throw std::runtime_error("AMR history initialization mask disagrees with activation"); - initialized->second.push_back(prolong ? initialized->second[static_cast(pk)] - : char(0)); - fill_count->second.push_back(prolong ? fill_count->second[static_cast(pk)] - : 0); - pending->second.push_back(0); + regrid_detail::collective_stage( + "AMR history remap metadata publication", communicator, [&] { + auto& initialized = eng.hist_init_.at(name); + auto& fill_count = eng.hist_fill_count_.at(name); + auto& pending = eng.hist_store_pending_.at(name); + if (initialized.size() != static_cast(pk + 1) || + fill_count.size() != static_cast(pk + 1) || + pending.size() != static_cast(pk + 1)) + throw std::runtime_error( + "AMR history initialization mask disagrees with " + "activation"); + initialized.push_back(prolong ? initialized[static_cast(pk)] : char(0)); + fill_count.push_back(prolong ? fill_count[static_cast(pk)] : 0); + pending.push_back(0); + }); } } } diff --git a/include/pops/runtime/amr/amr_program_reflux.hpp b/include/pops/runtime/amr/amr_program_reflux.hpp index 96ab1bd65..838b73ed6 100644 --- a/include/pops/runtime/amr/amr_program_reflux.hpp +++ b/include/pops/runtime/amr/amr_program_reflux.hpp @@ -525,14 +525,19 @@ inline void sample_fine_role_strip(const MultiFab& state, const MultiFab& Fx, co /// per (cell,direction) (ADC-636 ownership: each C/F face is owned by the rank holding the covering fine /// patch), so the gather is associativity-free -> distributed == replicated bit-for-bit. inline void route_reflux_program(AmrRuntime& eng, std::size_t b, int k, const EdgeFlux& coarse_role, - const EdgeFlux& fine_role) { + const EdgeFlux& fine_role, const amr::ClockStamp& logical_time, + std::vector* integrated_state_correction = nullptr) { MultiFab& Uc = eng.level_state(b, k - 1); // the PARENT (coarse) live state we correct const BoxArray child_ba = eng.level_state(b, k).box_array(); // GLOBAL level-k patches - if (child_ba.size() == 0) + if (child_ba.size() == 0) { + if (integrated_state_correction != nullptr) + integrated_state_correction->assign(static_cast(Uc.ncomp()), Real(0)); return; + } const Geometry gc = eng.level_geom(k - 1); eng.prepared_reflux_transition(b, k).synchronize_integrated( - Uc, gc.dx(), gc.dy(), coarse_role.coarse, fine_role.fine, world_communicator_view()); + Uc, gc.dx(), gc.dy(), coarse_role.coarse, fine_role.fine, world_communicator_view(), + &logical_time, integrated_state_correction); } } // namespace detail diff --git a/include/pops/runtime/amr/amr_restore.hpp b/include/pops/runtime/amr/amr_restore.hpp index b1a9076e7..80a7c0488 100644 --- a/include/pops/runtime/amr/amr_restore.hpp +++ b/include/pops/runtime/amr/amr_restore.hpp @@ -305,6 +305,214 @@ inline void AmrRuntime::rebuild_hierarchy(const std::vector= nlev_) + throw std::out_of_range("AMR rebalance currently accepts only an active fine level"); + if (!hierarchy_.load_balance) + throw std::logic_error("AMR hierarchy has no prepared load-balance authority"); + }); + const std::size_t index = static_cast(level); + return hierarchy_.load_balance->decide_rebalance( + level, hierarchy_.ba[index], hierarchy_.dm[index], n_ranks(), topology_epoch_, + topology_materialization_generation_, estimates, policy, communicator); +} + +inline bool AmrRuntime::apply_rebalance_decision(int level, const RebalanceDecision& decision) { + const CommunicatorView communicator = world_communicator_view(); + std::string live_contract; + std::int64_t moved_patches = 0; + detail::collective_load_balance_preflight("AMR rebalance migration preflight", communicator, [&] { + if (communicator.size() != n_ranks() || communicator.rank() != my_rank()) + throw std::invalid_argument( + "AMR rebalance communicator does not preserve the hierarchy rank space"); + if (level <= 0 || level >= nlev_) + throw std::out_of_range("AMR rebalance currently accepts only an active fine level"); + if (step_rollback_scope_active() || field_solve_transaction_active() || boundary_stage_states_) + throw std::logic_error("AMR rebalance requires a clean accepted runtime boundary"); + if (decision.topology_epoch != topology_epoch_ || + decision.materialization_generation != topology_materialization_generation_) + throw std::invalid_argument("AMR rebalance decision targets stale topology or storage"); + if (decision.source_contract.empty() || decision.exact_contract.empty() || + decision.exact_contract != detail::exact_rebalance_decision(decision)) + throw std::invalid_argument("AMR rebalance decision exact contract is invalid"); + + const std::size_t index = static_cast(level); + const BoxArray& boxes = hierarchy_.ba[index]; + const DistributionMapping& current = hierarchy_.dm[index]; + for (const auto& [name, field] : bootstrap_staggered_fields_) { + (void)name; + if (field.levels.size() > index) + throw std::logic_error( + "AMR rebalance does not yet support materialized staggered bootstrap fields"); + } + if (!hierarchy_.load_balance) + throw std::logic_error("AMR hierarchy has no prepared load-balance authority"); + live_contract = detail::exact_rebalance_source( + hierarchy_.load_balance->semantic_identity(), + hierarchy_.load_balance->collective_contract(), level, n_ranks(), topology_epoch_, + topology_materialization_generation_, boxes, current); + if (decision.source_contract != live_contract) + throw std::invalid_argument( + "AMR rebalance decision does not target the live prepared level authority"); + if (boxes.size() <= 0 || current.size() != boxes.size() || + decision.proposed_mapping.size() != boxes.size()) + throw std::invalid_argument("AMR rebalance decision does not match the active fine BoxArray"); + for (int patch = 0; patch < boxes.size(); ++patch) { + const int owner = decision.proposed_mapping[patch]; + if (owner < 0 || owner >= n_ranks()) + throw std::invalid_argument("AMR rebalance decision contains an invalid owner rank"); + if (owner != current[patch]) + ++moved_patches; + } + if (decision.moved_patches != moved_patches || decision.migration_bytes < 0 || + decision.migration_nanoseconds < 0 || decision.current_max_nanoseconds_per_step <= 0 || + decision.proposed_max_nanoseconds_per_step <= 0 || + !std::isfinite(decision.current_imbalance) || !std::isfinite(decision.proposed_imbalance) || + !std::isfinite(decision.predicted_net_speedup) || decision.current_imbalance < 1.0 || + decision.proposed_imbalance < 1.0 || decision.predicted_net_speedup <= 0.0) + throw std::invalid_argument("AMR rebalance decision metrics are incomplete or inconsistent"); + + switch (decision.reason) { + case RebalanceReason::MappingUnchanged: + if (decision.accepted || moved_patches != 0) + throw std::invalid_argument( + "AMR rebalance unchanged decision disagrees with the live mapping"); + break; + case RebalanceReason::NetBenefit: + if (!decision.accepted || moved_patches == 0) + throw std::invalid_argument( + "AMR rebalance accepted decision has no beneficial migration"); + break; + case RebalanceReason::InsufficientNetBenefit: + if (decision.accepted || moved_patches == 0) + throw std::invalid_argument( + "AMR rebalance refusal disagrees with the proposed migration"); + break; + case RebalanceReason::EmptyHierarchy: + throw std::invalid_argument( + "AMR rebalance cannot apply an empty-hierarchy decision to an active fine level"); + default: + throw std::invalid_argument("AMR rebalance decision reason is unsupported"); + } + }); + + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{"pops.amr.rebalance-source", live_contract}, + {"pops.amr.rebalance-decision", decision.exact_contract}}, + communicator)) + throw std::invalid_argument( + "AMR rebalance live hierarchy or decision differs across MPI ranks"); + if (!decision.accepted) + return false; + + StepSnapshot accepted; + detail::collective_load_balance_preflight("AMR rebalance snapshot capture", communicator, + [&] { capture_step_snapshot(accepted); }); + + const std::size_t index = static_cast(level); + const int parent_level = level - 1; + int refinement_ratio = 0; + std::optional boxes; + std::optional migrated_aux; + detail::collective_load_balance_preflight("AMR rebalance carrier allocation", communicator, [&] { + // Aux fields are not part of a block's conservative prolongation route. Prepare an exact + // owner-only copy before mutating the hierarchy; field publication may refresh derived ghosts + // and provider-owned components only after these accepted valid cells are restored. + boxes.emplace(hierarchy_.ba[index]); + refinement_ratio = hierarchy_.refinement_ratios[static_cast(parent_level)]; + migrated_aux.emplace(*boxes, decision.proposed_mapping, aux_[index].ncomp(), + aux_[index].n_grow()); + }); + + std::exception_ptr migration_failure; + try { + regrid_detail::collective_stage("AMR rebalance aux redistribution", communicator, [&] { + parallel_copy(*migrated_aux, aux_[index], communicator); + }); + + materialize_regrid_transition_(parent_level, *boxes, decision.proposed_mapping, + refinement_ratio); + detail::collective_load_balance_preflight( + "AMR rebalance carrier publication", communicator, [&] { + aux_[index] = std::move(*migrated_aux); + for (auto& block : blocks_) + for (int active_level = 0; active_level < nlev_; ++active_level) + (*block.levels)[static_cast(active_level)].aux = + &aux_[static_cast(active_level)]; + }); + + regrid_detail::collective_stage("AMR rebalance topology publication", communicator, [&] { + invalidate_named_field_topology(); + record_topology_replacement_(); + }); + regrid_detail::collective_stage("AMR rebalance field publication", communicator, [&] { + require_solved_field_outcome(solve_fields(), + "AmrRuntime::apply_rebalance_decision publication"); + }); + regrid_detail::collective_stage("AMR rebalance boundary publication", communicator, + [&] { materialize_boundary_sessions_(); }); + + detail::collective_load_balance_preflight( + "AMR rebalance publication validation", communicator, [&] { + const auto& reference = *blocks_.front().levels; + for (std::size_t block = 0; block < blocks_.size(); ++block) { + const auto& levels = *blocks_[block].levels; + if (levels.size() != reference.size()) + throw std::runtime_error( + "AMR rebalance produced different level " + "counts across blocks"); + if (levels[index].U.box_array().boxes() != boxes->boxes() || + levels[index].U.dmap().ranks() != decision.proposed_mapping.ranks()) + throw std::runtime_error( + "AMR rebalance did not publish its exact " + "owner mapping on every block"); + } + }); + require_complete_history_materialization_collective_("AmrRuntime::apply_rebalance_decision"); + regrid_detail::collective_stage("AMR rebalance final device fence", communicator, + [] { device_fence(); }); + } catch (...) { + migration_failure = std::current_exception(); + } + + const long migration_failures = all_reduce_max(migration_failure ? 1L : 0L, communicator); + if (migration_failures != 0) { + std::exception_ptr rollback_failure; + try { + restore_step_snapshot(accepted); + } catch (...) { + rollback_failure = std::current_exception(); + } + if (all_reduce_max(rollback_failure ? 1L : 0L, communicator) != 0) { + if (rollback_failure) + std::rethrow_exception(rollback_failure); + throw std::runtime_error("AMR rebalance rollback failed on another MPI rank"); + } + if (migration_failure) + std::rethrow_exception(migration_failure); + throw std::runtime_error("AMR rebalance migration failed on another MPI rank"); + } + + // Profiling is observational. It must never turn an already collectively committed hierarchy + // into a rank-local rollback attempt. + if (profiler_ != nullptr) + try { + profiler_->count("rebalance"); + profiler_->count("rebalance_moved_patches", moved_patches); + profiler_->count("rebalance_migration_bytes", decision.migration_bytes); + } catch (...) { // NOLINT(bugprone-empty-catch) -- profiling cannot invalidate publication + } + return true; +} + // --- regrid / clustering config setters (declared in amr_runtime.hpp) ----------------------------- inline void AmrRuntime::set_regrid(int every, int grow, int margin) { diff --git a/include/pops/runtime/amr/amr_runtime.hpp b/include/pops/runtime/amr/amr_runtime.hpp index ce1edf988..1e9eb126d 100644 --- a/include/pops/runtime/amr/amr_runtime.hpp +++ b/include/pops/runtime/amr/amr_runtime.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include // AmrLevelMP, mf_average_down_mb #include @@ -37,6 +38,7 @@ #include #include #include // n_ranks() / comm_active(): MPI message+reduction counts (Spec 5 criterion 43) +#include #include #include #include @@ -73,6 +75,7 @@ #include #include #include +#include #include #include @@ -124,6 +127,13 @@ struct AmrFieldSolveConfig { std::shared_ptr> boundary_parameters = std::make_shared>(); std::vector boundary_state_blocks; std::vector boundary_state_components; + std::vector boundary_field_blocks; + std::vector boundary_field_keys; + std::vector boundary_field_components; + std::vector boundary_state_buffers; + std::vector boundary_state_distributions; + std::vector boundary_field_buffers; + std::vector boundary_field_distributions; FieldBoundaryExecutionContext boundary_context{}; bool has_reaction = false; Real reaction = Real(0); @@ -194,6 +204,11 @@ inline std::string exact_amr_field_solve_config_contract(const AmrFieldSolveConf .sequence(plan.boundary_state_blocks, [](ExactContractBuilder& item, const std::string& value) { item.text(value); }) .sequence(plan.boundary_state_components) + .sequence(plan.boundary_field_blocks, + [](ExactContractBuilder& item, const std::string& value) { item.text(value); }) + .sequence(plan.boundary_field_keys, + [](ExactContractBuilder& item, const std::string& value) { item.text(value); }) + .sequence(plan.boundary_field_components) .scalar(plan.has_reaction) .scalar(plan.reaction) .scalar(plan.has_newton); @@ -227,21 +242,35 @@ class AmrPreparedFieldSolver { virtual ~AmrPreparedFieldSolver() = default; [[nodiscard]] virtual std::string_view provider_identity() const noexcept = 0; [[nodiscard]] virtual std::string_view exact_prepared_contract() const noexcept = 0; + /// Rank-local evidence produced while materializing this exact hierarchy. The runtime compares + /// these bytes only after every rank has completed construction, so a local component failure can + /// reach the outer fail-closed build consensus instead of stranding peers in a provider-owned + /// collective. Builtin providers without component-produced evidence retain the empty default. + [[nodiscard]] virtual std::string_view exact_materialization_evidence() const noexcept { + return {}; + } + /// True when the provider publishes valid-cell values only and delegates same-level, physical, + /// and coarse/fine potential halos to the runtime before centered-gradient postprocessing. + [[nodiscard]] virtual bool requires_runtime_solution_halos() const noexcept { return false; } [[nodiscard]] virtual bool couples_hierarchy_levels() const noexcept = 0; [[nodiscard]] virtual int level_count() const noexcept = 0; [[nodiscard]] virtual FieldDistribution level_distribution(int level) const = 0; virtual MultiFab& rhs_level(int level) = 0; virtual MultiFab& phi_level(int level) = 0; virtual void set_boundary_context(const FieldBoundaryExecutionContext& context) = 0; - /// Install one context whose dependency buffers and logical point belong to exactly @p level. - /// Existing one-level providers retain their source contract through set_boundary_context(). - /// A multilevel provider must opt in explicitly; the core never reuses a coarse dependency on a - /// fine solve. - virtual void set_boundary_context_for_level(int level, - const FieldBoundaryExecutionContext& context) { - if (level != 0 || level_count() != 1) - throw std::invalid_argument( - "AMR field provider has no level-qualified boundary-context route"); + /// Replace the dynamic-boundary carrier for one exact materialized hierarchy level. Provider + /// implementations rebuilt against this interface retain a fail-closed source-compatible + /// default: only level zero can reuse the historical single-context route. Providers advertising + /// genuine multilevel dynamic boundaries must override this seam instead of presenting coarse + /// dependency storage to a fine-level iterate. Binary compatibility for an already-built external + /// provider remains a separate ABI contract. Every override is a collective configuration call: + /// all communicator ranks enter with the same level sequence and must either commit the complete + /// rank-consistent carrier batch or reject it on every rank without changing the accepted batch. + virtual void set_boundary_context_at_level(int level, + const FieldBoundaryExecutionContext& context) { + if (level != 0) + throw std::runtime_error( + "AMR field provider has no level-qualified dynamic-boundary context route"); set_boundary_context(context); } [[nodiscard]] virtual const SolveReport& last_solve_report() const noexcept = 0; @@ -417,6 +446,21 @@ class AmrFieldSolverProvider { const AmrFieldSolverBuildRequest& request) const = 0; }; +namespace runtime::field { +struct PreparedFieldSolverSpec; +} +namespace component { +class LoadedComponent; +} + +/// Build the authenticated AMR adapter for one external FieldTopology@2 + FieldSolver@2 pair. +/// The provider materializes every hierarchy level in one topology/request batch and owns fresh +/// component states for exactly one regrid generation. +POPS_EXPORT std::shared_ptr make_external_amr_field_solver_provider( + runtime::field::PreparedFieldSolverSpec spec, + std::shared_ptr topology, + std::shared_ptr solver); + inline std::string exact_amr_field_solver_provider_declaration( const AmrFieldSolverProvider& provider) { std::vector capabilities = provider.capability_contracts(); @@ -755,6 +799,52 @@ struct AmrNamedAuxCopyKernel { field[static_cast(j - origin_j) * row_width + (i - origin_i)]; } }; + +inline void require_recoverable_amr_candidate( + const MultiFab& candidate, int ncomp, + const std::function& recovery, + std::string_view operation) { + const long missing = all_reduce_sum(recovery ? 0L : 1L); + if (missing != 0) + throw std::runtime_error(std::string(operation) + + ": block has no prepared variable-recovery authority"); + const long component_mismatches = all_reduce_sum(candidate.ncomp() == ncomp ? 0L : 1L); + if (component_mismatches != 0) + throw std::runtime_error(std::string(operation) + + ": candidate component count differs from its block model"); + + candidate.sync_host(); + std::vector conserved(static_cast(ncomp)); + std::vector primitive(static_cast(ncomp)); + long local_failures = 0; + for (int local = 0; local < candidate.local_size(); ++local) { + const ConstArray4 values = candidate.fab(local).const_array(); + const Box2D valid = candidate.box(local); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) { + for (int component = 0; component < ncomp; ++component) + conserved[static_cast(component)] = values(i, j, component); + try { + const RecoveryReport report = recovery(conserved.data(), primitive.data()); + const bool finite_candidate = + std::all_of(conserved.begin(), conserved.end(), + [](double value) { return std::isfinite(value); }) && + std::all_of(primitive.begin(), primitive.end(), + [](double value) { return std::isfinite(value); }); + if (!report.publication_permitted() || !finite_candidate) + ++local_failures; + } catch (...) { + ++local_failures; + } + } + } + const long failures = all_reduce_sum(local_failures); + if (failures != 0) + throw std::runtime_error(std::string(operation) + + ": prepared variable recovery rejected the candidate before " + "publication (failed cells=" + + std::to_string(failures) + ")"); +} } // namespace detail /// Type-erased closures of ONE AMR block, placed on the shared hierarchy. AMR counterpart of the @@ -767,6 +857,14 @@ struct AmrRuntimeBlock { /// Exact owner-qualified state Handle, installed by the block plan independently of whether this /// block owns a physical boundary authority. std::string state_identity; + /// Exact semantic identity and parameter bytes of the compiled transport-flux closure. + /// + /// They are populated only when the concrete model owns a reviewable spatial-provider contract + /// and the limiter/Riemann types have canonical native route tokens. Consumers such as the + /// cell-local temporal provider reject an empty pair; they never infer physics from a type-erased + /// ``std::function`` or accept an unrelated caller-supplied label. + std::string transport_flux_provider_identity; + std::string transport_flux_parameter_contract; int ncomp = 1; double gamma = static_cast(kPhysicalDefaultGamma); /// Authored per-block subdivision used by Program cadence and CFL scaling. @@ -788,6 +886,9 @@ struct AmrRuntimeBlock { /// add_coupled_source THROWS instead of falling back to component 0 (a silent fallback would apply /// the source to the wrong field). VariableSet cons_vars; + /// Prepared conservative -> primitive publication authority of the concrete block model. AMR + /// transfer/regrid candidates must pass it before replacing an accepted level. + std::function cons_to_prim; /// Level stack of the block (level 0 = coarse, > 0 = fine patches), ON the shared layout. The aux /// pointer of each AmrLevelMP is (re)wired by AmrRuntime to the SHARED aux of the level. shared_ptr: @@ -798,7 +899,6 @@ struct AmrRuntimeBlock { /// per-level closures of this block. std::shared_ptr boundary_plan; std::shared_ptr boundary_field_registry; - std::shared_ptr transport_boundary_fill; /// Prepared topology workspaces replaced transactionally after every hierarchy generation. std::optional fill_patch_plan; std::vector coarse_fine_spatial_workspaces; @@ -1101,12 +1201,9 @@ class AmrRuntime { if (block.boundary_plan && !same_periodicity(block.boundary_plan->periodicity(), base_per_)) throw std::runtime_error( "AmrRuntime prepared boundary topology differs from the shared hierarchy"); - if (block.transport_boundary_fill) - validate_amr_boundary_fill_authority(base_per_, block.transport_boundary_fill.get(), - *block.levels); - else if (!block.boundary_plan && (!base_per_.x || !base_per_.y)) + if (!block.boundary_plan && (!base_per_.x || !base_per_.y)) throw std::runtime_error( - "AmrRuntime non-periodic hierarchy has no physical boundary authority"); + "AmrRuntime non-periodic hierarchy has no prepared physical boundary plan"); } AmrHierarchyLayout coarse_hierarchy; @@ -1367,28 +1464,48 @@ class AmrRuntime { rematerialize_persistent_topology_resources_(topology_materialization_generation_); } + void require_recoverable_block_candidate_(std::size_t block, const MultiFab& candidate, + std::string_view operation) const { + if (block >= blocks_.size()) + throw std::out_of_range(std::string(operation) + ": block index is out of range"); + const AmrRuntimeBlock& runtime_block = blocks_[block]; + detail::require_recoverable_amr_candidate(candidate, runtime_block.ncomp, + runtime_block.cons_to_prim, operation); + } + MultiFab regrid_block_field(std::size_t block, const BoxArray& boxes, const DistributionMapping& distribution, const MultiFab& parent, const MultiFab& old_fine, int parent_level, int ghost_depth, int refinement_ratio) const { - if (block >= block_transfer_authorities_.size()) - throw std::runtime_error("AmrRuntime::regrid_block_field block out of range"); + const CommunicatorView communicator = world_communicator_view(); + regrid_detail::collective_stage("AMR regrid block authority", communicator, [&] { + if (block >= block_transfer_authorities_.size()) + throw std::runtime_error("AmrRuntime::regrid_block_field block out of range"); + const auto& candidate_authority = block_transfer_authorities_[block]; + if (!candidate_authority.prepared || !candidate_authority.prolongation.spatial || + candidate_authority.refinement_ratio != refinement_ratio) + throw std::runtime_error( + "AmrRuntime regrid has no compatible prepared prolongation authority"); + }); const auto& authority = block_transfer_authorities_[block]; - if (!authority.prepared || !authority.prolongation.spatial || - authority.refinement_ratio != refinement_ratio) - throw std::runtime_error( - "AmrRuntime regrid has no compatible prepared prolongation authority"); - RegridProlongation prolong = [this, &authority]( - const MultiFab& coarse, MultiFab& fine, int coarse_level, - int ratio, bool replicated_parent, const CommunicatorView&) { - authority.prolongation.spatial( - coarse, fine, - bootstrap_transfer_context(coarse, fine, coarse_level, coarse_level + 1, ratio, - replicated_parent, base_per_)); - }; - return regrid_field_on_layout_with_provider(boxes, distribution, parent, old_fine, parent_level, - ghost_depth, prolong, world_communicator_view(), - replicated_coarse_, refinement_ratio); + RegridProlongation prolong; + regrid_detail::collective_stage("AMR regrid block closure", communicator, [&] { + prolong = [this, &authority](const MultiFab& coarse, MultiFab& fine, int coarse_level, + int ratio, bool replicated_parent, const CommunicatorView&) { + authority.prolongation.spatial( + coarse, fine, + bootstrap_transfer_context(coarse, fine, coarse_level, coarse_level + 1, ratio, + replicated_parent, base_per_)); + }; + }); + MultiFab candidate = regrid_field_on_layout_with_provider( + boxes, distribution, parent, old_fine, parent_level, ghost_depth, prolong, communicator, + replicated_coarse_, refinement_ratio); + regrid_detail::collective_stage("AMR regrid candidate admissibility", communicator, [&] { + require_recoverable_block_candidate_(block, candidate, + "AmrRuntime regrid prolongation publication"); + }); + return candidate; } void restrict_block_field(std::size_t block, const MultiFab& fine, MultiFab& parent, @@ -1400,10 +1517,15 @@ class AmrRuntime { authority.refinement_ratio != refinement_ratio) throw std::runtime_error( "AmrRuntime coarsening has no compatible prepared restriction authority"); + MultiFab candidate(parent.box_array(), parent.dmap(), parent.ncomp(), parent.n_grow()); + PureFieldAlgebra::copy_allocated(candidate, parent); authority.restriction.spatial( - fine, parent, + fine, candidate, bootstrap_transfer_context(parent, fine, parent_level, parent_level + 1, refinement_ratio, parent_level == 0 && replicated_coarse_)); + require_recoverable_block_candidate_(block, candidate, "AmrRuntime restriction publication"); + PureFieldAlgebra::copy_allocated(parent, candidate); + device_fence(); } void set_tagging_program(std::vector stencils, std::vector leaves, @@ -1535,6 +1657,65 @@ class AmrRuntime { external_clustering_ = std::move(provider); } + void install_external_reflux(std::shared_ptr provider) { + const CommunicatorView communicator = world_communicator_view(); + std::exception_ptr local_failure; + try { + if (external_reflux_configured_ || bootstrap_pending_) + throw std::runtime_error( + "AmrRuntime external Reflux must be configured exactly once before bootstrap"); + } catch (...) { + local_failure = std::current_exception(); + } + if (all_reduce_max(local_failure ? std::uint64_t{1} : std::uint64_t{0}, communicator) != 0) { + if (communicator.size() == 1 && local_failure) + std::rethrow_exception(local_failure); + throw std::runtime_error( + "AmrRuntime external Reflux configuration failed on another communicator rank"); + } + + struct OptionalRefluxSelection { + const runtime::amr::PreparedRefluxComponent* provider = nullptr; + explicit operator bool() const noexcept { return provider != nullptr; } + [[nodiscard]] std::string_view collective_contract() const noexcept { + return provider == nullptr ? std::string_view{} : provider->collective_contract(); + } + }; + require_prepared_provider_collective_consensus(OptionalRefluxSelection{provider.get()}); + external_reflux_configured_ = true; + if (!provider) + return; + + external_reflux_ = std::move(provider); + local_failure = nullptr; + try { + rematerialize_persistent_topology_resources_(topology_materialization_generation_); + } catch (...) { + local_failure = std::current_exception(); + } + if (all_reduce_max(local_failure ? std::uint64_t{1} : std::uint64_t{0}, communicator) == 0) + return; + + external_reflux_.reset(); + std::exception_ptr rollback_failure; + try { + rematerialize_persistent_topology_resources_(topology_materialization_generation_); + } catch (...) { + rollback_failure = std::current_exception(); + } + external_reflux_configured_ = false; + if (all_reduce_max(rollback_failure ? std::uint64_t{1} : std::uint64_t{0}, communicator) != 0) { + if (communicator.size() == 1 && rollback_failure) + std::rethrow_exception(rollback_failure); + throw std::runtime_error( + "AmrRuntime external Reflux rollback failed on another communicator rank"); + } + if (communicator.size() == 1 && local_failure) + std::rethrow_exception(local_failure); + throw std::runtime_error( + "AmrRuntime external Reflux preparation failed on another communicator rank"); + } + /// Inject the current Program evaluation coordinate used by external Tagger/boundary component /// calls. This is not an accepted clock: it is never read for cadence/restart, is absent from /// StepSnapshot, and is overwritten by AmrProgramContext at the exact tagger/regrid boundary. @@ -1874,6 +2055,29 @@ class AmrRuntime { throw std::runtime_error("AmrRuntime::block_cons_vars : block index out of bounds"); return blocks_[b].cons_vars; } + std::string_view block_transport_flux_provider_identity(std::size_t b) const { + if (b >= blocks_.size()) + throw std::runtime_error( + "AmrRuntime::block_transport_flux_provider_identity : block index out of bounds"); + return blocks_[b].transport_flux_provider_identity; + } + std::string_view block_transport_flux_parameter_contract(std::size_t b) const { + if (b >= blocks_.size()) + throw std::runtime_error( + "AmrRuntime::block_transport_flux_parameter_contract : block index out of bounds"); + return blocks_[b].transport_flux_parameter_contract; + } + std::string_view block_state_identity(std::size_t b) const { + if (b >= blocks_.size()) + throw std::runtime_error("AmrRuntime::block_state_identity : block index out of bounds"); + return blocks_[b].state_identity; + } + bool block_has_prepared_boundary_plan(std::size_t b) const { + if (b >= blocks_.size()) + throw std::runtime_error( + "AmrRuntime::block_has_prepared_boundary_plan : block index out of bounds"); + return static_cast(blocks_[b].boundary_plan); + } std::size_t n_coupled_sources() const { return coupled_sources_.size(); } /// Read-only view of the registered coupling operators (ADC-595, parity with System): label plus the /// declared conservation / frequency contracts, in registration order, so a Program or a runtime @@ -2383,8 +2587,18 @@ class AmrRuntime { /// @{ /// The live state MultiFab of block @p b at level @p k (zero-copy; same address an AmrProgramContext /// reads each macro-step). @c b is the AMR block index (sys_block-resolved by the caller). - MultiFab& level_state(std::size_t b, int k) { return (*blocks_[b].levels)[k].U; } - const MultiFab& level_state(std::size_t b, int k) const { return (*blocks_[b].levels)[k].U; } + MultiFab& level_state(std::size_t b, int k) { + if (b >= blocks_.size() || k < 0 || k >= nlev_ || !blocks_[b].levels || + static_cast(k) >= blocks_[b].levels->size()) + throw std::out_of_range("AmrRuntime::level_state block/level index is out of range"); + return (*blocks_[b].levels)[static_cast(k)].U; + } + const MultiFab& level_state(std::size_t b, int k) const { + if (b >= blocks_.size() || k < 0 || k >= nlev_ || !blocks_[b].levels || + static_cast(k) >= blocks_[b].levels->size()) + throw std::out_of_range("AmrRuntime::level_state block/level index is out of range"); + return (*blocks_[b].levels)[static_cast(k)].U; + } /// Apply every registered coupled-source operator to one complete candidate-state pack at an exact /// AMR level. This is the Program-owned splitting primitive: it never solves fields, walks another @@ -2455,9 +2669,10 @@ class AmrRuntime { /// Geometry of level @p k: the coarse metric refined k times (dx/dy >> k, domain << k). The metric /// the per-level Laplacian / gradient / RHS read (parity with System's grid_context().geom). Geometry level_geom(int k) const { return geom_.refine(level_refinement(k)); } - /// Transport BCRec derived from the base periodicity (periodic where periodic, else Foextrap) -- the - /// SAME convention System::make_bc uses, so a Program's per-level ghost fill matches the System path. - BCRec transport_bc() const { + /// Topology-only BC descriptor used by field operators and fingerprints. Hyperbolic block + /// execution never treats it as a physical boundary authority: every non-periodic block owns a + /// PreparedBoundaryPlan and the plan performs the fill. + BCRec default_boundary_descriptor() const { BCRec b; // periodic by default if (!base_per_.x) b.xlo = b.xhi = BCType::Foextrap; @@ -2473,7 +2688,7 @@ class AmrRuntime { const Geometry geometry = level_geom(level); GridContext context; context.dom = geometry.domain; - context.bc = transport_bc(); + context.bc = default_boundary_descriptor(); context.geom = geometry; context.aux = &const_cast(aux_[static_cast(level)]); context.boundary_plan = blocks_[block].boundary_plan; @@ -2622,7 +2837,7 @@ class AmrRuntime { "AmrRuntime core RHS disagrees with the active grouped stage-state registry"); } else { boundary_stage_states_.emplace( - BoundaryStageStateView{point, nullptr, static_cast(b), &U}); + BoundaryStageStateView{point, {}, static_cast(b), &U}); stage_reset.slot = &boundary_stage_states_; } } @@ -2959,10 +3174,20 @@ class AmrRuntime { /// Real AMR multi-block residual executor. All per-block residuals on the level are complete /// before the shared pair flux is evaluated once and scattered, so neither side can consume an /// interface-incomplete residual. + void level_rhs_with_interfaces( + int k, const runtime::multiblock::BoundaryEvaluationPoint& point, + const std::vector& states, const std::vector& rhs, + const std::vector& flux_only = {}) { + level_rhs_with_interfaces( + k, point, std::span(states.data(), states.size()), + std::span(rhs.data(), rhs.size()), + std::span(flux_only.data(), flux_only.size())); + } + void level_rhs_with_interfaces(int k, const runtime::multiblock::BoundaryEvaluationPoint& point, - const std::vector& states, - const std::vector& rhs, - const std::vector& flux_only = {}) { + std::span states, + std::span rhs, + std::span flux_only = {}) { if (k < 0 || k >= nlev_ || point.level != k || states.size() != blocks_.size() || rhs.size() != blocks_.size() || (!flux_only.empty() && flux_only.size() != blocks_.size())) throw std::invalid_argument("AmrRuntime multi-block interface RHS axis mismatch"); @@ -2985,7 +3210,7 @@ class AmrRuntime { throw std::runtime_error( "AmrRuntime materialized block has no exact qualified state identity"); } - boundary_stage_states_.emplace(BoundaryStageStateView{point, &states, -1, nullptr}); + boundary_stage_states_.emplace(BoundaryStageStateView{point, states, -1, nullptr}); stage_reset.slot = &boundary_stage_states_; } for (std::size_t block = 0; block < blocks_.size(); ++block) { @@ -3059,7 +3284,8 @@ class AmrRuntime { staged[static_cast(block)] = requested_states[slot]; } - boundary_stage_states_.emplace(BoundaryStageStateView{point, &staged, -1, nullptr}); + boundary_stage_states_.emplace(BoundaryStageStateView{ + point, std::span(staged.data(), staged.size()), -1, nullptr}); struct StageStateReset { std::optional* slot; ~StageStateReset() { slot->reset(); } @@ -3096,6 +3322,40 @@ class AmrRuntime { level_rhs_with_interfaces(k, point, states, rhs, flux_only); } + /// Evaluate both perturbed endpoint residuals as one exact shared-interface transaction. + /// + /// This fixed-arity route is intentionally narrow: packed matrix-free JVP code may use it only + /// for a frozen, fully materialized two-level hierarchy with exactly two runtime blocks and one + /// prepared interface on the requested level. Stack arrays avoid allocating in a Krylov matvec. + void level_rhs_jacvec_pair( + int k, const runtime::multiblock::BoundaryEvaluationPoint& point, + std::size_t first_block, MultiFab& first_state, MultiFab& first_rhs, bool first_flux_only, + std::size_t second_block, MultiFab& second_state, MultiFab& second_rhs, + bool second_flux_only) { + if (nlev_ != 2 || max_levels() != 2 || regrid_every_ != 0) + throw std::runtime_error( + "AmrRuntime implicit interface JVP requires one frozen materialized two-level hierarchy"); + if (k < 0 || k >= nlev_ || point.level != k || blocks_.size() != 2 || + first_block >= blocks_.size() || second_block >= blocks_.size() || + first_block == second_block) + throw std::invalid_argument("AmrRuntime implicit interface JVP pair is invalid"); + interface_scheduler_.require_exact_jacvec_pair(k, first_block, second_block); + + std::array states{nullptr, nullptr}; + std::array rhs{nullptr, nullptr}; + std::array modes{0, 0}; + states[first_block] = &first_state; + states[second_block] = &second_state; + rhs[first_block] = &first_rhs; + rhs[second_block] = &second_rhs; + modes[first_block] = first_flux_only ? 1 : 0; + modes[second_block] = second_flux_only ? 1 : 0; + level_rhs_with_interfaces( + k, point, std::span(states.data(), states.size()), + std::span(rhs.data(), rhs.size()), + std::span(modes.data(), modes.size())); + } + /// Complete a Program-owned grouped capture with the ordinary canonical shared-interface /// evaluator, then publish its one level-qualified fragment into the caller's active attempt /// transaction. The scheduler still applies the one shared flux to both blocks with opposite @@ -3239,6 +3499,18 @@ class AmrRuntime { /// level; a level count over the composed max_levels is refused verbatim. void rebuild_hierarchy(const std::vector>& level_boxes, const std::vector>& level_owner_ranks); + /// Consume one collective load-balance decision at a clean accepted boundary. The scientific + /// boxes are unchanged; every block, aux carrier and history slot is redistributed onto the + /// proposed owner map before topology-bound providers are rematerialized. A stale, divergent or + /// incomplete decision fails before mutation, and any migration/publication failure restores the + /// complete accepted runtime snapshot. Level zero remains composition-owned and is not migrated by + /// this fine-level transaction. + bool apply_rebalance_decision(int level, const RebalanceDecision& decision); + /// Ask the hierarchy's immutable prepared load-balance authority for one topology-qualified + /// decision. This is the only production decision route: callers provide measurements and policy, + /// while the runtime injects the exact live level, BoxArray, owners, epoch and generation. + RebalanceDecision decide_rebalance(int level, ResourceEstimates estimates, + const RebalancePolicy& policy) const; /// Owner rank per box of level @p k (the shared layout's DistributionMapping), index-aligned with /// that level's boxes in patch_boxes(). The v3 checkpoint serializes it so a restart reproduces the /// LOCAL-fab iteration order (bit-identity of the host aggregations). Body in amr_restore.hpp. @@ -3249,12 +3521,9 @@ class AmrRuntime { std::vector level_aux_flat(int k) const; std::vector level_aux_flat_global(int k) const; void set_level_aux_flat(int k, const std::vector& v); - /// Head-of-step union-tags regrid at the Program driver's cadence. @p macro_step gates it by - /// skipping step zero and honoring regrid_every_. - void regrid_if_due(int macro_step) { - if (regrid_every_ > 0 && macro_step > 0 && macro_step % regrid_every_ == 0) - regrid(); - } + /// Prepared mesh-policy metadata consumed by the Program temporal authority. The spatial runtime + /// deliberately does not compare this interval with an accepted clock or decide when to regrid. + int regrid_interval() const noexcept { return regrid_every_; } /// @} /// Activates the UNION-TAGS REGRID at the cadence @p every (in macro-steps): every @p every @@ -3444,7 +3713,10 @@ class AmrRuntime { void set_field_boundary_dependencies(const std::string& provider_slot, const std::vector& state_blocks, - const std::vector& state_components) { + const std::vector& state_components, + const std::vector& field_blocks, + const std::vector& field_keys, + const std::vector& field_components) { auto found = named_fields_.find(provider_slot); if (found == named_fields_.end()) throw std::runtime_error("AmrRuntime: unknown field boundary-dependency slot"); @@ -3453,6 +3725,9 @@ class AmrRuntime { "AmrRuntime: field boundary dependencies require one component per state block"); found->second.plan.boundary_state_blocks = state_blocks; found->second.plan.boundary_state_components = state_components; + found->second.plan.boundary_field_blocks = field_blocks; + found->second.plan.boundary_field_keys = field_keys; + found->second.plan.boundary_field_components = field_components; invalidate_named_field_solver(found->second); } @@ -3722,28 +3997,223 @@ class AmrRuntime { /// injection. Reproduces AmrSystemCoupler::solve_fields identically, but the system RHS is assembled /// by the blocks' add_elliptic_rhs closures (Sum_b elliptic_rhs_b(U_b)) not a compile-time RhsAssembler. SolveOutcome solve_fields() { - return run_field_solve_transaction( - FieldSolveScope{true, NamedFieldSnapshotScope::kAll, nullptr}, [&]() { - SolveReport report = solve_default_field_uncommitted(); - if (!report.solved() || named_fields_.empty()) - return report; - return solve_named_fields_uncommitted(); - }); + const std::vector solve_order = + named_field_solve_order_collectively_(nullptr, /*allow_empty=*/true); + return run_field_solve_transaction(FieldSolveScope{true, NamedFieldSnapshotScope::kAll, {}}, + [&]() { + SolveReport report = solve_default_field_uncommitted(); + if (!report.solved() || solve_order.empty()) + return report; + return solve_named_fields_uncommitted(solve_order); + }); } SolveOutcome solve_default_field() { - return run_field_solve_transaction( - FieldSolveScope{true, NamedFieldSnapshotScope::kNone, nullptr}, - [&]() { return solve_default_field_uncommitted(); }); + return run_field_solve_transaction(FieldSolveScope{true, NamedFieldSnapshotScope::kNone, {}}, + [&]() { return solve_default_field_uncommitted(); }); } SolveOutcome solve_named_fields(const std::string* selected = nullptr) { + const std::vector solve_order = + named_field_solve_order_collectively_(selected, /*allow_empty=*/false); return run_field_solve_transaction( FieldSolveScope{false, selected == nullptr ? NamedFieldSnapshotScope::kAll : NamedFieldSnapshotScope::kSelected, - selected}, - [&]() { return solve_named_fields_uncommitted(selected); }); + selected == nullptr ? std::vector{} : solve_order}, + [&]() { return solve_named_fields_uncommitted(solve_order); }); + } + + /// Re-evaluate one exact named-field provider from a stage state on any materialized hierarchy + /// level. The live conservative state is restored before the returned SolveOutcome can be + /// consumed; only the provider's candidate publication remains transactional. This is the native + /// field-coupled Jacobian seam used by AmrProgramContext. + /// + SolveOutcome solve_named_fields_from_state_at( + const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& provider_slot, + std::size_t block, const MultiFab& stage_state) { + if (provider_slot.empty()) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_state_at requires an exact provider slot"); + if (point.level < 0 || point.level >= nlev_) + throw std::out_of_range("AmrRuntime::solve_named_fields_from_state_at level is out of range"); + if (block >= blocks_.size()) + throw std::out_of_range("AmrRuntime::solve_named_fields_from_state_at block is out of range"); + + MultiFab& live = (*blocks_[block].levels)[static_cast(point.level)].U; + if (!same_exact_multifab_layout_(live, stage_state)) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_state_at stage state does not match its exact " + "block/level layout"); + const std::pair scratch_key{block, point.level}; + auto insertion = named_field_stage_state_scratch_.try_emplace( + scratch_key, live.box_array(), live.dmap(), live.ncomp(), live.n_grow()); + MultiFab& accepted = insertion.first->second; + if (!same_exact_multifab_layout_(accepted, live)) + accepted = MultiFab(live.box_array(), live.dmap(), live.ncomp(), live.n_grow()); + + PureFieldAlgebra::copy_allocated(accepted, live); + try { + PureFieldAlgebra::copy_allocated(live, stage_state); + SolveOutcome outcome = solve_named_fields(&provider_slot); + PureFieldAlgebra::copy_allocated(live, accepted); + return outcome; + } catch (...) { + PureFieldAlgebra::copy_allocated(live, accepted); + throw; + } + } + + /// Re-evaluate one exact named-field provider from simultaneous stage states on one exact + /// hierarchy level. @p stage_states is indexed by runtime block; nullptr keeps that block's + /// accepted live state. Every live state is restored before the returned outcome can be consumed, + /// while the provider candidate remains private until collective Accept. + SolveOutcome solve_named_fields_from_states_at( + const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& provider_slot, + const std::vector& stage_states) { + std::string request_contract; + std::exception_ptr validation_error; + long validation_failed_local = 0; + try { + if (provider_slot.empty()) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at requires an exact provider slot"); + if (point.clock.empty() || point.tick < 0 || point.stage < 0 || point.substep < 0 || + !std::isfinite(point.dt) || point.dt <= 0.0 || !std::isfinite(point.physical_time)) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at requires a complete evaluation point"); + if (point.level < 0 || point.level >= nlev_) + throw std::out_of_range( + "AmrRuntime::solve_named_fields_from_states_at level is out of range"); + if (stage_states.size() != blocks_.size()) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at stage pack size mismatch"); + + bool has_override = false; + for (std::size_t block = 0; block < stage_states.size(); ++block) { + const MultiFab* stage = stage_states[block]; + if (stage == nullptr) + continue; + has_override = true; + MultiFab& live = (*blocks_[block].levels)[static_cast(point.level)].U; + if (!same_exact_multifab_layout_(live, *stage)) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at stage state does not match its " + "exact block/level layout"); + for (std::size_t other = 0; other < blocks_.size(); ++other) { + if (other != block && + stage == &(*blocks_[other].levels)[static_cast(point.level)].U) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at cannot borrow another block's " + "live state"); + if (other < block && stage_states[other] == stage) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at contains a duplicate stage " + "state"); + } + } + if (!has_override) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at requires at least one stage override"); + + ExactContractBuilder request; + request.text("pops.amr.named-field-stage-pack") + .scalar(std::uint32_t{1}) + .text(provider_slot) + .text(point.clock) + .scalar(point.tick) + .scalar(static_cast(point.level)) + .scalar(static_cast(point.substep)) + .scalar(static_cast(point.stage)) + .scalar(point.stage_fraction.numerator) + .scalar(point.stage_fraction.denominator) + .scalar(point.dt) + .scalar(point.physical_time) + .scalar(static_cast(stage_states.size())); + for (std::size_t block = 0; block < stage_states.size(); ++block) { + const MultiFab* stage = stage_states[block]; + request.scalar(static_cast(block)).presence(stage != nullptr); + if (stage != nullptr) + detail::append_elliptic_field_layout_contract( + request, "stage", stage->box_array(), stage->dmap(), stage->ncomp(), stage->n_grow(), + point.level == 0 && replicated_coarse_ ? FieldDistribution::Replicated + : FieldDistribution::Distributed); + } + request_contract = std::move(request).release(); + } catch (...) { + validation_error = std::current_exception(); + validation_failed_local = 1; + } + if (all_reduce_max(validation_failed_local) != 0) { + if (n_ranks() == 1 && validation_error != nullptr) + std::rethrow_exception(validation_error); + throw std::runtime_error( + "AmrRuntime::solve_named_fields_from_states_at validation failed on at least one MPI " + "rank"); + } + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{"amr-named-field-stage-pack", std::string_view(request_contract)}})) + throw std::invalid_argument( + "AmrRuntime::solve_named_fields_from_states_at request differs between MPI ranks"); + if (all_reduce_max(named_field_stage_pack_in_use_ ? 1L : 0L) != 0) + throw std::logic_error( + "AmrRuntime::solve_named_fields_from_states_at workspace is already in use"); + + struct WorkspaceUse { + bool& flag; + explicit WorkspaceUse(bool& value) : flag(value) { flag = true; } + ~WorkspaceUse() { flag = false; } + } use(named_field_stage_pack_in_use_); + + std::exception_ptr materialization_error; + long materialization_failed_local = 0; + try { + named_field_stage_restore_scratch_.clear(); + named_field_stage_restore_scratch_.reserve(stage_states.size()); + for (std::size_t block = 0; block < stage_states.size(); ++block) { + if (stage_states[block] == nullptr) + continue; + MultiFab& live = (*blocks_[block].levels)[static_cast(point.level)].U; + const std::pair scratch_key{block, point.level}; + auto insertion = named_field_stage_state_scratch_.try_emplace( + scratch_key, live.box_array(), live.dmap(), live.ncomp(), live.n_grow()); + MultiFab& accepted = insertion.first->second; + if (!same_exact_multifab_layout_(accepted, live)) + accepted = MultiFab(live.box_array(), live.dmap(), live.ncomp(), live.n_grow()); + named_field_stage_restore_scratch_.push_back({&live, &accepted}); + } + } catch (...) { + materialization_error = std::current_exception(); + materialization_failed_local = 1; + } + if (all_reduce_max(materialization_failed_local) != 0) { + named_field_stage_restore_scratch_.clear(); + if (n_ranks() == 1 && materialization_error != nullptr) + std::rethrow_exception(materialization_error); + throw std::runtime_error( + "AmrRuntime::solve_named_fields_from_states_at workspace materialization failed on at " + "least one MPI rank"); + } + + for (const auto& [live, accepted] : named_field_stage_restore_scratch_) + PureFieldAlgebra::copy_allocated(*accepted, *live); + const auto restore = [&]() { + for (const auto& [live, accepted] : named_field_stage_restore_scratch_) + PureFieldAlgebra::copy_allocated(*live, *accepted); + }; + try { + for (std::size_t block = 0; block < stage_states.size(); ++block) + if (stage_states[block] != nullptr) + PureFieldAlgebra::copy_allocated( + (*blocks_[block].levels)[static_cast(point.level)].U, + *stage_states[block]); + SolveOutcome outcome = solve_named_fields(&provider_slot); + restore(); + return outcome; + } catch (...) { + restore(); + throw; + } } [[nodiscard]] bool field_solve_transaction_active() const noexcept { @@ -3830,20 +4300,21 @@ class AmrRuntime { return report; } - /// Solves every registered NAMED elliptic field (ADC-428) on the coarse, writes phi (+ centered grad) - /// into the field's own aux components, ghost-fills them and injects coarse->fine. Mirror of the - /// default Poisson block above (steps 2-4), but each named field uses its resolved prepared provider. - /// The default phi/grad (comps 0..2) are never touched. No-op without a named field (default-only - /// path stays bit-identical). - SolveReport solve_named_fields_uncommitted(const std::string* selected = nullptr) { + /// Solves the prevalidated dependency order of NAMED elliptic fields (ADC-428), writes each + /// potential (+ centered gradient) into its own aux components, ghost-fills them and injects + /// coarse->fine. Mirror of the default Poisson block above (steps 2-4), but each named field uses + /// its resolved prepared provider. The default phi/grad (comps 0..2) are never touched. + SolveReport solve_named_fields_uncommitted(const std::vector& solve_order) { SolveReport completed; bool has_completed_solve = false; if (named_fields_.empty()) throw std::runtime_error("AmrRuntime::solve_named_fields has no registered field"); + const auto included = [&](const std::string& field) { + return std::find(solve_order.begin(), solve_order.end(), field) != solve_order.end(); + }; const Real dx = geom_.dx(), dy = geom_.dy(); - for (auto& [field, nf] : named_fields_) { - if (selected != nullptr && field != *selected) - continue; + for (const std::string& field : solve_order) { + auto& nf = named_fields_.at(field); if (!nf.has_plan) throw std::runtime_error("AmrRuntime: field provider slot '" + field + "' has no resolved install plan"); @@ -3859,53 +4330,195 @@ class AmrRuntime { throw std::runtime_error( "AmrRuntime: named elliptic field output components exceed the aux channel width"); ensure_named_elliptic(nf); - if (nf.plan.boundary_state_blocks.size() != nf.plan.boundary_state_components.size()) - throw std::runtime_error( - "AmrRuntime: field boundary dependency blocks and components differ in size"); - // These carriers outlive the provider solve below. Every hierarchy provider receives only the - // state buffers and distribution belonging to its exact AMR level; a composite provider owns - // one context per level instead of retaining a coarse pointer and reusing it on refined work. - const int boundary_levels = nf.solver->level_count(); - if (boundary_levels < 1) - throw std::runtime_error( - "AmrRuntime: field boundary provider has no materialized hierarchy level"); - std::vector> boundary_state_buffers( - static_cast(boundary_levels)); - std::vector> boundary_state_distributions( - static_cast(boundary_levels)); - if (nf.plan.has_boundary_kernel) { - for (int level = 0; level < boundary_levels; ++level) { - if (level < 0 || level >= nlev_) - throw std::runtime_error( - "AmrRuntime: field boundary context level is outside the active hierarchy"); - auto& buffers = boundary_state_buffers[static_cast(level)]; - auto& distributions = boundary_state_distributions[static_cast(level)]; - buffers.reserve(nf.plan.boundary_state_blocks.size()); - distributions.reserve(nf.plan.boundary_state_blocks.size()); - for (std::size_t index = 0; index < nf.plan.boundary_state_blocks.size(); ++index) { - const int block = block_index(nf.plan.boundary_state_blocks[index]); - if (block < 0) - throw std::runtime_error("AmrRuntime: boundary state dependency names unknown block"); - const auto& levels = *blocks_[static_cast(block)].levels; - if (static_cast(level) >= levels.size()) - throw std::runtime_error( - "AmrRuntime: boundary state dependency has no requested AMR level"); - const MultiFab& state = levels[static_cast(level)].U; - if (nf.plan.boundary_state_components[index] < 0 || - nf.plan.boundary_state_components[index] >= state.ncomp()) - throw std::runtime_error("AmrRuntime: boundary state component is out of range"); - buffers.push_back(&state); - distributions.push_back(level == 0 && replicated_coarse_ - ? FieldDistribution::Replicated - : FieldDistribution::Distributed); + const bool has_boundary_dependencies = + !nf.plan.boundary_state_blocks.empty() || !nf.plan.boundary_field_blocks.empty(); + if (nf.plan.has_boundary_kernel && has_boundary_dependencies) { + const int levels = nf.solver->level_count(); + long dependency_error = 0; + if (nf.plan.boundary_state_blocks.size() != nf.plan.boundary_state_components.size() || + nf.plan.boundary_field_blocks.size() != nf.plan.boundary_field_keys.size() || + nf.plan.boundary_field_blocks.size() != nf.plan.boundary_field_components.size()) + dependency_error = 1; + if (levels != nlev_) + dependency_error = std::max(dependency_error, 2L); + if (dependency_error == 0) { + for (int level = 0; level < levels; ++level) + for (std::size_t index = 0; index < nf.plan.boundary_state_blocks.size(); ++index) { + const int raw_block = block_index(nf.plan.boundary_state_blocks[index]); + if (raw_block < 0) { + dependency_error = std::max(dependency_error, 3L); + continue; + } + const std::size_t block = static_cast(raw_block); + const MultiFab& accepted = + (*blocks_[block].levels)[static_cast(level)].U; + const MultiFab* state = &accepted; + if (boundary_stage_states_ && boundary_stage_states_->point.level == level) { + MultiFab* staged = boundary_stage_states_->state(block); + if (staged != nullptr) { + if (!same_exact_multifab_layout_(accepted, *staged)) + dependency_error = std::max(dependency_error, 4L); + state = staged; + } + } + if (nf.plan.boundary_state_components[index] < 0 || + nf.plan.boundary_state_components[index] >= state->ncomp()) + dependency_error = std::max(dependency_error, 5L); + } + for (std::size_t index = 0; index < nf.plan.boundary_field_blocks.size(); ++index) { + const std::string* dependency_slot = unique_boundary_field_dependency_slot_( + nf, nf.plan.boundary_field_blocks[index], nf.plan.boundary_field_keys[index]); + if (dependency_slot == nullptr) { + dependency_error = std::max(dependency_error, 6L); + continue; + } + const auto dependency = named_fields_.find(*dependency_slot); + if (dependency == named_fields_.end() || !dependency->second.solver || + !dependency->second.solver->last_solve_report().solved_value_available()) { + dependency_error = std::max(dependency_error, 7L); + continue; + } + if (dependency->second.solver->level_count() != levels) { + dependency_error = std::max(dependency_error, 8L); + continue; + } + for (int level = 0; level < levels; ++level) { + const MultiFab& value = dependency->second.solver->phi_level(level); + if (nf.plan.boundary_field_components[index] < 0 || + nf.plan.boundary_field_components[index] >= value.ncomp()) + dependency_error = std::max(dependency_error, 9L); + } + } + } + dependency_error = all_reduce_max(dependency_error); + if (dependency_error != 0) + throw std::runtime_error( + "AmrRuntime: level-qualified boundary state/field materialization failed " + "collectively " + "(code " + + std::to_string(dependency_error) + ")"); + std::vector prepared_contexts; + long preparation_error = 0; + try { + prepared_contexts.resize(static_cast(levels)); + for (int level = 0; level < levels; ++level) { + auto& carrier = prepared_contexts[static_cast(level)]; + carrier.state_buffers.reserve(nf.plan.boundary_state_blocks.size()); + carrier.state_distributions.reserve(nf.plan.boundary_state_blocks.size()); + carrier.state_identities.reserve(nf.plan.boundary_state_blocks.size()); + carrier.field_buffers.reserve(nf.plan.boundary_field_blocks.size()); + carrier.field_distributions.reserve(nf.plan.boundary_field_blocks.size()); + carrier.field_identities.reserve(nf.plan.boundary_field_blocks.size()); + for (std::size_t index = 0; index < nf.plan.boundary_state_blocks.size(); ++index) { + const int raw_block = block_index(nf.plan.boundary_state_blocks[index]); + const std::size_t block = static_cast(raw_block); + const MultiFab& accepted = + (*blocks_[block].levels)[static_cast(level)].U; + const MultiFab* state = &accepted; + if (boundary_stage_states_ && boundary_stage_states_->point.level == level) { + MultiFab* staged = boundary_stage_states_->state(block); + if (staged != nullptr) + state = staged; + } + carrier.state_buffers.push_back(state); + carrier.state_distributions.push_back(level == 0 && replicated_coarse_ + ? FieldDistribution::Replicated + : FieldDistribution::Distributed); + ExactContractBuilder identity; + identity.text("amr-boundary-state") + .text(nf.plan.boundary_state_blocks[index]) + .scalar(static_cast(nf.plan.boundary_state_components[index])); + carrier.state_identities.push_back(std::move(identity).release()); + } + for (std::size_t index = 0; index < nf.plan.boundary_field_blocks.size(); ++index) { + const std::string* dependency_slot = unique_boundary_field_dependency_slot_( + nf, nf.plan.boundary_field_blocks[index], nf.plan.boundary_field_keys[index]); + auto& dependency = named_fields_.at(*dependency_slot); + carrier.field_buffers.push_back(&dependency.solver->phi_level(level)); + carrier.field_distributions.push_back(dependency.solver->level_distribution(level)); + ExactContractBuilder identity; + identity.text("amr-boundary-field") + .text(nf.plan.boundary_field_blocks[index]) + .text(nf.plan.boundary_field_keys[index]) + .scalar(static_cast(nf.plan.boundary_field_components[index])) + .text(*dependency_slot); + carrier.field_identities.push_back(std::move(identity).release()); + } + carrier.context = nf.plan.boundary_context; + // The materialized hierarchy level, not the unqualified authoring baseline, owns the + // boundary evaluation point consumed by both level-local and composite providers. + carrier.context.point.level = level; + carrier.context.states = carrier.state_buffers.data(); + carrier.context.state_distributions = carrier.state_distributions.data(); + carrier.context.state_identities = carrier.state_identities.data(); + carrier.context.state_count = static_cast(carrier.state_buffers.size()); + carrier.context.fields = carrier.field_buffers.data(); + carrier.context.field_distributions = carrier.field_distributions.data(); + carrier.context.field_identities = carrier.field_identities.data(); + carrier.context.field_count = static_cast(carrier.field_buffers.size()); } - FieldBoundaryExecutionContext context = nf.plan.boundary_context; - context.point.level = level; - context.states = buffers.empty() ? nullptr : buffers.data(); - context.state_distributions = distributions.empty() ? nullptr : distributions.data(); - context.state_count = static_cast(buffers.size()); - nf.solver->set_boundary_context_for_level(level, context); + } catch (...) { + preparation_error = 10; + } + preparation_error = all_reduce_max(preparation_error); + if (preparation_error != 0) + throw std::runtime_error( + "AmrRuntime: level-qualified boundary state/field carriers could not be prepared " + "collectively"); + const bool transactional_composite_refresh = nf.solver->couples_hierarchy_levels(); + std::vector* installation_contexts = &prepared_contexts; + if (!transactional_composite_refresh) { + // Level-local providers consume each carrier independently. Preserve their established + // stable-owner route; composite providers instead stage the complete batch below. + nf.boundary_level_contexts = std::move(prepared_contexts); + installation_contexts = &nf.boundary_level_contexts; + } + long installation_error = 0; + std::exception_ptr installation_exception; + try { + for (int level = 0; level < levels; ++level) { + auto& carrier = installation_contexts->at(static_cast(level)); + carrier.context.states = carrier.state_buffers.data(); + carrier.context.state_distributions = carrier.state_distributions.data(); + carrier.context.state_identities = carrier.state_identities.data(); + carrier.context.fields = carrier.field_buffers.data(); + carrier.context.field_distributions = carrier.field_distributions.data(); + carrier.context.field_identities = carrier.field_identities.data(); + nf.solver->set_boundary_context_at_level(level, carrier.context); + } + } catch (...) { + installation_error = 11; + installation_exception = std::current_exception(); + } + installation_error = all_reduce_max(installation_error); + if (installation_error != 0) { + if (n_ranks() == 1 && installation_exception) + std::rethrow_exception(installation_exception); + throw std::runtime_error( + "AmrRuntime: prepared field provider refused at least one exact level-qualified " + "boundary carrier collectively"); + } + if (transactional_composite_refresh) { + // Moving the vector transfers its backing allocation, so every pointer installed in the + // provider remains stable. The previously accepted carriers stay alive until the complete + // provider batch succeeds; a late refusal therefore cannot leave its active context + // dangling. + static_assert( + std::is_nothrow_move_assignable_v>); + nf.boundary_level_contexts = std::move(prepared_contexts); } + } else if (nf.plan.has_boundary_kernel) { + FieldBoundaryExecutionContext context = nf.plan.boundary_context; + context.states = nullptr; + context.state_distributions = nullptr; + context.state_identities = nullptr; + context.state_count = 0; + context.fields = nullptr; + context.field_distributions = nullptr; + context.field_identities = nullptr; + context.field_count = 0; + nf.boundary_level_contexts.clear(); + nf.solver->set_boundary_context(context); } prepare_named_field_providers(nf); prepare_named_rhs_scratch_(nf); @@ -3959,6 +4572,8 @@ class AmrRuntime { MultiFab& phi_mf = nf.solver->phi_level(0); if (!nf.level_nullspace.empty()) nf.level_nullspace_workspaces[0]->apply_gauge(phi_mf); + const BCRec boundary = nf.plan.has_explicit_bc ? nf.plan.explicit_bc : bcPhi_; + materialize_named_phi_halos_(nf, boundary); device_fence(); const int cphi = nf.phi_comp, cgx = nf.gx_comp, cgy = nf.gy_comp; const Real gradient_scale = static_cast(nf.gradient_sign); @@ -3976,7 +4591,7 @@ class AmrRuntime { // Composite and level-local fields own a solved potential on every level. Write every valid // value before materialising halos so no coarse injection can overwrite refined solutions. for (auto& [field, nf] : named_fields_) { - if (selected != nullptr && field != *selected) + if (!included(field)) continue; if (nf.solver->level_count() <= 1) continue; @@ -3985,10 +4600,14 @@ class AmrRuntime { const bool grad = nf.gx_comp >= 0 && nf.gy_comp >= 0; if (nf.solver->couples_hierarchy_levels()) nf.nullspace_workspace->apply_gauge(nf.nullspace_phi_levels); + if (!nf.solver->couples_hierarchy_levels() && !nf.level_nullspace.empty()) + for (int k = 0; k < nf.solver->level_count(); ++k) + nf.level_nullspace_workspaces[static_cast(k)]->apply_gauge( + nf.solver->phi_level(k)); + const BCRec boundary = nf.plan.has_explicit_bc ? nf.plan.explicit_bc : bcPhi_; + materialize_named_phi_halos_(nf, boundary); for (int k = 0; k < nf.solver->level_count(); ++k) { MultiFab& phi = nf.solver->phi_level(k); - if (!nf.solver->couples_hierarchy_levels() && !nf.level_nullspace.empty()) - nf.level_nullspace_workspaces[static_cast(k)]->apply_gauge(phi); const Real refinement = static_cast(level_refinement(k)); const Real level_dx = geom_.dx() / refinement; const Real level_dy = geom_.dy() / refinement; @@ -4011,7 +4630,7 @@ class AmrRuntime { components.insert(component); }; for (const auto& [field, nf] : named_fields_) { - if (selected != nullptr && field != *selected) + if (!included(field)) continue; add_component(nf.phi_comp); add_component(nf.gx_comp); @@ -4062,7 +4681,8 @@ class AmrRuntime { throw std::runtime_error("AMR Tagger requires unique qualified state storage routes"); staged[block] = &(*blocks_[block].levels)[static_cast(level)].U; } - boundary_stage_states_.emplace(BoundaryStageStateView{point, &staged, -1, nullptr}); + boundary_stage_states_.emplace(BoundaryStageStateView{ + point, std::span(staged.data(), staged.size()), -1, nullptr}); struct StageStateReset { std::optional* slot; ~StageStateReset() { slot->reset(); } @@ -4080,8 +4700,10 @@ class AmrRuntime { } if (block.boundary_plan) throw std::runtime_error("AMR Tagger boundary plan has no persistent prepared session"); + if (!base_per_.x || !base_per_.y) + throw std::runtime_error("AMR Tagger non-periodic block has no prepared boundary session"); fill_level_state_cf_ghosts(block_index, level, state); - fill_ghosts(state, domain, transport_bc()); + fill_boundary(state, domain, base_per_); } if (gradient_shared_aux) fill_ghosts(aux_.at(static_cast(level)), domain, aux_bc_); @@ -4329,18 +4951,31 @@ class AmrRuntime { } void commit_bootstrap_level() { - if (!bootstrap_pending_) + const long missing_transaction = all_reduce_sum(bootstrap_pending_ ? 0L : 1L); + if (missing_transaction != 0) throw std::runtime_error("AmrRuntime::commit_bootstrap_level : no pending transaction"); // AmrRuntime is a spatial hierarchy service and deliberately owns no accepted clock. The // AmrSystem facade validates its authoritative (time, macro_step) before entering or committing // a public bootstrap transaction; direct runtime users are responsible for their own scheduler. + std::string stale_cache; for (const auto& [subject, cache] : bootstrap_caches_) - if (!cache.valid || cache.materialized_level != nlev_ - 1) - throw std::runtime_error("AmrRuntime::commit_bootstrap_level has a stale cache '" + - subject + "'"); - // Bootstrap already owns a rank-coherent outer transaction; keep this final check local because - // the preceding pending/cache refusals are local as well. Introducing a collective only here - // would strand peers when one of those earlier conditions differs. + if (!cache.valid || cache.materialized_level != nlev_ - 1) { + stale_cache = subject; + break; + } + const long stale_caches = all_reduce_sum(stale_cache.empty() ? 0L : 1L); + if (stale_caches != 0) + throw std::runtime_error( + "AmrRuntime::commit_bootstrap_level has a stale cache" + + (stale_cache.empty() ? std::string(" on another rank") : " '" + stale_cache + "'")); + for (std::size_t block = 0; block < blocks_.size(); ++block) + for (int level = 0; level < nlev_; ++level) + require_recoverable_block_candidate_( + block, (*blocks_[block].levels)[static_cast(level)].U, + "AmrRuntime bootstrap state publication for block '" + blocks_[block].name + + "' level " + std::to_string(level)); + // Bootstrap owns a rank-coherent outer transaction. Pending/cache/recovery preflight above is + // collective, so no rank can publish while a peer refuses the same candidate hierarchy. require_complete_history_materialization_("AmrRuntime::commit_bootstrap_level"); bootstrap_interface_registry_size_ = 0; bootstrap_pending_ = false; @@ -4432,15 +5067,9 @@ class AmrRuntime { } continue; } - if (!block.transport_boundary_fill) + if (!base_per_.x || !base_per_.y) throw std::runtime_error( "non-periodic AMR regrid requires a prepared boundary authority for every block"); - validate_amr_boundary_fill_authority(base_per_, block.transport_boundary_fill.get(), - *block.levels); - if (!block.transport_boundary_fill->fills_all_allocated_ghosts) { - all_depths_supported = false; - shared_depth = std::min(shared_depth, block.transport_boundary_fill->provided_depth); - } } if (!all_depths_supported && shared_depth == std::numeric_limits::max()) throw std::runtime_error("non-periodic AMR regrid has no state boundary authority"); @@ -4496,54 +5125,91 @@ class AmrRuntime { void materialize_regrid_transition_(int parent_level, const BoxArray& boxes, const DistributionMapping& distribution, int refinement_ratio) { + const CommunicatorView communicator = world_communicator_view(); const int fine_level = parent_level + 1; const bool existed = fine_level < nlev_; - if (!existed) - require_coarse_fine_reconstruction_contract_(); std::vector remapped; - remapped.reserve(blocks_.size()); + regrid_detail::collective_stage("AMR regrid transition contract", communicator, [&] { + if (parent_level < 0 || parent_level >= nlev_ || refinement_ratio < 2 || boxes.size() <= 0 || + distribution.size() != boxes.size()) + throw std::runtime_error("AMR regrid transition has an invalid layout contract"); + if (!existed) + require_coarse_fine_reconstruction_contract_(); + require_complete_history_structure_("AMR regrid transition source"); + remapped.reserve(blocks_.size()); + }); + std::array transition_contract{ + static_cast(parent_level), static_cast(fine_level), + static_cast(existed ? 1 : 0), static_cast(nlev_), + static_cast(blocks_.size()), static_cast(boxes.size())}; + std::array transition_min = transition_contract; + std::array transition_max = transition_contract; + all_reduce_min_inplace(transition_min.data(), transition_min.size(), communicator); + all_reduce_max_inplace(transition_max.data(), transition_max.size(), communicator); + if (transition_min != transition_max) + throw std::runtime_error("AMR regrid transition contract differs between MPI ranks"); + for (std::size_t block = 0; block < blocks_.size(); ++block) { - auto& levels = *blocks_[block].levels; + std::optional empty; + regrid_detail::collective_stage("AMR regrid transition block binding", communicator, [&] { + if (!blocks_[block].levels || + blocks_[block].levels->size() <= static_cast(parent_level) || + (existed && blocks_[block].levels->size() <= static_cast(fine_level))) + throw std::runtime_error("AMR regrid transition block levels are incomplete"); + if (!existed) { + const MultiFab& parent = + (*blocks_[block].levels)[static_cast(parent_level)].U; + empty.emplace(BoxArray{}, DistributionMapping{}, parent.ncomp(), parent.n_grow()); + } + }); + const auto& levels = *blocks_[block].levels; const MultiFab& parent = levels[static_cast(parent_level)].U; const int ghost_depth = existed ? levels[static_cast(fine_level)].U.n_grow() : parent.n_grow(); - MultiFab empty(BoxArray{}, DistributionMapping{}, parent.ncomp(), ghost_depth); - const MultiFab& old_fine = existed ? levels[static_cast(fine_level)].U : empty; - remapped.push_back(regrid_block_field(block, boxes, distribution, parent, old_fine, - parent_level, ghost_depth, refinement_ratio)); - } - - if (!existed) { - hierarchy_.ba.push_back(boxes); - hierarchy_.dm.push_back(distribution); - hierarchy_.dx.push_back(hierarchy_.dx[static_cast(parent_level)] / - Real(refinement_ratio)); - hierarchy_.dy.push_back(hierarchy_.dy[static_cast(parent_level)] / - Real(refinement_ratio)); - hierarchy_.refinement_ratios.push_back(refinement_ratio); - aux_.emplace_back(boxes, distribution, aux_ncomp_, 1); - ++nlev_; - refresh_active_temporal_relations_(); - for (std::size_t block = 0; block < blocks_.size(); ++block) { - auto& levels = *blocks_[block].levels; - levels.push_back( - AmrLevelMP{std::move(remapped[block]), &aux_.back(), - levels[static_cast(parent_level)].dx / Real(refinement_ratio), - levels[static_cast(parent_level)].dy / Real(refinement_ratio)}); - } - } else { - hierarchy_.ba[static_cast(fine_level)] = boxes; - hierarchy_.dm[static_cast(fine_level)] = distribution; - aux_[static_cast(fine_level)] = MultiFab(boxes, distribution, aux_ncomp_, 1); - for (std::size_t block = 0; block < blocks_.size(); ++block) - (*blocks_[block].levels)[static_cast(fine_level)].U = - std::move(remapped[block]); + const MultiFab& old_fine = existed ? levels[static_cast(fine_level)].U : *empty; + MultiFab candidate = regrid_block_field(block, boxes, distribution, parent, old_fine, + parent_level, ghost_depth, refinement_ratio); + regrid_detail::collective_stage("AMR regrid transition candidate retention", communicator, + [&] { remapped.push_back(std::move(candidate)); }); } + + regrid_detail::collective_stage( + "AMR regrid transition hierarchy publication", communicator, [&] { + if (!existed) { + hierarchy_.ba.push_back(boxes); + hierarchy_.dm.push_back(distribution); + hierarchy_.dx.push_back(hierarchy_.dx[static_cast(parent_level)] / + Real(refinement_ratio)); + hierarchy_.dy.push_back(hierarchy_.dy[static_cast(parent_level)] / + Real(refinement_ratio)); + hierarchy_.refinement_ratios.push_back(refinement_ratio); + aux_.emplace_back(boxes, distribution, aux_ncomp_, 1); + ++nlev_; + refresh_active_temporal_relations_(); + for (std::size_t block = 0; block < blocks_.size(); ++block) { + auto& levels = *blocks_[block].levels; + levels.push_back(AmrLevelMP{ + std::move(remapped[block]), &aux_.back(), + levels[static_cast(parent_level)].dx / Real(refinement_ratio), + levels[static_cast(parent_level)].dy / Real(refinement_ratio)}); + } + } else { + hierarchy_.ba[static_cast(fine_level)] = boxes; + hierarchy_.dm[static_cast(fine_level)] = distribution; + aux_[static_cast(fine_level)] = + MultiFab(boxes, distribution, aux_ncomp_, 1); + for (std::size_t block = 0; block < blocks_.size(); ++block) + (*blocks_[block].levels)[static_cast(fine_level)].U = + std::move(remapped[block]); + } + }); remap_history_rings_(boxes, distribution, fine_level, parent_level, /*prolong=*/true); - for (auto& block : blocks_) - for (int level = 0; level < nlev_; ++level) - (*block.levels)[static_cast(level)].aux = - &aux_[static_cast(level)]; + regrid_detail::collective_stage("AMR regrid transition carrier rebinding", communicator, [&] { + for (auto& block : blocks_) + for (int level = 0; level < nlev_; ++level) + (*block.levels)[static_cast(level)].aux = + &aux_[static_cast(level)]; + }); } void remove_levels_above_(int parent_level) { @@ -5385,6 +6051,15 @@ class AmrRuntime { Real coefficient = Real(1); std::function rhs; }; + struct BoundaryLevelContext { + std::vector state_buffers; + std::vector state_distributions; + std::vector state_identities; + std::vector field_buffers; + std::vector field_distributions; + std::vector field_identities; + FieldBoundaryExecutionContext context{}; + }; int phi_comp = -1; int gx_comp = -1; int gy_comp = -1; @@ -5401,9 +6076,113 @@ class AmrRuntime { std::vector nullspace_phi_levels; std::vector rhs_contribution_scratch; std::uint64_t rhs_scratch_generation = 0; + std::vector phi_average_down_transfers; + std::vector phi_coarse_transfers; + std::uint64_t phi_halo_generation = 0; + std::vector boundary_level_contexts; bool nullspace_ready = false; }; + [[nodiscard]] const std::string* unique_boundary_field_dependency_slot_( + const NamedField& consumer, std::string_view block, std::string_view key) const noexcept { + const std::string* result = nullptr; + for (const auto& [slot, candidate] : named_fields_) { + if (candidate.plan.output_block != block || candidate.plan.output_key != key) + continue; + if (result != nullptr || &candidate == &consumer) + return nullptr; + result = &slot; + } + return result; + } + + [[nodiscard]] std::vector named_field_solve_order_collectively_( + const std::string* selected, bool allow_empty) const { + const std::string_view selection_kind = selected == nullptr ? "all" : "selected"; + const std::string_view selection_identity = + selected == nullptr ? std::string_view{} : std::string_view(*selected); + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{"amr-named-field-selection-kind", selection_kind}, + {"amr-named-field-selection", selection_identity}})) + throw std::runtime_error("AmrRuntime: named-field selection differs across MPI ranks"); + + std::vector solve_order; + std::vector closure_contract; + std::exception_ptr order_error; + long order_failed_local = 0; + try { + if (allow_empty && selected == nullptr && named_fields_.empty()) { + solve_order.clear(); + } else { + solve_order = named_field_solve_order_(selected); + } + closure_contract.reserve(solve_order.size()); + for (const std::string& field : solve_order) + closure_contract.emplace_back("amr-named-field-closure", field); + } catch (...) { + order_error = std::current_exception(); + order_failed_local = 1; + } + if (all_reduce_max(order_failed_local) != 0) { + if (n_ranks() == 1 && order_error != nullptr) + std::rethrow_exception(order_error); + throw std::runtime_error( + "AmrRuntime: named-field dependency closure failed on at least one MPI rank"); + } + if (!all_ranks_agree_exact_ordered_byte_pairs(closure_contract)) + throw std::runtime_error( + "AmrRuntime: named-field dependency closure differs across MPI ranks"); + return solve_order; + } + + [[nodiscard]] std::vector named_field_solve_order_( + const std::string* selected) const { + if (named_fields_.empty()) + throw std::runtime_error("AmrRuntime::solve_named_fields has no registered field"); + if (selected != nullptr && named_fields_.find(*selected) == named_fields_.end()) + throw std::runtime_error("AmrRuntime::solve_named_fields selected an unknown field"); + + enum class VisitState { kUnseen, kVisiting, kDone }; + std::map visit; + std::vector solve_order; + std::function append_with_dependencies = + [&](const std::string& field) { + const VisitState state = visit[field]; + if (state == VisitState::kDone) + return; + if (state == VisitState::kVisiting) + throw std::runtime_error( + "AmrRuntime: named-field boundary dependency graph contains a cycle"); + visit[field] = VisitState::kVisiting; + const NamedField& consumer = named_fields_.at(field); + for (std::size_t index = 0; index < consumer.plan.boundary_field_blocks.size(); ++index) { + if (index >= consumer.plan.boundary_field_keys.size()) + throw std::runtime_error( + "AmrRuntime: named-field boundary dependency pack is incomplete"); + const std::string* dependency_slot = unique_boundary_field_dependency_slot_( + consumer, consumer.plan.boundary_field_blocks[index], + consumer.plan.boundary_field_keys[index]); + if (dependency_slot == nullptr) + throw std::runtime_error( + "AmrRuntime: named-field boundary dependency is missing, ambiguous, or " + "recursive"); + append_with_dependencies(*dependency_slot); + } + visit[field] = VisitState::kDone; + solve_order.push_back(field); + }; + + if (selected != nullptr) { + append_with_dependencies(*selected); + } else { + for (const auto& [field, unused] : named_fields_) { + (void)unused; + append_with_dependencies(field); + } + } + return solve_order; + } + enum class NamedFieldSnapshotScope { kNone, kSelected, kAll }; struct FieldSolveSnapshot { @@ -5423,7 +6202,7 @@ class AmrRuntime { std::uint64_t topology_generation = 0; bool scope_default_field = false; NamedFieldSnapshotScope scope_named_fields = NamedFieldSnapshotScope::kNone; - std::string scope_selected_named_field; + std::vector scope_selected_named_fields; bool candidate_slot = false; AmrRuntime* publication_owner = nullptr; FieldSolveSnapshot* publication_candidate = nullptr; @@ -5432,11 +6211,12 @@ class AmrRuntime { struct FieldSolveScope { bool default_field = false; NamedFieldSnapshotScope named_fields = NamedFieldSnapshotScope::kNone; - const std::string* selected_named_field = nullptr; + std::vector selected_named_fields; }; std::vector default_aux_components() const; std::vector named_aux_components(const std::string* selected) const; + std::vector named_aux_components(const std::vector& selected) const; std::vector field_solve_aux_components(const FieldSolveScope& scope) const; std::vector allocate_aux_component_carriers_(const std::vector& components) const; void copy_aux_components_to_(std::vector& packed, @@ -5482,6 +6262,10 @@ class AmrRuntime { field.nullspace_phi_levels.clear(); field.rhs_contribution_scratch.clear(); field.rhs_scratch_generation = 0; + field.phi_average_down_transfers.clear(); + field.phi_coarse_transfers.clear(); + field.phi_halo_generation = 0; + field.boundary_level_contexts.clear(); field.solver.reset(); field.nullspace = {}; field.level_nullspace.clear(); @@ -5550,6 +6334,84 @@ class AmrRuntime { field.rhs_scratch_generation = topology_materialization_generation_; } + std::vector + prepare_named_phi_halo_transfers_(AmrPreparedFieldSolver& solver, const BCRec& boundary) const { + if (solver.level_count() != nlev_) + throw std::invalid_argument( + "named-field halo preparation requires the exact materialized hierarchy"); + if (!solver.requires_runtime_solution_halos()) + return {}; + const Periodicity periodicity{boundary.xlo == BCType::Periodic, + boundary.ylo == BCType::Periodic}; + std::vector transfers; + transfers.reserve(nlev_ > 0 ? static_cast(nlev_ - 1) : 0u); + for (int level = 1; level < nlev_; ++level) { + MultiFab& coarse = solver.phi_level(level - 1); + MultiFab& fine = solver.phi_level(level); + if (coarse.n_grow() < 1 || fine.n_grow() < 1) + throw std::invalid_argument( + "named-field centered gradients require one potential ghost cell on every level"); + const bool replicated_parent = level == 1 && replicated_coarse_; + const CommunicatorView communicator = + replicated_parent ? CommunicatorView{} : world_communicator_view(); + transfers.push_back(detail::PreparedConservativeLinearTransferWorkspace::prepare( + coarse, fine, amr_level_index_domain(dom_, level - 1), + amr_level_index_domain(dom_, level), replicated_parent, + detail::ConservativeCellFillRegion::Ghost, periodicity, + topology_materialization_generation_, communicator)); + } + return transfers; + } + + std::vector prepare_named_phi_average_down_transfers_( + AmrPreparedFieldSolver& solver) const { + if (solver.level_count() != nlev_) + throw std::invalid_argument( + "named-field restriction preparation requires the exact materialized hierarchy"); + if (!solver.requires_runtime_solution_halos()) + return {}; + std::vector transfers; + transfers.reserve(nlev_ > 0 ? static_cast(nlev_ - 1) : 0u); + for (int level = 1; level < nlev_; ++level) + transfers.push_back(PreparedAverageDownWorkspace::prepare( + solver.phi_level(level), solver.phi_level(level - 1), + topology_materialization_generation_)); + return transfers; + } + + void materialize_named_phi_halos_(NamedField& field, const BCRec& boundary) { + if (field.solver && !field.solver->requires_runtime_solution_halos()) + return; + if (!field.solver || field.solver->level_count() != nlev_ || + field.phi_halo_generation != topology_materialization_generation_ || + field.phi_average_down_transfers.size() != + (nlev_ > 0 ? static_cast(nlev_ - 1) : 0u) || + field.phi_coarse_transfers.size() != (nlev_ > 0 ? static_cast(nlev_ - 1) : 0u)) + throw std::logic_error( + "named-field potential halo workspace differs from the materialized hierarchy"); + if (nlev_ > 1) { + for (int level = nlev_ - 1; level >= 1; --level) + mf_average_down_mb(field.solver->phi_level(level), field.solver->phi_level(level - 1), + field.phi_average_down_transfers[static_cast(level - 1)], + topology_materialization_generation_, world_communicator_view()); + } + BCRec level_boundary = boundary; + Box2D level_domain = dom_; + fill_ghosts_profiled(field.solver->phi_level(0), level_domain, level_boundary); + for (int level = 1; level < nlev_; ++level) { + level_domain = amr_level_index_domain(dom_, level); + level_boundary.dx /= Real(kAmrRefRatio); + level_boundary.dy /= Real(kAmrRefRatio); + const bool replicated_parent = level == 1 && replicated_coarse_; + const CommunicatorView communicator = + replicated_parent ? CommunicatorView{} : world_communicator_view(); + field.phi_coarse_transfers[static_cast(level - 1)].apply( + field.solver->phi_level(level - 1), field.solver->phi_level(level), + topology_materialization_generation_, communicator); + fill_ghosts_profiled(field.solver->phi_level(level), level_domain, level_boundary); + } + } + // Materializes one resolved named-field provider lazily. Provider declaration, exact request, // construction failure and post-build storage are communicator-wide contracts; no rank may escape // around a collective because its local extension failed first. @@ -5591,20 +6453,31 @@ class AmrRuntime { std::unique_ptr prepared; bool build_failed = false; + std::string build_failure_reason; try { prepared = provider->build(request); + } catch (const std::exception& error) { + build_failed = true; + build_failure_reason = error.what(); } catch (...) { build_failed = true; + build_failure_reason = "non-standard exception"; + } + if (all_reduce_max(build_failed || !prepared ? 1L : 0L) != 0) { + std::string message = + "AmrRuntime: field solver provider construction failed on at least one rank"; + if (n_ranks() == 1 && !build_failure_reason.empty()) + message += ": " + build_failure_reason; + throw std::runtime_error(message); } - if (all_reduce_max(build_failed || !prepared ? 1L : 0L) != 0) - throw std::runtime_error( - "AmrRuntime: field solver provider construction failed on at least one rank"); bool inspection_failed = false; bool materialization_mismatch = false; std::string actual_contract; + std::string materialization_evidence; try { actual_contract = prepared->exact_prepared_contract(); + materialization_evidence = prepared->exact_materialization_evidence(); materialization_mismatch = prepared->provider_identity() != provider->identity() || actual_contract != expected_contract || prepared->level_count() != nlev_; @@ -5634,9 +6507,16 @@ class AmrRuntime { if (all_reduce_max(materialization_mismatch ? 1L : 0L) != 0) throw std::runtime_error( "AmrRuntime: field solver provider did not materialize the exact hierarchy contract"); - if (!all_ranks_agree_exact_ordered_byte_pairs({{"amr-field-actual-contract", actual_contract}})) + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{"amr-field-actual-contract", actual_contract}, + {"amr-field-materialization-evidence", materialization_evidence}})) throw std::runtime_error("AmrRuntime: field solver materialization differs across MPI ranks"); + auto phi_average_down_transfers = prepare_named_phi_average_down_transfers_(*prepared); + auto phi_coarse_transfers = prepare_named_phi_halo_transfers_(*prepared, boundary); nf.solver = std::move(prepared); + nf.phi_average_down_transfers = std::move(phi_average_down_transfers); + nf.phi_coarse_transfers = std::move(phi_coarse_transfers); + nf.phi_halo_generation = topology_materialization_generation_; } std::shared_ptr composite_valid_mask(AmrPreparedFieldSolver& solver, @@ -5865,13 +6745,13 @@ class AmrRuntime { std::vector blocks_; struct BoundaryStageStateView { runtime::multiblock::BoundaryEvaluationPoint point; - const std::vector* states = nullptr; + std::span states{}; int single_block = -1; MultiFab* single_state = nullptr; MultiFab* state(std::size_t block) const { - if (states != nullptr) - return block < states->size() ? (*states)[block] : nullptr; + if (!states.empty()) + return block < states.size() ? states[block] : nullptr; return single_block >= 0 && block == static_cast(single_block) ? single_state : nullptr; } @@ -5920,6 +6800,12 @@ class AmrRuntime { std::map named_aux_bc_; // NAMED multi-elliptic fields (ADC-428): field name -> aux outputs + prepared provider instance. std::map named_fields_; + // Persistent exact-layout snapshots used while a named provider assembles from a provisional + // stage state. Compatibility is rechecked on every use, so regrid/restart cannot retain stale + // storage while steady-state replays allocate nothing. + std::map, MultiFab> named_field_stage_state_scratch_; + std::vector> named_field_stage_restore_scratch_; + bool named_field_stage_pack_in_use_ = false; std::vector coupled_sources_; // registered coupled sources (applied after transport) // TYPED coupling operator inspect metadata (ADC-595, parity with System::Impl::coupled_operators_): @@ -5946,6 +6832,8 @@ class AmrRuntime { cluster_{}; ///< ADC-616: Berger-Rigoutsos params; default {0.7,1,32} (bit-identical). std::shared_ptr external_tagger_; std::shared_ptr external_clustering_; + std::shared_ptr external_reflux_; + bool external_reflux_configured_ = false; std::shared_ptr clustering_provider_ = std::make_shared(ClusterParams{}); // Ephemeral Program evaluation metadata required by the prepared component ABI. These values @@ -6116,6 +7004,13 @@ class AmrRuntime { coarse_fine_spatial_candidate.reserve(blocks_.size()); average_down_candidate.reserve(blocks_.size()); program_reflux_candidate.reserve(blocks_.size()); + PreparedAmrRefluxLocalKernel external_reflux_kernel; + if (external_reflux_) { + const std::shared_ptr provider = external_reflux_; + external_reflux_kernel = [provider](const PreparedAmrRefluxLocalRequest& request) { + provider->apply(request); + }; + } for (std::size_t block_index = 0; block_index < blocks_.size(); ++block_index) { const AmrRuntimeBlock& block = blocks_[block_index]; const auto& authority = block_transfer_authorities_[block_index]; @@ -6145,7 +7040,8 @@ class AmrRuntime { average_down_candidate.push_back( PreparedAmrAverageDownPlan::prepare(*block.levels, generation)); program_reflux_candidate.push_back(PreparedAmrProgramRefluxPlan::prepare( - *block.levels, dom_, base_per_, generation, world_communicator_view())); + *block.levels, dom_, base_per_, generation, world_communicator_view(), + external_reflux_kernel, block.state_identity)); } auto tagging_candidate = make_tagging_execution_plan_(tagging_program_, generation); auto interface_candidate = interface_scheduler_.rematerialized( diff --git a/include/pops/runtime/amr/prepared_component_providers.hpp b/include/pops/runtime/amr/prepared_component_providers.hpp index 2803d6ef4..b73d4e270 100644 --- a/include/pops/runtime/amr/prepared_component_providers.hpp +++ b/include/pops/runtime/amr/prepared_component_providers.hpp @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -20,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -186,6 +189,16 @@ struct PreparedClusteringSpec { std::shared_ptr execution; }; +struct PreparedRefluxSpec { + std::string provider_identity; + std::string component_id; + std::string manifest_identity; + std::string layout_identity; + std::string clock_identity; + std::uint32_t interface_version = 1; + std::shared_ptr execution; +}; + /// Prepared external Tagger. One invocation per local patch sees every graph input as a qualified /// borrowed SoA view and evaluates the exact resolved graph program. Only four Boolean candidate /// bitmaps are reduced across ranks; state arrays are never packed or globally reduced. @@ -600,6 +613,200 @@ class PreparedTaggerComponent final { void* state_ = nullptr; }; +/// Prepared adapter for the deliberately narrow Reflux ABI. One callback receives four contiguous +/// faces of one rank-local child patch. It has no communicator, topology mask, global register or +/// live state; the enclosing PreparedAmrProgramRefluxTransition validates and publishes its result. +class PreparedRefluxComponent final { + public: + PreparedRefluxComponent(PreparedRefluxSpec spec, + std::shared_ptr component) + : spec_(std::move(spec)), component_(std::move(component)) { + validate_(); + prepare_provider_contract_(); + local_execution_ = std::make_shared( + spec_.execution->without_collective_authority()); + state_owner_ = component_->prepare_fresh_state( + POPS_NATIVE_INTERFACE_REFLUX_V1, spec_.interface_version, local_execution_->view()); + state_ = state_owner_.get(); + } + + [[nodiscard]] const std::string& provider_identity() const noexcept { + return spec_.provider_identity; + } + [[nodiscard]] std::string_view collective_contract() const noexcept { + return collective_contract_; + } + + void apply(const PreparedAmrRefluxLocalRequest& request) const { + static_assert(sizeof(Real) == sizeof(double), + "Reflux ABI v1 requires the binary64 PoPS backend"); + if (request.transition_identity == nullptr || request.transition_identity->empty() || + request.patch_identity == nullptr || request.patch_identity->empty() || + request.parent_level < 0 || request.child_level != request.parent_level + 1 || + request.logical_time.level != request.parent_level || request.logical_time.macro_step < 0 || + request.coarse.components <= 0 || request.coarse.components != request.fine.components || + request.coarse.components != request.correction.components || + request.coarse.I0 != request.fine.I0 || request.coarse.I1 != request.fine.I1 || + request.coarse.J0 != request.fine.J0 || request.coarse.J1 != request.fine.J1 || + request.coarse.I0 != request.correction.I0 || request.coarse.I1 != request.correction.I1 || + request.coarse.J0 != request.correction.J0 || request.coarse.J1 != request.correction.J1 || + !std::isfinite(request.dx) || !std::isfinite(request.dy) || request.dx <= Real(0) || + request.dy <= Real(0)) + throw std::invalid_argument("prepared native Reflux invocation is incomplete"); + for (const std::string* identity : request.interface_identities) + if (identity == nullptr || identity->empty()) + throw std::invalid_argument("prepared native Reflux face identity is empty"); + + const auto make_const_view = [&](const Real* data, int axis) { + if (data == nullptr) + throw std::invalid_argument("prepared native Reflux input face is absent"); + const std::size_t tangent = + static_cast(axis == 0 ? request.coarse.J1 - request.coarse.J0 + 1 + : request.coarse.I1 - request.coarse.I0 + 1); + const std::size_t components = static_cast(request.coarse.components); + const std::size_t extent0 = axis == 0 ? 1u : tangent; + const std::size_t extent1 = axis == 0 ? tangent : 1u; + return PopsConstFieldViewV1{sizeof(PopsConstFieldViewV1), + data, + 2, + {extent0, extent1, 1}, + {static_cast(extent1 * components), + static_cast(components), 0}, + components, + 1, + POPS_FIELD_CENTERING_FACE_V1, + 1u << static_cast(axis), + {0, 0, 0}, + {0, 0, 0}, + POPS_SCALAR_FLOAT64_V1, + POPS_MEMORY_SPACE_HOST_V1, + spec_.layout_identity.c_str(), + request.patch_identity->c_str(), + POPS_FIELD_OWNERSHIP_RUNTIME_BORROWED_V1}; + }; + const auto make_output_view = [&](Real* data, int axis) { + if (data == nullptr) + throw std::invalid_argument("prepared native Reflux output face is absent"); + const std::size_t tangent = + static_cast(axis == 0 ? request.coarse.J1 - request.coarse.J0 + 1 + : request.coarse.I1 - request.coarse.I0 + 1); + const std::size_t components = static_cast(request.coarse.components); + const std::size_t extent0 = axis == 0 ? 1u : tangent; + const std::size_t extent1 = axis == 0 ? tangent : 1u; + return PopsFieldViewV1{sizeof(PopsFieldViewV1), + data, + 2, + {extent0, extent1, 1}, + {static_cast(extent1 * components), + static_cast(components), 0}, + components, + 1, + POPS_FIELD_CENTERING_CELL_V1, + 0, + {0, 0, 0}, + {0, 0, 0}, + POPS_SCALAR_FLOAT64_V1, + POPS_MEMORY_SPACE_HOST_V1, + spec_.layout_identity.c_str(), + request.patch_identity->c_str(), + POPS_FIELD_OWNERSHIP_RUNTIME_BORROWED_V1}; + }; + + const std::array coarse{request.coarse.cL, request.coarse.cR, request.coarse.cB, + request.coarse.cT}; + const std::array fine{request.fine.fL, request.fine.fR, request.fine.fB, + request.fine.fT}; + const std::array correction{request.correction.x_low, request.correction.x_high, + request.correction.y_low, request.correction.y_high}; + const std::array axes{0, 0, 1, 1}; + const std::array sides{ + POPS_REFLUX_FACE_LOW_V1, POPS_REFLUX_FACE_HIGH_V1, POPS_REFLUX_FACE_LOW_V1, + POPS_REFLUX_FACE_HIGH_V1}; + std::array faces; + for (std::size_t face = 0; face < faces.size(); ++face) + faces[face] = PopsRefluxFaceV1{ + sizeof(PopsRefluxFaceV1), + request.interface_identities[face]->c_str(), + axes[face], + sides[face], + static_cast(Real(1) / (axes[face] == 0 ? request.dx : request.dy)), + make_const_view(coarse[face], axes[face]), + make_const_view(fine[face], axes[face]), + make_output_view(correction[face], axes[face])}; + + const PopsLogicalTimeV1 logical_time{sizeof(PopsLogicalTimeV1), + spec_.clock_identity.c_str(), + request.logical_time.macro_step, + request.parent_level, + 0, + 0, + request.logical_time.phase.numerator, + request.logical_time.phase.denominator, + 0.0, + request.logical_time.physical_time}; + const PopsRefluxRequestV1 abi_request{sizeof(PopsRefluxRequestV1), + request.transition_identity->c_str(), + request.parent_level, + request.child_level, + faces.size(), + faces.data(), + logical_time, + local_execution_->view()}; + PopsComponentStatusV1 status = component::unwritten_component_status(); + const auto& api = component_->table(POPS_NATIVE_INTERFACE_REFLUX_V1, + spec_.interface_version); + const int code = component::apply_reflux_interface_batch(api, state_, abi_request, status); + if (code != 0) + throw std::runtime_error(status.reason == nullptr ? "native Reflux component failed" + : status.reason); + } + + private: + void prepare_provider_contract_() { + ExactContractBuilder contract; + contract.text("pops.runtime.external-amr-reflux-provider") + .scalar(std::uint32_t{1}) + .text(spec_.provider_identity) + .text(spec_.component_id) + .text(spec_.manifest_identity) + .text(spec_.layout_identity) + .text(spec_.clock_identity) + .scalar(spec_.interface_version) + .text(spec_.execution->identity()); + collective_contract_ = std::move(contract).release(); + } + + void validate_() const { + if (!component_ || !spec_.execution || spec_.provider_identity.empty() || + spec_.component_id.empty() || spec_.manifest_identity.empty() || + spec_.layout_identity.empty() || spec_.clock_identity.empty() || + spec_.interface_version != 1) + throw std::invalid_argument("prepared AMR Reflux specification is incomplete"); + if constexpr (!std::is_same_v) + throw std::invalid_argument( + "prepared external Reflux v1 is qualified only for a host execution backend"); + component::validate_execution_context(spec_.execution->view()); + if (spec_.execution->view().memory_space != POPS_MEMORY_SPACE_HOST_V1) + throw std::invalid_argument( + "prepared external Reflux v1 requires host-resident face storage"); + const auto& api = component_->api(); + if (api.component_id == nullptr || api.manifest_identity == nullptr || + spec_.component_id != api.component_id || spec_.manifest_identity != api.manifest_identity) + throw std::invalid_argument("prepared AMR Reflux changed native component identity"); + component::require_operation( + component_->table(POPS_NATIVE_INTERFACE_REFLUX_V1, spec_.interface_version) + .apply_interface_batch != nullptr, + "apply_interface_batch"); + } + + PreparedRefluxSpec spec_; + std::shared_ptr component_; + std::shared_ptr local_execution_; + component::LoadedComponent::PreparedState state_owner_; + void* state_ = nullptr; + std::string collective_contract_; +}; + /// External Clustering ABI contract: each result is `2 * dimension` signed integers laid out as /// `[lo_0, ..., lo_(d-1), hi_0, ..., hi_(d-1)]`, inclusive and relative to the supplied region. class PreparedClusteringComponent final : public pops::amr::ClusteringProvider { diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 71c70314a..df6a9645a 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -58,7 +58,7 @@ namespace pops { -class WorldCommunicator; +class ObserverMpiLane; namespace runtime::program { class AmrProgramContext; } @@ -269,8 +269,9 @@ class AmrSystem { /// @param name block name: INDEXES the block (set_density(name), mass(name), density(name)). In /// multi-block the name must be unique; mono-block an empty name targets the single block. /// @param model composition of bricks (transport/source/elliptic + parameters) - /// @param limiter "none" | "minmod" | "vanleer" | "weno5" (weno5 = WENO5-Z, 3 ghosts; - /// native low-level stencil route). The resolved Case route derives its + /// @param limiter "none" | "minmod" | "vanleer" | "weno5" | "mc" | "superbee" + /// (weno5 = WENO5-Z, 3 ghosts; native low-level stencil route). The resolved + /// Case route derives its /// coarse/fine order and halo requirements from this spatial descriptor and /// selects the minimum sufficient conservative provider. /// @param riemann "rusanov" | "hll" (generic signed-wave, requires model.wave_speeds) | "hllc" @@ -350,17 +351,27 @@ class AmrSystem { POPS_EXPORT void install_boundary_plan(const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces = {}, const std::string& state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}); - /// Exact-topology overload; preserves the translation-only exported ABI above. + /// Exact-topology overload. Periodic endpoint maps remain topology metadata beside the sole + /// model-aware physical-law plan; they never restore component-wise BCRec transport semantics. POPS_EXPORT void install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications); + std::vector periodic_identifications, + const std::vector& face_representations = {}, + const std::vector& face_converter_identities = {}, + const std::vector>& face_analytic_opcodes = {}, + const std::vector>& face_analytic_literals = {}, + const std::vector& face_analytic_clocks = {}); /// Register the exact state Handle independently from physical-boundary ownership. POPS_EXPORT void install_block_state_route(const std::string& name, const std::string& state_identity); @@ -373,6 +384,9 @@ class AmrSystem { POPS_EXPORT void install_ghost_boundary_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); + POPS_EXPORT void install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component); POPS_EXPORT void install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); @@ -384,6 +398,8 @@ class AmrSystem { POPS_EXPORT void install_amr_clustering_component( runtime::amr::PreparedClusteringSpec spec, std::shared_ptr component); + POPS_EXPORT void install_amr_reflux_component( + runtime::amr::PreparedRefluxSpec spec, std::shared_ptr component); POPS_EXPORT void discard_amr_provider_components(); /// Materialize one exact shared NumericalFlux route on a frozen AMR level. This seam is called /// only after the lazy AmrRuntime has been built and before bind freezes composition. @@ -507,6 +523,12 @@ class AmrSystem { /// Adds one native AMR field solver provider before binding. Builtins and extensions are resolved /// through the same per-system registry and must expose exact collective contracts. void register_field_solver_provider(std::shared_ptr provider); + /// Installs one authenticated external FieldTopology@2 + FieldSolver@2 pair as an AMR provider. + /// The returned route is exactly ``provider_slot`` and is suitable for set_field_solver_plan. + POPS_EXPORT std::string register_field_solver_provider( + const std::string& provider_slot, runtime::field::PreparedFieldSolverSpec spec, + std::shared_ptr topology, + std::shared_ptr solver); /// Adds one native field-nullspace provider before binding. The selected route is resolved only /// after operator, boundary, topology and distribution facts have materialized. void register_field_nullspace_provider(std::shared_ptr provider); @@ -672,7 +694,7 @@ class AmrSystem { /// Exact rank-local valid-cell pieces for one qualified field provider. The returned metadata /// explicitly marks replicated level-zero ownership so output modes never infer it from box counts. std::vector output_field_local_pieces(const std::string& provider_slot, int level); - std::vector output_field_root_pieces(const WorldCommunicator& world, + std::vector output_field_root_pieces(const ObserverMpiLane& lane, const std::string& provider_slot, int level); /// Transaction bracket used by the accepted-state reader after complete payload preflight. Every /// hierarchy, @@ -852,6 +874,9 @@ class AmrSystem { /// Human/audit-readable qualification rows decoded from the same accepted image persisted as bytes. POPS_EXPORT std::vector> program_accepted_state_manifest() const; POPS_EXPORT std::vector> program_clock_manifest() const; + /// Accepted temporal-partition provider, synchronization tick and per-rung cell counts. The rows + /// are decoded from the same opaque image used by strict restart, never a capability ledger. + POPS_EXPORT std::vector> program_temporal_partition_manifest() const; POPS_EXPORT std::vector> program_flux_ledger_manifest() const; POPS_EXPORT std::vector> program_interface_flux_ledger_manifest() const; POPS_EXPORT std::vector> program_sync_manifest() const; @@ -909,6 +934,13 @@ class AmrSystem { /// The recorded diagnostic @p name (0 if absent) / the whole map. Exposed to Python for inspection. POPS_EXPORT double program_diagnostic(const std::string& name) const; POPS_EXPORT std::map program_diagnostics() const; + /// Five current-attempt scalars for one typed balance route. RuntimeInstance calls this only + /// inside its active outer accepted-step transaction; missing/stale/non-finite evidence fails. + POPS_EXPORT std::map accepted_balance_terms(const std::string& route) const; + /// The same accepted route with selected attempt-local native reflux/projection producers. + POPS_EXPORT std::map selected_accepted_balance_terms( + const std::string& route, const std::string& block, int component, + const std::vector& levels, const std::vector& automatic_terms) const; POPS_EXPORT void begin_step_projection_report(); POPS_EXPORT void note_step_projection(const std::string& name); POPS_EXPORT std::vector consume_step_projections(); @@ -1048,7 +1080,7 @@ class AmrSystem { /// without allocating a global level buffer. std::vector output_state_local_pieces(const std::string& name, int k); std::vector output_geometry_boxes(); - std::vector output_state_root_pieces(const WorldCommunicator& world, + std::vector output_state_root_pieces(const ObserverMpiLane& lane, const std::string& name, int k); /// Owner rank per box of level @p k (the shared layout's DistributionMapping), aligned with the /// level-@p k rows of patch_boxes(). The v3 checkpoint (ADC-542) serializes it so a restart @@ -1101,6 +1133,12 @@ class AmrSystem { private: friend class runtime::program::AmrProgramContext; + /// Dedicated generated-Program sink for one validated, attempt-local balance term. It remains + /// private to AmrProgramContext and is deliberately absent from Python bindings. + POPS_EXPORT void record_program_balance_term(const std::string& route, const std::string& term, + double value); + POPS_EXPORT bool program_balance_consumer_is_due(const std::string& contract, + const std::string& route, int every_n) const; POPS_EXPORT runtime::program::ProgramRuntimeState& program_runtime_state_(); /// Read-only compiled-artifact capability check; artifact authority installation is private to /// AmrSystem::install_program and cannot be injected through the public facade. diff --git a/include/pops/runtime/analytic/collective_preflight.hpp b/include/pops/runtime/analytic/collective_preflight.hpp index 3d0464cce..3f288d51a 100644 --- a/include/pops/runtime/analytic/collective_preflight.hpp +++ b/include/pops/runtime/analytic/collective_preflight.hpp @@ -117,30 +117,29 @@ inline std::string canonical_analytic_request(std::string_view operation, } // namespace detail -/// Run a non-mutating local validator/preparer on every rank, convert any rank-local exception into -/// one collective failure, then require exact equality of the complete canonical request. The -/// returned object may own prepared native programs or staged registry nodes; callers publish it -/// only after this function returns. -/// -/// This is a control-plane collective. Every rank in @p communicator must call it in the same order. -/// With one rank the original validation exception is rethrown, preserving the serial API contract. -template -[[nodiscard]] auto collectively_prepare_analytic_request( - std::string_view operation, std::span text_metadata, - std::span real_metadata, const AnalyticOpcodeRows& opcodes, - const AnalyticLiteralRows& literals, LocalPrepare&& local_prepare, +/// Run one fallible local preparation and one fallible exact canonicalization under the same +/// collective failure boundary. This is the common transaction used by analytic requests whose +/// metadata is richer than the standard text/real/opcode tables. +template +[[nodiscard]] auto collectively_prepare_exact_analytic_request( + std::string_view operation, LocalPrepare&& local_prepare, + LocalCanonicalize&& local_canonicalize, const CommunicatorView& communicator = world_communicator_view()) -> std::invoke_result_t { using Result = std::invoke_result_t; static_assert(!std::is_void_v); + static_assert(std::is_convertible_v, std::string>); std::optional prepared; std::string canonical_payload; std::exception_ptr local_failure; try { prepared.emplace(std::invoke(std::forward(local_prepare))); - canonical_payload = detail::canonical_analytic_request(operation, text_metadata, real_metadata, - opcodes, literals); + const std::string request = + std::string(std::invoke(std::forward(local_canonicalize))); + detail::append_analytic_bytes(canonical_payload, "pops.analytic.exact-request.v1"); + detail::append_analytic_bytes(canonical_payload, operation); + detail::append_analytic_bytes(canonical_payload, request); } catch (...) { local_failure = std::current_exception(); } @@ -161,6 +160,29 @@ template return std::move(*prepared); } +/// Run a non-mutating local validator/preparer on every rank, convert any rank-local exception into +/// one collective failure, then require exact equality of the complete canonical request. The +/// returned object may own prepared native programs or staged registry nodes; callers publish it +/// only after this function returns. +/// +/// This is a control-plane collective. Every rank in @p communicator must call it in the same order. +/// With one rank the original validation exception is rethrown, preserving the serial API contract. +template +[[nodiscard]] auto collectively_prepare_analytic_request( + std::string_view operation, std::span text_metadata, + std::span real_metadata, const AnalyticOpcodeRows& opcodes, + const AnalyticLiteralRows& literals, LocalPrepare&& local_prepare, + const CommunicatorView& communicator = world_communicator_view()) + -> std::invoke_result_t { + return collectively_prepare_exact_analytic_request( + operation, std::forward(local_prepare), + [&]() { + return detail::canonical_analytic_request(operation, text_metadata, real_metadata, opcodes, + literals); + }, + communicator); +} + template [[nodiscard]] auto collectively_prepare_analytic_request( std::string_view operation, std::initializer_list text_metadata, diff --git a/include/pops/runtime/builders/block/amr_block_seam.hpp b/include/pops/runtime/builders/block/amr_block_seam.hpp index 7c5dbec90..aa81679f5 100644 --- a/include/pops/runtime/builders/block/amr_block_seam.hpp +++ b/include/pops/runtime/builders/block/amr_block_seam.hpp @@ -80,4 +80,6 @@ AmrRuntimeBlock build_amr_block_compressible_hllc(const AmrBlockBuildArgs& a, const SharedAmrLayout& S); AmrRuntimeBlock build_amr_block_compressible_roe(const AmrBlockBuildArgs& a, const SharedAmrLayout& S); +AmrRuntimeBlock build_amr_block_compressible_roe_hll_rusanov_recovery(const AmrBlockBuildArgs& a, + const SharedAmrLayout& S); } // namespace pops::detail diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index 6aeb0af88..1798ffd6a 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include // assemble_rhs_eb (cut-cell EB) + detail::DiscLevelSet (T5-PR2) #include @@ -18,7 +20,11 @@ #include // GridContext + BlockClosures (shared lightweight header) #include +#include +#include +#include #include +#include #include // std::shared_ptr (shared scratch of the HLL wave speed cache, opt-in) #include #include @@ -46,6 +52,13 @@ namespace pops { // included by system.hpp to expose grid_context() / install_block() without pulling in the numerics). namespace detail { +template +concept HasCharacteristicNoInflow = requires( + const Model model, const typename Model::State interior, const typename Model::State reference, + int axis, int side, typename Model::State& ghost) { + { model.characteristic_no_inflow(interior, reference, axis, side, ghost) } -> std::same_as; +}; + inline bool embedded_boundary_active(const GridContext& context) { return context.embedded_boundary_set != nullptr && *context.embedded_boundary_set && context.geometry_mode != nullptr && *context.geometry_mode != GeometryMode::None; @@ -65,7 +78,7 @@ inline void require_geometry_aware_boundary_provider(const GridContext& context, "that provider has no active-cell or cut-cell metric contract"); } -struct ZeroPreparedInterfaceFace { +struct ZeroPreparedBoundaryFace { Array4 flux; int axis = 0; int coordinate = 0; @@ -78,26 +91,27 @@ struct ZeroPreparedInterfaceFace { } }; -inline void zero_prepared_interface_fluxes(MultiFab& fx, MultiFab& fy, const GridContext& context) { - if (!context.boundary_plan || !context.boundary_plan->has_omitted_faces()) +inline void zero_prepared_boundary_fluxes(MultiFab& fx, MultiFab& fy, const GridContext& context) { + if (!context.boundary_plan || (!context.boundary_plan->has_omitted_faces() && + !context.boundary_plan->has_zero_flux_faces())) return; for (int local = 0; local < fx.local_size(); ++local) { const Box2D faces = fx.box(local); - if (context.boundary_plan->omits_face(0, -1)) - for_each_cell(faces, ZeroPreparedInterfaceFace{fx.fab(local).array(), 0, context.dom.lo[0], - fx.ncomp()}); - if (context.boundary_plan->omits_face(0, 1)) - for_each_cell(faces, ZeroPreparedInterfaceFace{fx.fab(local).array(), 0, - context.dom.hi[0] + 1, fx.ncomp()}); + if (context.boundary_plan->omits_face(0, -1) || context.boundary_plan->zeroes_face(0, -1)) + for_each_cell( + faces, ZeroPreparedBoundaryFace{fx.fab(local).array(), 0, context.dom.lo[0], fx.ncomp()}); + if (context.boundary_plan->omits_face(0, 1) || context.boundary_plan->zeroes_face(0, 1)) + for_each_cell(faces, ZeroPreparedBoundaryFace{fx.fab(local).array(), 0, context.dom.hi[0] + 1, + fx.ncomp()}); } for (int local = 0; local < fy.local_size(); ++local) { const Box2D faces = fy.box(local); - if (context.boundary_plan->omits_face(1, -1)) - for_each_cell(faces, ZeroPreparedInterfaceFace{fy.fab(local).array(), 1, context.dom.lo[1], - fy.ncomp()}); - if (context.boundary_plan->omits_face(1, 1)) - for_each_cell(faces, ZeroPreparedInterfaceFace{fy.fab(local).array(), 1, - context.dom.hi[1] + 1, fy.ncomp()}); + if (context.boundary_plan->omits_face(1, -1) || context.boundary_plan->zeroes_face(1, -1)) + for_each_cell( + faces, ZeroPreparedBoundaryFace{fy.fab(local).array(), 1, context.dom.lo[1], fy.ncomp()}); + if (context.boundary_plan->omits_face(1, 1) || context.boundary_plan->zeroes_face(1, 1)) + for_each_cell(faces, ZeroPreparedBoundaryFace{fy.fab(local).array(), 1, context.dom.hi[1] + 1, + fy.ncomp()}); } } @@ -105,19 +119,23 @@ inline BoundaryFaceOmission prepared_boundary_face_omission(const GridContext& c BoundaryFaceOmission omission; omission.domain = context.dom; if (context.boundary_plan) { - omission.xlo = context.boundary_plan->omits_face(0, -1); - omission.xhi = context.boundary_plan->omits_face(0, +1); - omission.ylo = context.boundary_plan->omits_face(1, -1); - omission.yhi = context.boundary_plan->omits_face(1, +1); + omission.xlo = + context.boundary_plan->omits_face(0, -1) || context.boundary_plan->zeroes_face(0, -1); + omission.xhi = + context.boundary_plan->omits_face(0, +1) || context.boundary_plan->zeroes_face(0, +1); + omission.ylo = + context.boundary_plan->omits_face(1, -1) || context.boundary_plan->zeroes_face(1, -1); + omission.yhi = + context.boundary_plan->omits_face(1, +1) || context.boundary_plan->zeroes_face(1, +1); } return omission; } -struct PreparedInterfaceFluxFilter { +struct PreparedBoundaryFluxFilter { const GridContext* context = nullptr; void operator()(MultiFab& fx, MultiFab& fy) const { if (context != nullptr) - zero_prepared_interface_fluxes(fx, fy, *context); + zero_prepared_boundary_fluxes(fx, fy, *context); } }; @@ -125,7 +143,9 @@ template inline void assemble_rhs_without_prepared_interfaces( const Model& model, MultiFab& state, const GridContext& context, MultiFab& residual, bool reconstruct_primitive, Real positivity_floor, Real weno_epsilon = kWenoEpsilon, - const std::shared_ptr& ws_cache = {}) { + const std::shared_ptr& ws_cache = {}, + const runtime::multiblock::BoundaryEvaluationPoint* point = nullptr, + const PreparedGridBoundarySession* boundary = nullptr) { std::vector xboxes; std::vector yboxes; xboxes.reserve(static_cast(state.box_array().size())); @@ -151,7 +171,16 @@ inline void assemble_rhs_without_prepared_interfaces( context.geom.dy(), reconstruct_primitive, positivity_floor, weno_epsilon); } - zero_prepared_interface_fluxes(fx, fy, context); + if (context.boundary_plan && context.boundary_plan->has_flux_transformations()) { + if (point == nullptr) + throw std::logic_error( + "post-Riemann boundary flux transformation requires a BoundaryEvaluationPoint"); + if (boundary != nullptr) + transform_grid_boundary_fluxes(state, fx, fy, *boundary, *point); + else + transform_grid_boundary_fluxes(state, fx, fy, context, *point); + } + zero_prepared_boundary_fluxes(fx, fy, context); mf_eval_rhs(model, state, *context.aux, fx, fy, context.geom.dx(), context.geom.dy(), residual); } @@ -175,19 +204,14 @@ struct BlockRhsEval { Real weno_eps = kWenoEpsilon; ///< ADC-645: WENO-Z regulariser (default = historical, bit-identical) void operator()(MultiFab& U, MultiFab& R) const { + if (ctx->boundary_plan && ctx->boundary_plan->has_flux_transformations()) + throw std::logic_error( + "post-Riemann boundary flux transformation requires a BoundaryEvaluationPoint"); + if (ctx->boundary_plan && ctx->boundary_plan->has_omitted_faces()) + throw std::logic_error( + "prepared shared-interface flux requires BoundaryEvaluationPoint group authority"); fill_grid_ghosts(U, *ctx); - if constexpr (std::is_same_v) { - if (ws_cache) { - // Re-allocate the scratch at the current layout (4 components, 1 ghost): covers an AMR regrid - // or a first call (shared_ptr to an empty MultiFab). Otherwise reuse the existing allocation. - if (!detail::wave_speed_cache_matches(*ws_cache, U)) - *ws_cache = MultiFab(U.box_array(), U.dmap(), 4, 1); - assemble_rhs_hll_cached(model, U, *ctx->aux, ctx->geom, R, *ws_cache, recon_prim, - pos_floor, weno_eps); - return; - } - } - assemble_rhs(model, U, *ctx->aux, ctx->geom, R, recon_prim, pos_floor, weno_eps); + eval_core_filled(U, R); } void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, @@ -202,20 +226,24 @@ struct BlockRhsEval { void eval_core(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, MultiFab& R) const { fill_grid_ghosts(U, *ctx, point); - eval_core_filled(U, R); + eval_core_filled(U, R, &point, nullptr); } void eval_core(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, MultiFab& R, const PreparedGridBoundarySession& boundary) const { fill_grid_ghosts(U, boundary, point); - eval_core_filled(U, R); + eval_core_filled(U, R, &point, &boundary); } private: - void eval_core_filled(MultiFab& U, MultiFab& R) const { - if (ctx->boundary_plan && ctx->boundary_plan->has_omitted_faces()) { - assemble_rhs_without_prepared_interfaces(model, U, *ctx, R, recon_prim, - pos_floor, weno_eps, ws_cache); + void eval_core_filled(MultiFab& U, MultiFab& R, + const runtime::multiblock::BoundaryEvaluationPoint* point = nullptr, + const PreparedGridBoundarySession* boundary = nullptr) const { + if (ctx->boundary_plan && + (ctx->boundary_plan->has_omitted_faces() || ctx->boundary_plan->has_zero_flux_faces() || + ctx->boundary_plan->has_flux_transformations())) { + assemble_rhs_without_prepared_interfaces( + model, U, *ctx, R, recon_prim, pos_floor, weno_eps, ws_cache, point, boundary); return; } if constexpr (std::is_same_v) { @@ -476,8 +504,9 @@ struct BlockRhsEvalMasked { void operator()(MultiFab& U, MultiFab& R) const { require_geometry_aware_boundary_provider(ctx, "masked transport residual"); fill_grid_ghosts(U, ctx); - assemble_rhs_masked(model, U, *ctx.aux, *mask, ctx.geom, R, recon_prim, - pos_floor, weno_eps); + const BoundaryFaceOmission omission = prepared_boundary_face_omission(ctx); + assemble_rhs_masked_impl(model, U, *ctx.aux, *mask, ctx.geom, R, recon_prim, + pos_floor, weno_eps, omission); } /// Program core after its point-qualified host protocol has produced ghosts. Kept as the only @@ -506,9 +535,10 @@ struct BlockRhsEvalEb { fill_grid_ghosts(U, ctx); const Real face_open_eps = ctx.eb_thresholds ? ctx.eb_thresholds->face_open_eps : ctx.eb_face_open_eps; - assemble_rhs_eb_prepared(model, U, *ctx.aux, *ctx.domain_mask, - *inverse_volume_fraction, ctx.geom, R, recon_prim, - pos_floor, face_open_eps, weno_eps); + const PreparedEbMetricsProvider provider{ctx.domain_mask, inverse_volume_fraction}; + assemble_rhs_eb_with_metrics(model, U, *ctx.aux, provider, ctx.geom, R, + recon_prim, pos_floor, face_open_eps, weno_eps, + PreparedBoundaryFluxFilter{&ctx}); } /// Program core after its point-qualified host protocol has produced ghosts. See the staircase @@ -519,7 +549,7 @@ struct BlockRhsEvalEb { const PreparedEbMetricsProvider provider{ctx.domain_mask, inverse_volume_fraction}; assemble_rhs_eb_with_metrics(model, U, *ctx.aux, provider, ctx.geom, R, recon_prim, pos_floor, face_open_eps, weno_eps, - PreparedInterfaceFluxFilter{&ctx}); + PreparedBoundaryFluxFilter{&ctx}); } }; @@ -550,11 +580,14 @@ POPS_COLD_FN BlockClosures build_block(const Model& m, const GridContext& ctx, b // The current EB operators own only first-order hyperbolic transport. Higher-order // reconstructions can cross the inactive set, and DiffusiveModel needs a conservative embedded // diffusive flux that is not implemented here. Advertise only capabilities that are physically - // executable; System validates this bitset before publishing a geometry. + // executable; System validates this matrix before publishing a geometry. constexpr bool supports_embedded_boundary = supports_embedded_boundary_reconstruction_v && !DiffusiveModel; + bc.spatial_provider = + make_cartesian_spatial_provider(kNativeDimension, detail::HasCharacteristicNoInflow, + /*boundary_linearization=*/true); if constexpr (supports_embedded_boundary) - bc.supported_geometry_modes = kAllGeometrySupport; + bc.spatial_provider = with_embedded_boundary_residuals(bc.spatial_provider); // SHARED scratch of the HLL wave speed cache (opt-in): a single MultiFab for the residual family // (never called concurrently by one Program stage). nullptr when the option is OFF -> BlockRhsEval // keeps the per-face path (bit-identical). Allocated at the real layout on the first call @@ -653,8 +686,8 @@ POPS_COLD_FN BlockClosures build_block(const Model& m, const GridContext& ctx, b return bc; } -/// Dispatch of the spatial scheme (limiter x Riemann flux) -> compiled closures. HLLC / Roe guarded -/// by requires: they demand a 4-variable transport exposing pressure (otherwise an explicit error). +/// Dispatch of the spatial scheme (limiter x Riemann flux) -> compiled closures. HLLC / Roe are +/// guarded only by their exact physical-provider capabilities (otherwise an explicit error). /// "weno5" = WENO5-Z reconstruction (order 5, 5-point stencil, 3 ghosts); spatial_operator routes /// through the policy's explicit stencil protocol (the caller allocates its declared ghost radius, /// cf. block_n_ghost). @@ -746,6 +779,31 @@ POPS_COLD_FN BlockClosures make_block_roe(const Model& m, const std::string& lim } } +template +POPS_COLD_FN BlockClosures make_block_roe_hll_rusanov_recovery(const Model& m, + const std::string& lim, + const GridContext& ctx, + bool recon_prim, Real pos_floor, + Real weno_eps = kWenoEpsilon) { + if constexpr (!HasRoeDissipation) { + throw std::runtime_error( + "System: recovery policy 'roe -> hll -> rusanov' requires the model's Roe capability " + "(HasRoeDissipation); no candidate substitution"); + } else if constexpr (!requires(const Model mm, typename Model::State s, Aux a, Real r) { + mm.wave_speeds(s, a, 0, r, r); + }) { + throw std::runtime_error( + "System: recovery policy 'roe -> hll -> rusanov' requires signed wave speeds for its " + "declared HLL candidate; no candidate substitution"); + } else { + return dispatch_limiter(parse_limiter_route(lim, "System"), "System", [&](auto tag) { + using L = typename decltype(tag)::type; + return build_block(m, ctx, recon_prim, pos_floor, + /*wave_speed_cache=*/false, weno_eps); + }); + } +} + template POPS_COLD_FN BlockClosures make_block(const Model& m, const std::string& lim, const std::string& riem, const GridContext& ctx, @@ -758,6 +816,8 @@ POPS_COLD_FN BlockClosures make_block(const Model& m, const std::string& lim, // guard (unreachable after validate_riemann). validate_riemann(riem, /*polar=*/false, "System"); validate_limiter(lim, "System"); + if (wave_speed_cache && riem != "hll") + throw std::runtime_error("System: wave_speed_cache requires flux='hll'; no alternate flux"); // Parse the validated tag ONCE into the typed RiemannRouteId (ADC-641). Each public provider owns // exactly one leaf; the default is a defense-in-depth registry/dispatch guard. switch (parse_riemann_route(riem, "System")) { @@ -769,6 +829,8 @@ POPS_COLD_FN BlockClosures make_block(const Model& m, const std::string& lim, return make_block_hllc(m, lim, ctx, recon_prim, pos_floor, weno_eps); case RiemannRouteId::kRoe: return make_block_roe(m, lim, ctx, recon_prim, pos_floor, weno_eps); + case RiemannRouteId::kRoeHllRusanovRecovery: + return make_block_roe_hll_rusanov_recovery(m, lim, ctx, recon_prim, pos_floor, weno_eps); } throw_registry_dispatch_mismatch("System", "flux", riem); } @@ -788,6 +850,9 @@ inline int block_n_ghost(const std::string& lim) { static_assert(limiter_n_ghost_ct("vanleer") == VanLeer::n_ghost, "kLimiters[vanleer].n_ghost drifted"); static_assert(limiter_n_ghost_ct("weno5") == Weno5::n_ghost, "kLimiters[weno5].n_ghost drifted"); + static_assert(limiter_n_ghost_ct("mc") == MC::n_ghost, "kLimiters[mc].n_ghost drifted"); + static_assert(limiter_n_ghost_ct("superbee") == Superbee::n_ghost, + "kLimiters[superbee].n_ghost drifted"); return limiter_n_ghost(lim); } @@ -894,19 +959,203 @@ std::function make_poisson_rhs(const Model& m) return detail::PoissonRhs{m}; } +namespace detail { +template +auto make_recovery_validated_forward_conversion(Forward forward, Recovery recovery) { + return [forward = std::move(forward), recovery = std::move(recovery)](const double* in, + double* out) { + double candidate[N] = {}; + double recovered[N] = {}; + forward(in, candidate); + for (int component = 0; component < N; ++component) + if (!std::isfinite(candidate[component])) + throw std::runtime_error( + "primitive-to-conservative conversion produced a non-finite candidate"); + const RecoveryReport report = recovery(candidate, recovered); + if (!report.publication_permitted()) + throw std::runtime_error( + "primitive-to-conservative conversion produced a candidate rejected by prepared " + "variable recovery"); + for (int component = 0; component < N; ++component) + out[component] = candidate[component]; + }; +} +} // namespace detail + +namespace detail { + +template +struct CharacteristicNoInflowPreflightKernel { + Model model; + ConstArray4 state; + typename Model::State reference; + int axis = 0; + int side = -1; + int boundary = 0; + + POPS_HD Real operator()(int i, int j) const { + const int source_i = axis == 0 ? (side < 0 ? 2 * boundary - i - 1 : 2 * boundary - i + 1) : i; + const int source_j = axis == 1 ? (side < 0 ? 2 * boundary - j - 1 : 2 * boundary - j + 1) : j; + const typename Model::State interior = load_state(state, source_i, source_j); + typename Model::State ghost{}; + if (!model.characteristic_no_inflow(interior, reference, axis, side, ghost)) + return Real(1); + for (int component = 0; component < Model::n_vars; ++component) + if (!std::isfinite(ghost[component])) + return Real(1); + return Real(0); + } +}; + +template +struct CharacteristicNoInflowCommitKernel { + Model model; + Array4 state; + ConstArray4 source; + typename Model::State reference; + int axis = 0; + int side = -1; + int boundary = 0; + + POPS_HD void operator()(int i, int j) const { + const int source_i = axis == 0 ? (side < 0 ? 2 * boundary - i - 1 : 2 * boundary - i + 1) : i; + const int source_j = axis == 1 ? (side < 0 ? 2 * boundary - j - 1 : 2 * boundary - j + 1) : j; + const typename Model::State interior = load_state(source, source_i, source_j); + typename Model::State ghost{}; + const bool accepted = model.characteristic_no_inflow(interior, reference, axis, side, ghost); + for (int component = 0; component < Model::n_vars; ++component) + state(i, j, component) = accepted ? ghost[component] : std::numeric_limits::quiet_NaN(); + } +}; + +template +void for_each_characteristic_no_inflow_region(const PreparedHyperbolicBoundary<2>& boundary, + const MultiFab& state, const Box2D& domain, + Visitor&& visitor) { + const int depth = state.n_grow(); + for (int local = 0; local < state.local_size(); ++local) { + const Box2D valid = state.box(local); + int tangential_lo = valid.lo[1] - depth; + int tangential_hi = valid.hi[1] + depth; + if (boundary.face(1, -1).law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[1]); + if (boundary.face(1, 1).law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[1]); + if (boundary.face(0, -1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow && + valid.lo[0] == domain.lo[0]) + visitor(local, 0, -1, domain.lo[0], + Box2D{{domain.lo[0] - depth, tangential_lo}, {domain.lo[0] - 1, tangential_hi}}); + if (boundary.face(0, 1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow && + valid.hi[0] == domain.hi[0]) + visitor(local, 0, 1, domain.hi[0], + Box2D{{domain.hi[0] + 1, tangential_lo}, {domain.hi[0] + depth, tangential_hi}}); + + tangential_lo = valid.lo[0] - depth; + tangential_hi = valid.hi[0] + depth; + if (boundary.face(0, -1).law != HyperbolicBoundaryLaw::Periodic) + tangential_lo = std::max(tangential_lo, domain.lo[0]); + if (boundary.face(0, 1).law != HyperbolicBoundaryLaw::Periodic) + tangential_hi = std::min(tangential_hi, domain.hi[0]); + if (boundary.face(1, -1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow && + valid.lo[1] == domain.lo[1]) + visitor(local, 1, -1, domain.lo[1], + Box2D{{tangential_lo, domain.lo[1] - depth}, {tangential_hi, domain.lo[1] - 1}}); + if (boundary.face(1, 1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow && + valid.hi[1] == domain.hi[1]) + visitor(local, 1, 1, domain.hi[1], + Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}); + } +} + +template +PreparedBoundaryPlan::CharacteristicNoInflowFill make_characteristic_no_inflow_fill( + const Model& model, const PreparedHyperbolicBoundary<2>& boundary) { + if (!boundary.has_characteristic_no_inflow()) + return {}; + if constexpr (!HasCharacteristicNoInflow) { + throw std::runtime_error( + "characteristic no-inflow requires the exact block-model flux-Jacobian provider; " + "no component-wise or Euler-specific fallback exists"); + } else { + std::array references{}; + for (int face = 0; face < 4; ++face) { + const auto& prepared = boundary.face(face / 2, face % 2 == 0 ? -1 : 1); + if (prepared.law != HyperbolicBoundaryLaw::CharacteristicNoInflow) + continue; + if (prepared.fixed_state.size() != static_cast(Model::n_vars)) + throw std::runtime_error( + "characteristic no-inflow reference does not cover the exact model state"); + for (int component = 0; component < Model::n_vars; ++component) + references[static_cast(face)][component] = + prepared.fixed_state[static_cast(component)]; + } + return [model, boundary, references](MultiFab& state, const Box2D& domain, + CommunicatorView communicator) { + const int depth = state.n_grow(); + const bool characteristic_x = + boundary.face(0, -1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow || + boundary.face(0, 1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow; + const bool characteristic_y = + boundary.face(1, -1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow || + boundary.face(1, 1).law == HyperbolicBoundaryLaw::CharacteristicNoInflow; + if ((characteristic_x && depth > domain.nx()) || (characteristic_y && depth > domain.ny())) + throw std::invalid_argument( + "characteristic no-inflow does not support multi-reflection ghost depth"); + long invalid_local = 0; + for_each_characteristic_no_inflow_region( + boundary, state, domain, + [&](int local, int axis, int side, int coordinate, const Box2D& region) { + const int face = 2 * axis + (side > 0 ? 1 : 0); + invalid_local += static_cast(for_each_cell_reduce_sum( + region, CharacteristicNoInflowPreflightKernel{ + model, state.fab(local).const_array(), + references[static_cast(face)], axis, side, coordinate})); + }); + const long invalid = all_reduce_sum(invalid_local, communicator); + if (invalid != 0) + throw std::runtime_error( + "characteristic no-inflow lost a real prepared spectrum (failed cells=" + + std::to_string(invalid) + ")"); + for_each_characteristic_no_inflow_region( + boundary, state, domain, + [&](int local, int axis, int side, int coordinate, const Box2D& region) { + const int face = 2 * axis + (side > 0 ? 1 : 0); + for_each_cell(region, + CharacteristicNoInflowCommitKernel{ + model, state.fab(local).array(), state.fab(local).const_array(), + references[static_cast(face)], axis, side, coordinate}); + }); + }; + } +} + +} // namespace detail + /// PER-CELL (one cell) cons <-> prim conversions of the MODEL, type-erased over arrays of /// Model::n_vars doubles. First = primitive -> conservative (M.to_conservative, init from the -/// primitives), second = conservative -> primitive (M.to_primitive, diagnostic). Captures the model by -/// value (frozen when the block is added). For a model WITHOUT a conversion (pure scalar, no -/// hyperbolic brick) both are the IDENTITY -- exact for a scalar transport (prim == cons). -/// Model::Prim shares the Model::n_vars width of State (HyperbolicPhysicalModel contract), so the flat -/// arrays align component by component. Shared by add_block (native) and add_compiled_model (compiled): +/// primitives), second = conservative -> primitive through one PreparedVariableRecovery method. +/// The second closure returns a RecoveryReport and writes its output only after recovery succeeds. +/// Captures the model by value (frozen when the block is added). For a model WITHOUT a conversion +/// (pure scalar, no hyperbolic brick) both formulas are the IDENTITY -- exact for scalar transport +/// (prim == cons) -- while the recovery route still rejects non-finite publication. +/// This flat ABI requires Model::Prim to share the Model::n_vars width of State; make_cell_convert +/// enforces that additional constraint at compile time because HyperbolicPhysicalModel itself only +/// types the forward/inverse maps. Shared by add_block (native) and add_compiled_model (compiled): /// the SAME conversion serves both paths. template -std::pair, std::function> +std::pair, + std::function> make_cell_convert(const Model& m) { constexpr int NV = Model::n_vars; + const auto recovery_plan = prepare_model_variable_recovery(m); if constexpr (HasPrimitiveVars) { + static_assert( + requires { std::integral_constant{}; }, + "make_cell_convert requires a compile-time primitive-state width"); + if constexpr (requires { std::integral_constant{}; }) + static_assert( + Model::Prim::size() == NV, + "make_cell_convert requires primitive and conservative states to have equal arity"); auto p2c = [m](const double* in, double* out) { typename Model::Prim p{}; for (int c = 0; c < NV; ++c) @@ -915,23 +1164,45 @@ make_cell_convert(const Model& m) { for (int c = 0; c < NV; ++c) out[c] = static_cast(u[c]); }; - auto c2p = [m](const double* in, double* out) { - typename Model::State u{}; - for (int c = 0; c < NV; ++c) - u[c] = static_cast(in[c]); - const typename Model::Prim p = m.to_primitive(u); - for (int c = 0; c < NV; ++c) - out[c] = static_cast(p[c]); + auto c2p = [recovery_plan](const double* in, double* out) { + constexpr int N = Model::n_vars; + Real conserved[N] = {}; + Real initial_guess[N] = {}; + for (int c = 0; c < N; ++c) + conserved[c] = initial_guess[c] = static_cast(in[c]); + const RecoveryOutcome outcome = + recover_prepared_variable(recovery_plan, conserved, initial_guess); + if (!outcome.publication_permitted()) + return recovery_report(outcome); + for (int c = 0; c < N; ++c) + out[c] = static_cast(outcome.value[c]); + return recovery_report(outcome); }; - return {std::function(p2c), - std::function(c2p)}; + auto validated_p2c = detail::make_recovery_validated_forward_conversion(p2c, c2p); + return {std::function(std::move(validated_p2c)), + std::function(c2p)}; } else { - auto id = [](const double* in, double* out) { + auto p2c = [](const double* in, double* out) { for (int c = 0; c < NV; ++c) out[c] = in[c]; }; - return {std::function(id), - std::function(id)}; + auto c2p = [recovery_plan](const double* in, double* out) { + constexpr int N = Model::n_vars; + Real conserved[N] = {}; + Real initial_guess[N] = {}; + for (int c = 0; c < N; ++c) + conserved[c] = initial_guess[c] = static_cast(in[c]); + const RecoveryOutcome outcome = + recover_prepared_variable(recovery_plan, conserved, initial_guess); + if (!outcome.publication_permitted()) + return recovery_report(outcome); + for (int c = 0; c < N; ++c) + out[c] = static_cast(outcome.value[c]); + return recovery_report(outcome); + }; + auto validated_p2c = detail::make_recovery_validated_forward_conversion(p2c, c2p); + return {std::function(std::move(validated_p2c)), + std::function(c2p)}; } } diff --git a/include/pops/runtime/builders/block/block_builder_polar.hpp b/include/pops/runtime/builders/block/block_builder_polar.hpp index 43d2545f1..9f5dbc7ad 100644 --- a/include/pops/runtime/builders/block/block_builder_polar.hpp +++ b/include/pops/runtime/builders/block/block_builder_polar.hpp @@ -14,6 +14,7 @@ #include // all_reduce_max (MPI-safe collective reduction) #include // ExBVelocityPolar, CompositeModel, source/elliptic bricks #include // dispatch_limiter: ONE limiter-route dispatch generator (ADC-640) +#include #include // UNIQUE registry of tags (validate_limiter/riemann) #include // BlockClosures (light header) #include // detail::dispatch_source / dispatch_elliptic (REUSED) @@ -21,6 +22,7 @@ #include #include +#include #include #include #include @@ -54,6 +56,21 @@ struct PolarGridContext { BCRec bc; ///< BC: r (xlo/xhi) physical, theta (ylo/yhi) periodic PolarGeometry geom; ///< ring (r_min, r_max, dr, dtheta) MultiFab* aux = nullptr; ///< System's aux (phi, grad_r, grad_theta); NOT owned + std::shared_ptr boundary_plan; + + Geometry boundary_geometry() const { + return Geometry{dom, geom.r_min, geom.r_max, Real(0), PolarGeometry::kTwoPi}; + } + + GridContext boundary_context() const { + GridContext context; + context.dom = dom; + context.bc = bc; + context.geom = boundary_geometry(); + context.aux = aux; + context.boundary_plan = boundary_plan; + return context; + } }; namespace detail { @@ -122,50 +139,83 @@ void dispatch_model_polar(const ModelSpec& m, Visitor&& visitor) { }); } -/// Fills the ghosts of a MultiFab on the polar grid (theta periodic + r physical). fill_ghosts -/// already routes periodic vs physical by BCRec (xlo/xhi physical, ylo/yhi periodic): we call it -/// VERBATIM. This is the analogue of the cartesian fill_ghosts(U, dom, bc) of BlockRhsEval. -inline void fill_ghosts_polar(MultiFab& U, const Box2D& dom, const BCRec& bc) { - fill_ghosts(U, dom, bc); -} - -/// Polar residual functor R = -div_polar F + S (fill_ghosts then assemble_rhs_polar). NAMED FUNCTOR -/// (counterpart of cartesian detail::BlockRhsEval): this is what take_step receives, triggering the -/// instantiation of assemble_rhs_polar and its device kernels. @c wall_radial: solid -/// radial wall (no-penetration) -> mass conserved to machine precision (see assemble_rhs_polar). +/// Frozen polar residual (fill_ghosts + assemble_rhs_polar) installed as the block's rhs_into (eval_rhs). template -struct PolarBlockRhsEval { - Model model; - const PolarGridContext* ctx; +struct PolarRhsInto { + Model m; + PolarGridContext ctx; bool recon_prim; - bool wall_radial; Real pos_floor = Real(0); ///< Zhang-Shu positivity limiter (<= 0: inactive, bit-identical) void operator()(MultiFab& U, MultiFab& R) const { - fill_ghosts_polar(U, ctx->dom, ctx->bc); - assemble_rhs_polar(model, U, *ctx->aux, ctx->geom, R, recon_prim, wall_radial, + if (!ctx.boundary_plan) + throw std::runtime_error("polar transport has no prepared boundary plan"); + ctx.boundary_plan->fill_same_level_and_physical(U, ctx.boundary_geometry()); + assemble_rhs_polar(m, U, *ctx.aux, ctx.geom, R, *ctx.boundary_plan, recon_prim, + pos_floor); + } + void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, + MultiFab& R) const { + if (point.level != 0) + throw std::invalid_argument( + "uniform polar Program residual requires BoundaryEvaluationPoint.level == 0"); + if (!ctx.boundary_plan) + throw std::runtime_error("polar transport has no prepared boundary plan"); + auto lane = ExecutionLane::world(ctx.boundary_plan->identity(), "::polar-boundary-control"); + auto session = ctx.boundary_plan->make_session(lane); + session.prepare_trace_recovery_workspace(U); + session.fill_same_level_and_physical(U, ctx.boundary_geometry(), point); + assemble_rhs_polar(m, U, *ctx.aux, ctx.geom, R, *ctx.boundary_plan, recon_prim, pos_floor); } }; -/// Frozen polar residual (fill_ghosts + assemble_rhs_polar) installed as the block's rhs_into (eval_rhs). +/// Point-qualified polar transport core. The persistent overload consumes the exact System-owned +/// PreparedGridBoundarySession selected at bind; neither overload reconstructs a BCRec authority. template -struct PolarRhsInto { +struct PolarRhsCoreInto { Model m; PolarGridContext ctx; bool recon_prim; - bool wall_radial; - Real pos_floor = Real(0); ///< Zhang-Shu positivity limiter (<= 0: inactive, bit-identical) - void operator()(MultiFab& U, MultiFab& R) const { - fill_ghosts_polar(U, ctx.dom, ctx.bc); - assemble_rhs_polar(m, U, *ctx.aux, ctx.geom, R, recon_prim, wall_radial, - pos_floor); - } + Real pos_floor = Real(0); + void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, MultiFab& R) const { + PolarRhsInto{m, ctx, recon_prim, pos_floor}(point, U, R); + } + + void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, + MultiFab& R, const PreparedGridBoundarySession& boundary) const { if (point.level != 0) throw std::invalid_argument( "uniform polar Program residual requires BoundaryEvaluationPoint.level == 0"); - (*this)(U, R); + fill_grid_ghosts(U, boundary, point); + assemble_rhs_polar(m, U, *ctx.aux, ctx.geom, R, *ctx.boundary_plan, recon_prim, + pos_floor); + } +}; + +struct PolarBoundaryResidualInto { + GridContext ctx; + void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, + MultiFab& R) const { + add_grid_boundary_residual(U, R, ctx, point); + } + void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, + MultiFab& R, const PreparedGridBoundarySession& boundary) const { + add_grid_boundary_residual(U, R, boundary, point); + } +}; + +struct PolarBoundaryJvpInto { + GridContext ctx; + void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, + const MultiFab& V, MultiFab& J) const { + apply_grid_boundary_jvp(U, V, J, ctx, point); + } + void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, + const MultiFab& V, MultiFab& J, + const PreparedGridBoundarySession& boundary) const { + apply_grid_boundary_jvp(U, V, J, boundary, point); } }; @@ -246,54 +296,77 @@ inline void derive_aux_polar(const MultiFab& phi, MultiFab& aux, const PolarGeom } /// Spatial closures of a POLAR block for a frozen scheme (Limiter x Flux). Counterpart of Cartesian -/// build_block. @p wall_radial: solid radial wall (no-penetration) -> mass conservation to machine -/// precision. +/// build_block. Ghost production and radial flux closure are both selected by the same immutable +/// PreparedBoundaryPlan captured in the context. template BlockClosures build_block_polar(const Model& m, const PolarGridContext& ctx, bool recon_prim, - bool wall_radial, Real pos_floor = Real(0)) { + Real pos_floor = Real(0)) { + if (!ctx.boundary_plan) + throw std::invalid_argument("build_block_polar requires a prepared boundary plan"); + if (ctx.boundary_plan->has_component_boundaries() || ctx.boundary_plan->has_omitted_faces()) + throw std::invalid_argument( + "polar transport does not yet support native boundary components or shared-interface " + "face omission"); BlockClosures bc; - bc.rhs_into = - detail::PolarRhsInto{m, ctx, recon_prim, wall_radial, pos_floor}; + bc.base_spatial_geometry = SpatialProviderGeometry::Polar; + bc.spatial_provider = make_polar_spatial_provider(kNativeDimension); + bc.rhs_into = detail::PolarRhsInto{m, ctx, recon_prim, pos_floor}; // A polar Program owns the same exact stage/clock identity as a Cartesian Program even though // the current radial-wall/theta-periodic ghost producer is time independent. Install a genuine // point-qualified polar residual instead of falling back to an unqualified spatial route. - bc.rhs_at_point = - detail::PolarRhsInto{m, ctx, recon_prim, wall_radial, pos_floor}; + bc.rhs_at_point = detail::PolarRhsInto{m, ctx, recon_prim, pos_floor}; + bc.rhs_flux_only = detail::PolarRhsInto>{ + SourceFreeModel{m}, ctx, recon_prim, pos_floor}; + bc.rhs_flux_only_at_point = detail::PolarRhsInto>{ + SourceFreeModel{m}, ctx, recon_prim, pos_floor}; + bc.rhs_core_at_point = + detail::PolarRhsCoreInto{m, ctx, recon_prim, pos_floor}; + bc.rhs_flux_only_core_at_point = detail::PolarRhsCoreInto>{ + SourceFreeModel{m}, ctx, recon_prim, pos_floor}; + const GridContext boundary_context = ctx.boundary_context(); + bc.boundary_residual_at_point = detail::PolarBoundaryResidualInto{boundary_context}; + bc.boundary_jvp_at_point = detail::PolarBoundaryJvpInto{boundary_context}; + bc.rhs_core_at_point_prepared = + detail::PolarRhsCoreInto{m, ctx, recon_prim, pos_floor}; + bc.rhs_flux_only_core_at_point_prepared = + detail::PolarRhsCoreInto>{SourceFreeModel{m}, + ctx, recon_prim, pos_floor}; + bc.boundary_residual_at_point_prepared = detail::PolarBoundaryResidualInto{boundary_context}; + bc.boundary_jvp_at_point_prepared = detail::PolarBoundaryJvpInto{boundary_context}; return bc; } /// Dispatch of the spatial scheme (frozen limiter, Riemann flux) -> compiled polar closures. -/// Two fluxes wired in polar, SAME template injection point as the cartesian one (build_block_polar -/// carries the Flux parameter down to assemble_rhs_polar): +/// Four fluxes wired in polar through the SAME template injection point as the Cartesian one +/// (build_block_polar carries the Flux parameter down to assemble_rhs_polar): /// - "rusanov": RusanovFlux, requires only max_wave_speed (valid for scalar ExB AND the /// isothermal fluid) -- DEFAULT, strictly bit-identical to history; /// - "hll": HLLFlux (signed waves), GATE identical to the cartesian one (make_block) on the /// presence of model.wave_speeds. The polar isothermal fluid (IsothermalFluxPolar: /// inherits IsothermalFlux::wave_speeds) is eligible -> HLL less diffusive than Rusanov /// on the ring. The scalar ExB (ExBVelocityPolar, no wave_speeds) -> CLEAR rejection. -/// HLLC/Roe stay NOT wired in polar because no oriented metric provider supplies their contact/Roe -/// capability yet -> explicit rejection. "weno5" routes assemble_rhs_polar onto the WENO5-Z reconstruction -/// (3 ghosts) like the cartesian one. @p wall_radial: solid radial wall (mass conservation to machine -/// precision; see build_block_polar). +/// - "hllc" / "roe": exactly the same HasHLLCStructure / HasRoeDissipation gates as Cartesian. +/// The annular operator supplies the oriented FaceContext and metric measure; the physical +/// model supplies its contact/star or Roe action. A missing capability is rejected explicitly +/// and never selects HLL or Rusanov. +/// "weno5" routes assemble_rhs_polar onto the WENO5-Z reconstruction (3 ghosts) like the +/// Cartesian one. Radial wall/outflow selection is carried exclusively by @p ctx.boundary_plan. template BlockClosures make_block_polar(const Model& m, const std::string& lim, const std::string& riem, - const PolarGridContext& ctx, bool recon_prim, bool wall_radial, + const PolarGridContext& ctx, bool recon_prim, Real pos_floor = Real(0)) { // CENTRALIZED VALIDATION (registry dispatch_tags.hpp) BEFORE the dispatch: in polar, rusanov AND - // hll are wired (hll since the rest of the audit); HLLC/Roe and unknown tags raise the polar - // message of the registry. The CAPABILITY GUARD (hll requires model.wave_speeds) stays an - // `if constexpr` PER MODEL below, with its dedicated "requires ..." message. + // all public providers are wired. Their CAPABILITY GUARDS stay `if constexpr` PER MODEL below, + // with dedicated "requires ..." messages and no numerical fallback. validate_riemann(riem, /*polar=*/true, "System (polar)"); validate_limiter(lim, "System (polar)"); - // Parse the validated tag ONCE (ADC-641): only rusanov / hll are wired in polar, so the switch has two - // arms plus a default. The default keeps the "valid tag, not wired in polar" path for HLLC/Roe, - // already rejected by validate_riemann(polar=true), and suppresses -Wswitch on the partial switch. + // Parse the validated tag ONCE (ADC-641). Every public provider has one capability-gated leaf. switch (parse_riemann_route(riem, "System (polar)")) { case RiemannRouteId::kRusanov: return dispatch_limiter( parse_limiter_route(lim, "System (polar)"), "System (polar)", [&](auto tag) { using L = typename decltype(tag)::type; - return build_block_polar(m, ctx, recon_prim, wall_radial, pos_floor); + return build_block_polar(m, ctx, recon_prim, pos_floor); }); case RiemannRouteId::kHll: // GATE IDENTICAL TO THE CARTESIAN ONE (block_builder.hpp make_block, 'hll' branch): HLL is @@ -308,7 +381,7 @@ BlockClosures make_block_polar(const Model& m, const std::string& lim, const std return dispatch_limiter( parse_limiter_route(lim, "System (polar)"), "System (polar)", [&](auto tag) { using L = typename decltype(tag)::type; - return build_block_polar(m, ctx, recon_prim, wall_radial, pos_floor); + return build_block_polar(m, ctx, recon_prim, pos_floor); }); } else { throw std::runtime_error( @@ -317,6 +390,30 @@ BlockClosures make_block_polar(const Model& m, const std::string& lim, const std "fluid " "(transport='isothermal') declares them and accepts 'hll'."); } + case RiemannRouteId::kHllc: + if constexpr (HasHLLCStructure) { + return dispatch_limiter( + parse_limiter_route(lim, "System (polar)"), "System (polar)", [&](auto tag) { + using L = typename decltype(tag)::type; + return build_block_polar(m, ctx, recon_prim, pos_floor); + }); + } else { + throw std::runtime_error( + "System (polar): flux 'hllc' requires the model's exact HasHLLCStructure " + "capability (pressure + wave_speeds + contact_speed + hllc_star_state); no fallback"); + } + case RiemannRouteId::kRoe: + if constexpr (HasRoeDissipation) { + return dispatch_limiter( + parse_limiter_route(lim, "System (polar)"), "System (polar)", [&](auto tag) { + using L = typename decltype(tag)::type; + return build_block_polar(m, ctx, recon_prim, pos_floor); + }); + } else { + throw std::runtime_error( + "System (polar): flux 'roe' requires the model's exact HasRoeDissipation capability " + "(roe_dissipation); no fallback"); + } default: throw_registry_dispatch_mismatch("System (polar)", "Riemann flux", riem); } diff --git a/include/pops/runtime/builders/block/block_seam.hpp b/include/pops/runtime/builders/block/block_seam.hpp index 68edc60f5..34f894b10 100644 --- a/include/pops/runtime/builders/block/block_seam.hpp +++ b/include/pops/runtime/builders/block/block_seam.hpp @@ -6,6 +6,7 @@ #include // dispatch_model_for + resolve_implicit_components + ModelSpec #include +#include #include #include #include @@ -38,8 +39,13 @@ struct BuiltBlock { BlockClosures clo; std::function max_speed; std::function add_poisson_rhs; - std::function src_freq, stab_dt; // optional step bounds (model traits) - std::function prim_to_cons, cons_to_prim; // System::CellConvert + std::function src_freq, stab_dt; // optional step bounds (model traits) + std::function prim_to_cons; // System::CellConvert + std::function cons_to_prim; // System::CellRecovery + UniformCellRecovery batch_cons_to_prim; // generation-qualified host/Uniform materialization + /// Compatibility-only plan lowered once while building a native polar block. System publishes + /// this same shared object before installation; the generated closures already capture it. + std::shared_ptr synthesized_boundary_plan; int aux_width = 0; // aux_comps() (Cartesian); unused on the polar path (no ensure_aux_width) }; @@ -90,12 +96,13 @@ BuiltBlock build_block_for_make(TR tr, const ModelSpec& model, const BlockBuildA auto conv = make_cell_convert(m); out.prim_to_cons = std::move(conv.first); out.cons_to_prim = std::move(conv.second); + out.batch_cons_to_prim = make_uniform_recovery_consumer(m); }); return out; } /// Per-transport seam body: the full make_block dispatcher (all fluxes). Used by transports that are NOT -/// flux-subdivided (exb -- only rusanov reachable via the capability guards; isothermal -- rusanov+hll). +/// flux-subdivided (exb -- only rusanov reachable via the capability guards). template BuiltBlock build_block_for(TR tr, const ModelSpec& model, const BlockBuildArgs& a) { return build_block_for_make(std::move(tr), model, a, [](auto m, const BlockBuildArgs& aa) { @@ -109,14 +116,19 @@ BuiltBlock build_block_for(TR tr, const ModelSpec& model, const BlockBuildArgs& // IsothermalFlux{cs2, vacuum_floor}). BuiltBlock build_block_exb(const ModelSpec& model, const BlockBuildArgs& a); -// Isothermal (3-var fluid) carries two reachable fluxes (rusanov + hll; hllc/roe need 4-var + pressure) -// x 4 limiters x 15 models -- the post-split long pole -- so it is FLUX-SUBDIVIDED like compressible -// (ADC-342): one .cpp per reachable flux. System dispatches on the riemann string; an unsupported flux -// (incl. hllc/roe) is caught by the shared validate_riemann + the registry throw. +// Isothermal (3-var fluid) carries all four single-solver providers plus the fixed recovery policy +// through its exact physical +// capabilities. It stays FLUX-SUBDIVIDED like compressible (ADC-342): one generated .cpp per +// reachable flux, with no alternate Euler-specific builder. BuiltBlock build_block_isothermal_rusanov(const ModelSpec& model, const BlockBuildArgs& a); BuiltBlock build_block_isothermal_hll(const ModelSpec& model, const BlockBuildArgs& a); +BuiltBlock build_block_isothermal_hllc(const ModelSpec& model, const BlockBuildArgs& a); +BuiltBlock build_block_isothermal_roe(const ModelSpec& model, const BlockBuildArgs& a); +BuiltBlock build_block_isothermal_roe_hll_rusanov_recovery(const ModelSpec& model, + const BlockBuildArgs& a); -// Compressible (Euler, 4-var + pressure) is the heaviest transport: all four fluxes are valid, so it is +// Compressible (Euler, 4-var + pressure) is the heaviest transport: all four single-solver fluxes +// plus the fixed recovery policy are valid, so it is // FLUX-SUBDIVIDED into one .cpp per flux (ADC-335) -- each instantiates only its flux's build_block // leaves, so they compile in parallel. System dispatches on the riemann string to the right one (every // flux is valid for Euler, so no capability rejection to reproduce; an unknown flux is caught by the @@ -125,10 +137,13 @@ BuiltBlock build_block_compressible_rusanov(const ModelSpec& model, const BlockB BuiltBlock build_block_compressible_hll(const ModelSpec& model, const BlockBuildArgs& a); BuiltBlock build_block_compressible_hllc(const ModelSpec& model, const BlockBuildArgs& a); BuiltBlock build_block_compressible_roe(const ModelSpec& model, const BlockBuildArgs& a); +BuiltBlock build_block_compressible_roe_hll_rusanov_recovery(const ModelSpec& model, + const BlockBuildArgs& a); // Polar (ring) seam: VERBATIM polar visitor body (make_block_polar + polar makers). IMEX is rejected on // the ring by add_block before this is called. @p aux is &System::Impl::aux (the polar makers read it). -BuiltBlock build_block_polar(const ModelSpec& model, const std::string& limiter, +BuiltBlock build_block_polar(const ModelSpec& model, const std::string& name, + const std::string& state_identity, const std::string& limiter, const std::string& riemann, const PolarGridContext& pctx, bool recon_prim, Real positivity_floor, const MultiFab* aux); diff --git a/include/pops/runtime/builders/block/prepared_boundary_defaults.hpp b/include/pops/runtime/builders/block/prepared_boundary_defaults.hpp new file mode 100644 index 000000000..6cbb62cbe --- /dev/null +++ b/include/pops/runtime/builders/block/prepared_boundary_defaults.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace pops::detail { + +inline const char* prepared_boundary_role_token(VariableRole role) { + switch (role) { + case VariableRole::Density: + return "Density"; + case VariableRole::MomentumX: + return "MomentumX"; + case VariableRole::MomentumY: + return "MomentumY"; + case VariableRole::MomentumZ: + return "MomentumZ"; + case VariableRole::Energy: + return "Energy"; + case VariableRole::VelocityX: + return "VelocityX"; + case VariableRole::VelocityY: + return "VelocityY"; + case VariableRole::VelocityZ: + return "VelocityZ"; + case VariableRole::Pressure: + return "Pressure"; + case VariableRole::Temperature: + return "Temperature"; + case VariableRole::Scalar: + return "Scalar"; + case VariableRole::Custom: + return "Custom"; + case VariableRole::AxialX: + return "AxialX"; + case VariableRole::AxialY: + return "AxialY"; + case VariableRole::AxialZ: + return "AxialZ"; + } + throw std::logic_error("unknown variable role in prepared boundary lowering"); +} + +inline std::string prepared_boundary_face_type(BCType type) { + switch (type) { + case BCType::Periodic: + return "periodic"; + case BCType::Foextrap: + return "foextrap"; + case BCType::Dirichlet: + return "dirichlet"; + case BCType::Robin: + throw std::invalid_argument("hyperbolic transport has no prepared Robin boundary provider"); + case BCType::External: + throw std::invalid_argument( + "hyperbolic transport requires an explicitly installed external boundary provider"); + } + throw std::logic_error("unknown BCType in prepared boundary lowering"); +} + +/// Lower the legacy mesh-level BC descriptor exactly once during block materialization. The +/// returned PreparedBoundaryPlan is the only executable transport authority retained by the +/// closure. `close_radial_flux` is the annular default: radial ghosts remain extrapolated while the +/// already evaluated radial numerical flux is closed by the plan's NoFlux law. +inline std::shared_ptr prepare_builtin_boundary_plan( + const std::string& block_name, const std::string& state_identity, int required_depth, + const VariableSet& variables, const BCRec& descriptor, bool close_radial_flux = false) { + if (block_name.empty() || required_depth < 1 || variables.size < 1 || + static_cast(variables.names.size()) != variables.size || + (!variables.roles.empty() && static_cast(variables.roles.size()) != variables.size)) + throw std::invalid_argument("built-in prepared boundary requires one complete block layout"); + validate_periodic_pairs(descriptor); + + const std::array types{descriptor.xlo, descriptor.xhi, descriptor.ylo, descriptor.yhi}; + std::vector face_types; + std::vector face_identities; + face_types.reserve(4); + face_identities.reserve(4); + for (int face = 0; face < 4; ++face) { + const bool radial_physical = close_radial_flux && face < 2 && types[face] != BCType::Periodic; + face_types.push_back(radial_physical ? "no_flux" : prepared_boundary_face_type(types[face])); + face_identities.push_back("pops://runtime/boundary/" + block_name + "/face/" + + std::to_string(face)); + } + + std::vector component_roles; + component_roles.reserve(static_cast(variables.size)); + for (int component = 0; component < variables.size; ++component) { + const VariableRole role = variables.roles.empty() + ? VariableRole::Custom + : variables.roles[static_cast(component)]; + component_roles.emplace_back(prepared_boundary_role_token(role)); + } + + const std::array values{ + static_cast(descriptor.xlo_val), static_cast(descriptor.xhi_val), + static_cast(descriptor.ylo_val), static_cast(descriptor.yhi_val)}; + std::vector face_values(static_cast(4 * variables.size), 0.0); + for (int component = 0; component < variables.size; ++component) + for (int face = 0; face < 4; ++face) + if (types[face] == BCType::Dirichlet) + face_values[static_cast(4 * component + face)] = values[face]; + + auto hyperbolic = + prepare_hyperbolic_boundary<2>(face_types, face_values, face_identities, component_roles); + return std::make_shared( + "pops://runtime/boundary/" + block_name + "/builtin@1", required_depth, std::move(hyperbolic), + std::vector{}, state_identity); +} + +} // namespace pops::detail diff --git a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp index ed83da32f..d318a904f 100644 --- a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp +++ b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp @@ -1,6 +1,7 @@ #pragma once #include // AmrCouplerMP, AmrLevelMP +#include #include #include #include @@ -22,11 +23,14 @@ #include #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -55,6 +59,78 @@ struct AmrDiscLF { namespace detail { +template +concept ExactAmrTransportModelProvider = + requires(const Model& model, ExactContractBuilder& contract) { + { + Model::transport_model_provider_identity() + } noexcept -> std::same_as; + { model.serialize_exact_transport_parameters(contract) } -> std::same_as; + }; + +template +constexpr std::string_view exact_limiter_route_token() noexcept { + if constexpr (std::is_same_v) + return "none"; + if constexpr (std::is_same_v) + return "minmod"; + if constexpr (std::is_same_v) + return "vanleer"; + if constexpr (std::is_same_v) + return "weno5"; + if constexpr (std::is_same_v) + return "mc"; + if constexpr (std::is_same_v) + return "superbee"; + return {}; +} + +template +constexpr std::string_view exact_riemann_route_token() noexcept { + if constexpr (std::is_same_v) + return "rusanov"; + if constexpr (std::is_same_v) + return "hll"; + if constexpr (std::is_same_v) + return "hllc"; + if constexpr (std::is_same_v) + return "roe"; + if constexpr (std::is_same_v) + return "roe_hll_rusanov_recovery"; + return {}; +} + +template +void prepare_amr_transport_flux_contract(const Model& model, bool reconstruct_primitive, + Real positivity_floor, Real weno_epsilon, + bool wave_speed_cache, AmrRuntimeBlock& block) { + constexpr std::string_view limiter = exact_limiter_route_token(); + constexpr std::string_view riemann = exact_riemann_route_token(); + if constexpr (ExactAmrTransportModelProvider && !limiter.empty() && !riemann.empty()) { + const PreparedProviderIdentity model_identity = Model::transport_model_provider_identity(); + if (model_identity.name.empty() || model_identity.version == 0) + throw std::invalid_argument( + "AMR transport model provider requires a non-empty identity and non-zero version"); + ExactContractBuilder model_parameters; + model.serialize_exact_transport_parameters(model_parameters); + ExactContractBuilder contract; + contract.text("pops.amr.compiled-transport-flux") + .scalar(std::uint32_t{1}) + .text(model_identity.name) + .scalar(model_identity.version) + .bytes(model_parameters.view()) + .text(limiter) + .text(riemann) + .scalar(reconstruct_primitive) + .scalar(positivity_floor) + .scalar(weno_epsilon) + .scalar(wave_speed_cache) + .scalar(static_cast(Model::n_vars)); + block.transport_flux_provider_identity = "pops.amr.compiled-transport-flux@1"; + block.transport_flux_parameter_contract = std::move(contract).release(); + } +} + template void compute_amr_face_fluxes(const Model& model, const MultiFab& state, const MultiFab& aux, MultiFab& flux_x, MultiFab& flux_y, Real dx, Real dy, @@ -207,19 +283,28 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, const int nc = Model::n_vars; const int ng = Limiter::n_ghost; const int nlev = S.nlev(); - std::shared_ptr boundary_plan; + std::shared_ptr prepared_boundary_plan; if (S.boundary_plans != nullptr) { auto found = S.boundary_plans->find(name); if (found != S.boundary_plans->end()) - boundary_plan = found->second; + prepared_boundary_plan = found->second; + } + auto conversion = make_cell_convert(model); + if (prepared_boundary_plan) { + if (prepared_boundary_plan->requires_fixed_state_conversion()) + prepared_boundary_plan->prepare_fixed_state_conversion(conversion.first); + if (prepared_boundary_plan->requires_characteristic_no_inflow()) + prepared_boundary_plan->prepare_characteristic_no_inflow( + detail::make_characteristic_no_inflow_fill( + model, prepared_boundary_plan->hyperbolic_boundary())); + prepared_boundary_plan->prepare_trace_recovery(conversion.second); } - BCRec transport_bc; + std::shared_ptr boundary_plan = prepared_boundary_plan; + BCRec boundary_descriptor; if (!S.base_per.x) - transport_bc.xlo = transport_bc.xhi = BCType::Foextrap; + boundary_descriptor.xlo = boundary_descriptor.xhi = BCType::Foextrap; if (!S.base_per.y) - transport_bc.ylo = transport_bc.yhi = BCType::Foextrap; - auto transport_boundary_fill = std::make_shared( - make_amr_boundary_fill_authority(transport_bc)); + boundary_descriptor.ylo = boundary_descriptor.yhi = BCType::Foextrap; auto boundary_field_registry = std::make_shared(); auto levels = std::make_shared>(); levels->reserve(nlev); @@ -250,10 +335,13 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, b.reconstruction_ghost_depth = Limiter::n_ghost; b.cons_vars = Model::conservative_vars(); // names + ROLES: role resolution -> component of coupled sources + b.cons_to_prim = std::move(conversion.second); b.levels = levels; b.boundary_plan = boundary_plan; b.boundary_field_registry = boundary_field_registry; - b.transport_boundary_fill = transport_boundary_fill; + prepare_amr_transport_flux_contract( + model, recon_prim, static_cast(pos_floor), static_cast(weno_epsilon), + wave_speed_cache, b); const bool rprim = recon_prim; const Real pf = static_cast(pos_floor); const Real weps = static_cast(weno_epsilon); @@ -278,7 +366,7 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, // lambda), instantiated HERE on the concrete Model/Limiter/Flux, so the kernel stays compiled and runs // Serial / OpenMP / CUDA identically. These closures are read only by an installed Program. { - const BCRec tbc = transport_bc; + const BCRec tbc = boundary_descriptor; b.level_rhs = [model, rprim, pf, weps, ws_cache, tbc, boundary_plan]( MultiFab& U, const MultiFab& aux, const Geometry& geom, MultiFab& R) { GridContext gc; @@ -500,7 +588,8 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, boundary.fill_same_level_and_physical(U, point); detail::compute_amr_face_fluxes(model, U, aux, Fx, Fy, geom.dx(), geom.dy(), rprim, pf, weps, ws_cache); - detail::zero_prepared_interface_fluxes(Fx, Fy, boundary.context()); + transform_grid_boundary_fluxes(U, Fx, Fy, boundary, point); + detail::zero_prepared_boundary_fluxes(Fx, Fy, boundary.context()); pops::mf_eval_rhs(model, U, aux, Fx, Fy, geom.dx(), geom.dy(), R); }; b.level_flux_capture_neg_div_prepared = @@ -512,7 +601,8 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, boundary.fill_same_level_and_physical(U, point); detail::compute_amr_face_fluxes(sm, U, aux, Fx, Fy, geom.dx(), geom.dy(), rprim, pf, weps, ws_cache); - detail::zero_prepared_interface_fluxes(Fx, Fy, boundary.context()); + transform_grid_boundary_fluxes(U, Fx, Fy, boundary, point); + detail::zero_prepared_boundary_fluxes(Fx, Fy, boundary.context()); pops::mf_eval_rhs(sm, U, aux, Fx, Fy, geom.dx(), geom.dy(), R); }; } @@ -569,7 +659,7 @@ AmrRuntimeBlock build_amr_block(const Model& model, const SharedAmrLayout& S, // branch of dispatch_amr_block VERBATIM (same leaves, same hllc/roe `if constexpr` capability guards, same // messages); validate_riemann/limiter run in the caller (dispatch_amr_block, or the compressible thin // dispatcher python/amr_block_compressible.cpp). dispatch_amr_block (below, unchanged) still serves the -// exb/isothermal seam, where the if constexpr guards prune hllc/roe. +// transport seam, where the same if constexpr guards admit or refuse each concrete provider. template AmrRuntimeBlock dispatch_amr_block_rusanov(const Model& m, const std::string& lim, const SharedAmrLayout& S, const std::string& name, @@ -657,6 +747,33 @@ AmrRuntimeBlock dispatch_amr_block_roe(const Model& m, const std::string& lim, } } +template +AmrRuntimeBlock dispatch_amr_block_roe_hll_rusanov_recovery( + const Model& m, const std::string& lim, const SharedAmrLayout& S, const std::string& name, + const std::vector& density, bool has_density, double gamma, int substeps, + bool recon_prim, int stride, const std::vector* state, double pos_floor, + double weno_epsilon, bool wave_speed_cache) { + if constexpr (!HasRoeDissipation) { + throw std::runtime_error( + "add_block(AmrSystem, multi-block): recovery policy 'roe -> hll -> rusanov' requires " + "the model's Roe capability (HasRoeDissipation); no candidate substitution"); + } else if constexpr (!requires(const Model mm, typename Model::State s, Aux a, Real r) { + mm.wave_speeds(s, a, 0, r, r); + }) { + throw std::runtime_error( + "add_block(AmrSystem, multi-block): recovery policy 'roe -> hll -> rusanov' requires " + "signed wave speeds for its declared HLL candidate; no candidate substitution"); + } else { + return dispatch_limiter(parse_limiter_route(lim, "add_block(AmrSystem, multi-block)"), + "add_block(AmrSystem, multi-block)", [&](auto tag) { + using L = typename decltype(tag)::type; + return build_amr_block( + m, S, name, density, has_density, gamma, substeps, recon_prim, + stride, state, pos_floor, weno_epsilon, wave_speed_cache); + }); + } +} + /// Dispatch of the spatial scheme (limiter x Riemann flux) -> build_amr_block. HLLC/Roe require /// the model's exact Riemann capability HasHLLCStructure / HasRoeDissipation. Time integration and /// implicit solves are not part of this spatial seam. @@ -671,7 +788,7 @@ AmrRuntimeBlock dispatch_amr_block(const Model& m, const std::string& lim, const bool wave_speed_cache = false) { // CENTRALIZED VALIDATION (dispatch_tags.hpp registry) BEFORE the dispatch: same tags accepted / // rejected as before, identical messages. The template if/else dispatch that follows is UNCHANGED; the - // capability guards (hllc/roe: 2D Euler or capability) stay `if constexpr` PER MODEL. + // capability guards (hllc/roe: exact physical provider capability) stay `if constexpr` PER MODEL. validate_riemann(riem, /*polar=*/false, "add_block(AmrSystem, multi-block)"); validate_limiter(lim, "add_block(AmrSystem, multi-block)"); if (!std::isfinite(weno_epsilon) || weno_epsilon <= 0.0) @@ -684,7 +801,8 @@ AmrRuntimeBlock dispatch_amr_block(const Model& m, const std::string& lim, const "add_block(AmrSystem, multi-block): wave_speed_cache requires flux='hll'"); // ADC-359: delegate to the flux-pinned dispatch_amr_block_ helpers above (factored so the // compressible seam compiles one flux per TU). Behavior is unchanged: same leaves, same hllc/roe - // capability guards, same throws. exb/isothermal route here as before (their guards prune hllc/roe). + // capability guards and same throws. ExB is refused; native isothermal is admitted by its exact + // HLLC/Roe provider hooks. // ADC-641: parse the validated tag ONCE into the typed RiemannRouteId; the switch decodes it and the switch (parse_riemann_route(riem, "add_block(AmrSystem, multi-block)")) { case RiemannRouteId::kRusanov: @@ -703,6 +821,10 @@ AmrRuntimeBlock dispatch_amr_block(const Model& m, const std::string& lim, const return dispatch_amr_block_roe(m, lim, S, name, density, has_density, gamma, substeps, recon_prim, stride, state, pos_floor, weno_epsilon, wave_speed_cache); + case RiemannRouteId::kRoeHllRusanovRecovery: + return dispatch_amr_block_roe_hll_rusanov_recovery(m, lim, S, name, density, has_density, + gamma, substeps, recon_prim, stride, state, + pos_floor, weno_epsilon, wave_speed_cache); } throw_registry_dispatch_mismatch("add_block(AmrSystem, multi-block)", "flux", riem); } diff --git a/include/pops/runtime/builders/compiled/dsl_block.hpp b/include/pops/runtime/builders/compiled/dsl_block.hpp index 7214fdfa1..765a64a44 100644 --- a/include/pops/runtime/builders/compiled/dsl_block.hpp +++ b/include/pops/runtime/builders/compiled/dsl_block.hpp @@ -88,6 +88,11 @@ void add_compiled_model(System& sys, const std::string& name, Model model, // recompiled against this header (ABI key verified) carries them too. auto conv = make_cell_convert(model); sys.set_block_conversion(name, std::move(conv.first), std::move(conv.second)); + if (ctx.boundary_plan && ctx.boundary_plan->requires_characteristic_no_inflow()) + sys.set_block_characteristic_no_inflow( + name, detail::make_characteristic_no_inflow_fill(model, + ctx.boundary_plan->hyperbolic_boundary())); + sys.set_block_batch_recovery(name, make_uniform_recovery_consumer(model)); // OPTIONAL step bounds of the model (HasSourceFrequency / HasStabilityDt traits, see // core/physical_model.hpp): compiled here like flux/source (a DSL model declaring // m.source_frequency(...) / m.stability_dt(...) carries them down to the System's step_cfl). @@ -97,7 +102,8 @@ void add_compiled_model(System& sys, const std::string& name, Model model, // Scheme GHOSTS: WENO5 reads a 5-point stencil (3 ghosts) > the 2 allocated by install_block. // We reallocate the block state with block_n_ghost(limiter) -- SAME mechanism as add_block (PR #88) -- // so that fill_boundary + assemble_rhs do not read out of bounds on the System's real MultiFab. - // none/minmod/vanleer (<= 2 ghosts): no-op, allocation and result bit-identical to before. + // Any catalogue limiter requiring <= 2 ghosts (none/MUSCL family): no-op; allocation and result + // stay bit-identical to the prepared route. sys.set_block_ghosts(name, block_n_ghost(limiter)); } diff --git a/include/pops/runtime/builders/scheme_dispatch.hpp b/include/pops/runtime/builders/scheme_dispatch.hpp index 78b791853..d75961baa 100644 --- a/include/pops/runtime/builders/scheme_dispatch.hpp +++ b/include/pops/runtime/builders/scheme_dispatch.hpp @@ -1,7 +1,7 @@ #pragma once #include // POPS_COLD_FN -#include // NoSlope / Minmod / VanLeer / Weno5 +#include // Prepared reconstruction policies #include // throw_registry_dispatch_mismatch #include // LimiterRouteId, route_token, kLimiterRoutes @@ -40,7 +40,9 @@ namespace pops { X(kNone, NoSlope) \ X(kMinmod, Minmod) \ X(kVanLeer, VanLeer) \ - X(kWeno5, Weno5) + X(kWeno5, Weno5) \ + X(kMc, MC) \ + X(kSuperbee, Superbee) namespace detail { constexpr int kLimiterXMacroCount = 0 diff --git a/include/pops/runtime/config/dispatch_tags.hpp b/include/pops/runtime/config/dispatch_tags.hpp index e5c73ba59..1e2629980 100644 --- a/include/pops/runtime/config/dispatch_tags.hpp +++ b/include/pops/runtime/config/dispatch_tags.hpp @@ -81,17 +81,16 @@ inline void validate_limiter(const std::string& lim, const char* ctx = "System") throw std::runtime_error(std::string(ctx) + ": unknown limiter '" + lim + "'"); } -/// Validates a Riemann FLUX tag against kRiemanns. @p polar: annular geometry (rusanov and hll are -/// wired there). Throws if unknown (cartesian) or not wired in polar, naming the generated valid +/// Validates a Riemann FLUX tag against kRiemanns. @p polar: annular geometry. Throws if unknown +/// (Cartesian) or not wired in polar, naming the generated valid /// set. Does NOT validate the model /// capabilities (hll/hllc/roe on a transport without signed waves / without pressure): these guards /// stay `if constexpr` PER MODEL at the call-site, with their "requires ..." messages unchanged. inline void validate_riemann(const std::string& riem, bool polar = false, const char* ctx = "System") { if (polar) { - // Polar: wired fluxes = those of kRiemanns with polar_ok (rusanov + hll since the audit - // settlement; hll keeps its model.wave_speeds capability gate at the call-site). HLLC/Roe and - // unknown tags -> single polar message. + // Polar: wired fluxes = those of kRiemanns with polar_ok. Model-dependent requirements remain + // at the call-site; registry validation never infers a model or silently changes the solver. for (const RiemannTag& t : kRiemanns) if (riem == t.name && t.polar_ok) return; diff --git a/include/pops/runtime/config/generated_component_abi.hpp b/include/pops/runtime/config/generated_component_abi.hpp index a698145a9..7492946e0 100644 --- a/include/pops/runtime/config/generated_component_abi.hpp +++ b/include/pops/runtime/config/generated_component_abi.hpp @@ -15,7 +15,7 @@ extern "C" { #endif #define POPS_COMPONENT_API_SYMBOL_V1 "pops_component_interface_v1" -#define POPS_COMPONENT_CATALOG_SHA256_V1 "5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6" +#define POPS_COMPONENT_CATALOG_SHA256_V1 "b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66" #define POPS_COMPONENT_PROTOCOL_ABI_V1 1u #define POPS_COMPONENT_COMMON_ABI_V1 1u @@ -26,9 +26,11 @@ typedef enum PopsNativeInterfaceIdV1 { POPS_NATIVE_INTERFACE_TAGGER_V2 = 3, POPS_NATIVE_INTERFACE_CLUSTERING_V1 = 4, POPS_NATIVE_INTERFACE_TRANSFER_V1 = 5, + POPS_NATIVE_INTERFACE_REFLUX_V1 = 6, POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2 = 7, POPS_NATIVE_INTERFACE_WRITER_V1 = 8, POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2 = 9, + POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1 = 10, } PopsNativeInterfaceIdV1; typedef enum PopsTaggingOpcodeV1 { @@ -289,6 +291,35 @@ typedef struct PopsGhostBoundaryApiV1 { PopsApplyRegionBatchFnV1 apply_region_batch; } PopsGhostBoundaryApiV1; +typedef struct PopsBoundaryFluxRequestV1 { + uint32_t struct_size; + const char* provider_identity; + const char* state_identity; + PopsConstFieldViewV1 base_outward_normal_flux; + PopsConstFieldViewV1 coordinates; + PopsConstFieldViewV1 outward_normals; + const double* face_measures; + PopsBoundaryRegionV1 region; + size_t dependency_count; + const PopsQualifiedConstFieldV1* dependencies; + size_t parameter_count; + const PopsQualifiedScalarV1* parameters; + PopsLogicalTimeV1 logical_time; + PopsExecutionContextV1 execution; +} PopsBoundaryFluxRequestV1; +typedef struct PopsBoundaryFluxResultV1 { + uint32_t struct_size; + PopsFieldViewV1 outward_normal_flux; + PopsComponentActionV1* actions; + PopsComponentStatusV1 status; +} PopsBoundaryFluxResultV1; +typedef int32_t (*PopsTransformBoundaryFacesFnV1)( + void*, const PopsBoundaryFluxRequestV1*, PopsBoundaryFluxResultV1*); +typedef struct PopsBoundaryFluxApiV1 { + PopsComponentTableHeaderV1 header; + PopsTransformBoundaryFacesFnV1 transform_faces; +} PopsBoundaryFluxApiV1; + typedef struct PopsFieldBoundaryRequestV1 { uint32_t struct_size; const char* closure_identity; @@ -446,6 +477,41 @@ typedef struct PopsTransferApiV1 { PopsTransferApplyFnV1 apply; } PopsTransferApiV1; +// Reflux providers are patch-local numerical kernels only. PoPS retains sole ownership of the +// time-integrated flux ledger, interface topology, MPI reduction, transaction and state update. +// Each face contains coarse/fine fluxes already integrated in time and averaged onto the same +// coarse face. The provider writes, but never applies, side*(fine-coarse)/dx into `correction`. +typedef enum PopsRefluxFaceSideV1 { + POPS_REFLUX_FACE_LOW_V1 = -1, + POPS_REFLUX_FACE_HIGH_V1 = 1 +} PopsRefluxFaceSideV1; +typedef struct PopsRefluxFaceV1 { + uint32_t struct_size; + const char* interface_identity; + int32_t axis; + PopsRefluxFaceSideV1 side; + double inverse_coarse_cell_spacing; + PopsConstFieldViewV1 coarse_integrated_flux; + PopsConstFieldViewV1 fine_integrated_flux; + PopsFieldViewV1 correction; +} PopsRefluxFaceV1; +typedef struct PopsRefluxRequestV1 { + uint32_t struct_size; + const char* transition_identity; + int32_t parent_level; + int32_t child_level; + size_t face_count; + const PopsRefluxFaceV1* faces; + PopsLogicalTimeV1 logical_time; + PopsExecutionContextV1 execution; +} PopsRefluxRequestV1; +typedef int32_t (*PopsRefluxApplyInterfaceBatchFnV1)( + void*, const PopsRefluxRequestV1*, PopsComponentStatusV1*); +typedef struct PopsRefluxApiV1 { + PopsComponentTableHeaderV1 header; + PopsRefluxApplyInterfaceBatchFnV1 apply_interface_batch; +} PopsRefluxApiV1; + typedef struct PopsFieldPatchMetadataV1 { uint32_t struct_size; size_t global_patch_index; @@ -724,9 +790,11 @@ inline constexpr size_t generated_native_interface_table_size( case POPS_NATIVE_INTERFACE_TAGGER_V2: return sizeof(PopsTaggerApiV2); case POPS_NATIVE_INTERFACE_CLUSTERING_V1: return sizeof(PopsClusteringApiV1); case POPS_NATIVE_INTERFACE_TRANSFER_V1: return sizeof(PopsTransferApiV1); + case POPS_NATIVE_INTERFACE_REFLUX_V1: return sizeof(PopsRefluxApiV1); case POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2: return sizeof(PopsFieldSolverApiV2); case POPS_NATIVE_INTERFACE_WRITER_V1: return sizeof(PopsWriterApiV1); case POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2: return sizeof(PopsFieldTopologyApiV2); + case POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1: return sizeof(PopsBoundaryFluxApiV1); } return 0; } @@ -739,12 +807,77 @@ inline constexpr const char* generated_native_interface_table_name( case POPS_NATIVE_INTERFACE_TAGGER_V2: return "PopsTaggerApiV2"; case POPS_NATIVE_INTERFACE_CLUSTERING_V1: return "PopsClusteringApiV1"; case POPS_NATIVE_INTERFACE_TRANSFER_V1: return "PopsTransferApiV1"; + case POPS_NATIVE_INTERFACE_REFLUX_V1: return "PopsRefluxApiV1"; case POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2: return "PopsFieldSolverApiV2"; case POPS_NATIVE_INTERFACE_WRITER_V1: return "PopsWriterApiV1"; case POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2: return "PopsFieldTopologyApiV2"; + case POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1: return "PopsBoundaryFluxApiV1"; } return nullptr; } +inline bool generated_native_interface_table_is_complete( + PopsNativeInterfaceIdV1 id, const void* table, size_t table_size) noexcept { + if (table == nullptr) + return false; + switch (id) { + case POPS_NATIVE_INTERFACE_NUMERICAL_FLUX_V1: { + if (table_size < sizeof(PopsNumericalFluxApiV1)) return false; + const auto* api = static_cast(table); + return api->evaluate_faces != nullptr; + } + case POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1: { + if (table_size < sizeof(PopsGhostBoundaryApiV1)) return false; + const auto* api = static_cast(table); + return api->apply_region_batch != nullptr; + } + case POPS_NATIVE_INTERFACE_FIELD_BOUNDARY_CLOSURE_V1: { + if (table_size < sizeof(PopsFieldBoundaryClosureApiV1)) return false; + const auto* api = static_cast(table); + return api->residual != nullptr && api->jvp != nullptr; + } + case POPS_NATIVE_INTERFACE_TAGGER_V2: { + if (table_size < sizeof(PopsTaggerApiV2)) return false; + const auto* api = static_cast(table); + return api->tag_batch != nullptr; + } + case POPS_NATIVE_INTERFACE_CLUSTERING_V1: { + if (table_size < sizeof(PopsClusteringApiV1)) return false; + const auto* api = static_cast(table); + return api->cluster != nullptr; + } + case POPS_NATIVE_INTERFACE_TRANSFER_V1: { + if (table_size < sizeof(PopsTransferApiV1)) return false; + const auto* api = static_cast(table); + return api->apply != nullptr; + } + case POPS_NATIVE_INTERFACE_REFLUX_V1: { + if (table_size < sizeof(PopsRefluxApiV1)) return false; + const auto* api = static_cast(table); + return api->apply_interface_batch != nullptr; + } + case POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2: { + if (table_size < sizeof(PopsFieldSolverApiV2)) return false; + const auto* api = static_cast(table); + return api->solve != nullptr; + } + case POPS_NATIVE_INTERFACE_WRITER_V1: { + if (table_size < sizeof(PopsWriterApiV1)) return false; + const auto* api = static_cast(table); + return api->verify != nullptr && api->publish != nullptr && api->discard != nullptr && api->rollback != nullptr; + } + case POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2: { + if (table_size < sizeof(PopsFieldTopologyApiV2)) return false; + const auto* api = static_cast(table); + return api->prepare_topology != nullptr; + } + case POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1: { + if (table_size < sizeof(PopsBoundaryFluxApiV1)) return false; + const auto* api = static_cast(table); + return api->transform_faces != nullptr; + } + } + return false; +} } // namespace pops::component #endif // clang-format on diff --git a/include/pops/runtime/config/generated_component_catalog.hpp b/include/pops/runtime/config/generated_component_catalog.hpp index 47aad9625..8121353f1 100644 --- a/include/pops/runtime/config/generated_component_catalog.hpp +++ b/include/pops/runtime/config/generated_component_catalog.hpp @@ -88,28 +88,34 @@ enum class RiemannRouteId : int { kHll = 1, kHllc = 2, kRoe = 3, + kRoeHllRusanovRecovery = 4, }; inline constexpr RouteInfo kRiemannRoutes[] = { {0, "rusanov", "pops::RusanovFlux", "physical_flux,provider_pack,stability_bound", ""}, {1, "hll", "pops::HLLFlux", "physical_flux,provider_pack,stability_bound,wave_speeds", ""}, - {2, "hllc", "pops::HLLCFlux", "physical_flux,provider_pack,stability_bound,pressure,wave_speeds,contact_speed,hllc_star_state", "polar metric provider not wired; requires exact HasHLLCStructure capability"}, - {3, "roe", "pops::RoeFlux", "physical_flux,provider_pack,stability_bound,roe_dissipation", "polar metric provider not wired; requires exact HasRoeDissipation capability"}, + {2, "hllc", "pops::HLLCFlux", "physical_flux,provider_pack,stability_bound,pressure,wave_speeds,contact_speed,hllc_star_state", ""}, + {3, "roe", "pops::RoeFlux", "physical_flux,provider_pack,stability_bound,roe_dissipation", ""}, + {4, "roe_hll_rusanov_recovery", "pops::PreparedRiemannRecoveryPolicy", "physical_flux,provider_pack,stability_bound,wave_speeds,roe_dissipation", "fixed ordered policy Roe -> HLL -> Rusanov -> reject,annular polar route unavailable"}, }; -inline constexpr const char* kRiemannRouteTokensCsv = "rusanov|hll|hllc|roe"; +inline constexpr const char* kRiemannRouteTokensCsv = "rusanov|hll|hllc|roe|roe_hll_rusanov_recovery"; enum class LimiterRouteId : int { kNone = 0, kMinmod = 1, kVanLeer = 2, kWeno5 = 3, + kMc = 4, + kSuperbee = 5, }; inline constexpr RouteInfo kLimiterRoutes[] = { {0, "none", "pops::NoSlope", "", ""}, {1, "minmod", "pops::Minmod", "", ""}, {2, "vanleer", "pops::VanLeer", "", ""}, {3, "weno5", "pops::Weno5", "3-cell halo", ""}, + {4, "mc", "pops::MC", "", ""}, + {5, "superbee", "pops::Superbee", "", ""}, }; -inline constexpr const char* kLimiterRouteTokensCsv = "none|minmod|vanleer|weno5"; +inline constexpr const char* kLimiterRouteTokensCsv = "none|minmod|vanleer|weno5|mc|superbee"; enum class ReconRouteId : int { kConservative = 0, @@ -241,6 +247,8 @@ inline constexpr LimiterTag kLimiters[] = { {"minmod", 2}, {"vanleer", 2}, {"weno5", 3}, + {"mc", 2}, + {"superbee", 2}, }; struct RiemannTag { @@ -250,8 +258,9 @@ struct RiemannTag { inline constexpr RiemannTag kRiemanns[] = { {"rusanov", false, false, false, true}, {"hll", true, false, false, true}, - {"hllc", false, true, false, false}, - {"roe", false, false, true, false}, + {"hllc", false, true, false, true}, + {"roe", false, false, true, true}, + {"roe_hll_rusanov_recovery", true, false, true, false}, }; struct TransportTag { const char* name; int n_vars; bool polar_ok; const char* summary; }; @@ -299,11 +308,11 @@ inline constexpr BrickCatalogEntry kBrickCatalog[] = { inline constexpr int kComponentCatalogSchemaVersion = 1; inline constexpr int kComponentManifestSchemaVersion = 2; -inline constexpr int kRouteRegistryVersion = 2; +inline constexpr int kRouteRegistryVersion = 3; inline constexpr int kCapabilityVocabularyVersion = 4; -inline constexpr const char* kComponentCatalogSha256 = "5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6"; -inline constexpr const char* kComponentCatalogSemanticSha256 = "adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea"; -inline constexpr const char* kRouteRegistrySignature = "v2:adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea"; +inline constexpr const char* kComponentCatalogSha256 = "b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66"; +inline constexpr const char* kComponentCatalogSemanticSha256 = "b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3"; +inline constexpr const char* kRouteRegistrySignature = "v3:b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3"; inline constexpr const char* kComponentManifestSemanticFields[] = { "schema_version", "uri", diff --git a/include/pops/runtime/config/generated_release_contract.hpp b/include/pops/runtime/config/generated_release_contract.hpp index 3b601ecdd..8a6c935ca 100644 --- a/include/pops/runtime/config/generated_release_contract.hpp +++ b/include/pops/runtime/config/generated_release_contract.hpp @@ -3,19 +3,21 @@ // clang-format off namespace pops::release_contract { inline constexpr const char* kPackageVersion = "1.0.0"; -inline constexpr int kReleaseContractSchemaVersion = 1; +inline constexpr int kReleaseContractSchemaVersion = 2; inline constexpr int kPublicApiVersion = 1; inline constexpr int kSemanticIrVersion = 1; inline constexpr int kNormalizationVersion = 1; inline constexpr int kComponentCatalogSchemaVersion = 1; inline constexpr int kReleaseComponentManifestSchemaVersion = 2; -inline constexpr int kComponentRegistryVersion = 2; +inline constexpr int kComponentRegistryVersion = 3; inline constexpr int kReleaseCapabilityVocabularyVersion = 4; inline constexpr int kComponentInterfaceAbiVersion = 1; inline constexpr int kReleaseNativeAbiVersion = 3; inline constexpr int kCheckpointEnvelopeSchemaVersion = 1; inline constexpr int kUniformCheckpointPayloadVersion = 5; inline constexpr int kAmrCheckpointPayloadVersion = 7; -inline constexpr const char* kContractSha256 = "677cc4279df230eeedcf0d657b558a1c42479bc41d0e7e1cc8cbdf0e7560a3da"; +inline constexpr const char* kComponentCatalogSha256 = "b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66"; +inline constexpr const char* kComponentCatalogSemanticSha256 = "b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3"; +inline constexpr const char* kContractSha256 = "c3f532c08e06c5fdeceeff5f5ee92ac0f737bd345d9f0fc4f06ae0c9600643a2"; } // namespace pops::release_contract // clang-format on diff --git a/include/pops/runtime/config/generated_route_accessors.inc b/include/pops/runtime/config/generated_route_accessors.inc index 78ad94f1e..44dcda875 100644 --- a/include/pops/runtime/config/generated_route_accessors.inc +++ b/include/pops/runtime/config/generated_route_accessors.inc @@ -1,4 +1,4 @@ -// Generated from component catalog 5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6; DO NOT EDIT. +// Generated from component catalog b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66; DO NOT EDIT. // POPS_DEFINE_ROUTE_ACCESSORS must be defined by the including behavior header. POPS_DEFINE_ROUTE_ACCESSORS(riemann, RiemannRouteId, kRiemannRoutes, kRiemann); POPS_DEFINE_ROUTE_ACCESSORS(limiter, LimiterRouteId, kLimiterRoutes, kLimiter); diff --git a/include/pops/runtime/config/platform_manifest.hpp b/include/pops/runtime/config/platform_manifest.hpp index e83c80e4e..177e277ae 100644 --- a/include/pops/runtime/config/platform_manifest.hpp +++ b/include/pops/runtime/config/platform_manifest.hpp @@ -225,12 +225,12 @@ inline void require_same(const std::string& field, const CapabilityProof& expect throw ContractError(field, field + " mismatch between artifact and runtime backend"); } -inline const CapabilityProof& capability(const RuntimeBackendManifest& backend, - const std::string& name) { - const auto found = backend.capabilities.find(name); - if (found == backend.capabilities.end()) +template +inline const CapabilityProof& capability(const Manifest& manifest, const std::string& name) { + const auto found = manifest.capabilities.find(name); + if (found == manifest.capabilities.end()) throw ContractError("capabilities." + name, - "runtime backend omitted required capability proof " + name); + "platform/runtime manifest omitted required capability proof " + name); return found->second; } @@ -248,6 +248,18 @@ inline void validate_descriptor(const FieldViewDescriptor& view) { if (std::any_of(view.ghosts.begin(), view.ghosts.end(), [](const auto& pair) { return pair.first < 0 || pair.second < 0; })) throw ContractError("field.ghosts", "field ghost widths must be non-negative"); + for (std::size_t axis = 0; axis < rank; ++axis) { + const auto lower = static_cast(view.ghosts[axis].first); + const auto upper = static_cast(view.ghosts[axis].second); + if (lower >= view.extents[axis] || upper >= view.extents[axis] - lower) + throw ContractError("field.ghosts", + "field ghost widths must leave a positive interior extent"); + } + if (view.centering.empty() || view.scalar.empty() || view.memory_space.empty() || + view.patch.empty() || view.layout.empty() || view.ownership.empty()) + throw ContractError("field.metadata", + "field centering, scalar, memory space, patch, layout and ownership " + "must be non-empty"); } template @@ -281,33 +293,67 @@ inline void validate_launch(const PlatformManifest& platform, const ExecutionCon !context.device.has_handle) throw ContractError("device", "non-host execution requires an explicit handle"); + for (const std::string name : + {"dimensions", "centerings", "scalars", "layouts", "ownership", "generic_field_view"}) + require_same("capabilities." + name, capability(platform, name), capability(backend, name)); + const auto& generic_field_view = + require(capability(backend, "generic_field_view"), "runtime.capabilities.generic_field_view"); + if (generic_field_view.kind() != CanonicalValue::Kind::kBool || !generic_field_view.boolean()) + throw ContractError("generic_field_view", + "runtime does not prove the generic field-view launch contract"); const auto dimensions = require_int_set(capability(backend, "dimensions"), "runtime.capabilities.dimensions"); const auto centerings = require_text_set(capability(backend, "centerings"), "runtime.capabilities.centerings"); const auto scalars = require_text_set(capability(backend, "scalars"), "runtime.capabilities.scalars"); + const auto layouts = + require_text_set(capability(backend, "layouts"), "runtime.capabilities.layouts"); + const auto ownership = + require_text_set(capability(backend, "ownership"), "runtime.capabilities.ownership"); const auto memories = require_text_set(backend.memory_spaces, "runtime.memory_spaces"); - for (const auto& view : fields) { + std::vector field_names; + field_names.reserve(fields.size()); + std::vector expected_names; + expected_names.reserve(expected.size()); + const auto validate_unique_name = [](const FieldViewDescriptor& view, + std::vector& names, const std::string& owner) { + if (std::find(names.begin(), names.end(), view.name) != names.end()) + throw ContractError("field." + view.name, + owner + " field descriptors must have unique names"); + names.push_back(view.name); + }; + const auto validate_capabilities = [&](const FieldViewDescriptor& view) { validate_descriptor(view); require_member("dimension", view.dimension, dimensions); require_member("centering", view.centering, centerings); require_member("scalar", view.scalar, scalars); require_member("memory_space", view.memory_space, memories); + require_member("layout", view.layout, layouts); + require_member("ownership", view.ownership, ownership); + }; + for (const auto& view : fields) { + validate_unique_name(view, field_names, "launch"); + validate_capabilities(view); if (view.scalar != context.datatype.identity) throw ContractError("datatype", "field scalar and ExecutionContext datatype differ"); const auto wanted = std::find_if(expected.begin(), expected.end(), [&](const auto& item) { return item.name == view.name; }); if (wanted != expected.end() && (view.dimension != wanted->dimension || view.extents != wanted->extents || - view.centering != wanted->centering || view.scalar != wanted->scalar || - view.memory_space != wanted->memory_space)) + view.strides != wanted->strides || view.centering != wanted->centering || + view.ghosts != wanted->ghosts || view.scalar != wanted->scalar || + view.memory_space != wanted->memory_space || view.patch != wanted->patch || + view.layout != wanted->layout || view.ownership != wanted->ownership)) throw ContractError("field." + view.name, "field descriptor does not match launch contract"); } - for (const auto& wanted : expected) + for (const auto& wanted : expected) { + validate_unique_name(wanted, expected_names, "expected"); + validate_capabilities(wanted); if (std::none_of(fields.begin(), fields.end(), [&](const auto& view) { return view.name == wanted.name; })) throw ContractError("field." + wanted.name, "required field descriptor is missing"); + } } template diff --git a/include/pops/runtime/context/grid_context.hpp b/include/pops/runtime/context/grid_context.hpp index 5d3b985d8..6454fcba5 100644 --- a/include/pops/runtime/context/grid_context.hpp +++ b/include/pops/runtime/context/grid_context.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -7,6 +8,7 @@ #include #include #include +#include #include #include @@ -39,15 +41,26 @@ struct EbThresholds; /// residuals. None stays the untouched production path. enum class GeometryMode { None, Staircase, CutCell }; -constexpr std::uint8_t geometry_mode_flag(GeometryMode mode) { - return static_cast(1U << static_cast(mode)); +constexpr SpatialProviderGeometry spatial_provider_geometry(GeometryMode mode) { + switch (mode) { + case GeometryMode::None: + return SpatialProviderGeometry::Cartesian; + case GeometryMode::Staircase: + return SpatialProviderGeometry::Staircase; + case GeometryMode::CutCell: + return SpatialProviderGeometry::CutCell; + } + return SpatialProviderGeometry::Cartesian; +} + +constexpr bool supports_spatial_operation(const SpatialProviderCapabilities& capabilities, + GeometryMode mode, SpatialProviderOperation operation) { + return capabilities.supports({kNativeDimension, spatial_provider_geometry(mode), operation}); } -constexpr std::uint8_t kCartesianGeometrySupport = geometry_mode_flag(GeometryMode::None); -constexpr std::uint8_t kAllGeometrySupport = geometry_mode_flag(GeometryMode::None) | - geometry_mode_flag(GeometryMode::Staircase) | - geometry_mode_flag(GeometryMode::CutCell); -constexpr bool supports_geometry_mode(std::uint8_t supported_modes, GeometryMode mode) { - return (supported_modes & geometry_mode_flag(mode)) != 0; + +constexpr bool supports_geometry_mode(const SpatialProviderCapabilities& capabilities, + GeometryMode mode) { + return supports_spatial_operation(capabilities, mode, SpatialProviderOperation::Residual); } /// Mesh + transport BC + aux shared by a block closures. @c aux is NOT owned: @@ -174,6 +187,7 @@ class PreparedGridBoundarySession final { if (!context_.boundary_plan) return; plan_session_.emplace(context_.boundary_plan->make_session(lane)); + plan_session_->prepare_trace_recovery_workspace(prototype); configure_registry_(); // Resolve every declared read route once while the session is materialized. Subsequent RHS // applications only advance the registry epoch and rebind pointers into these stable slots. @@ -185,6 +199,7 @@ class PreparedGridBoundarySession final { if (!context_.boundary_plan->has_component_boundaries()) return; plan_session_->prepare_ghost_executor(prototype, registry_, context_.geom); + plan_session_->prepare_flux_executor(prototype, registry_, context_.geom); if (!residual_outputs_.empty()) { bind_registry_(preparation_point, prototype, nullptr, &prototype); plan_session_->prepare_residual_executor(registry_, context_.geom); @@ -251,7 +266,7 @@ class PreparedGridBoundarySession final { return; } if (!context_.boundary_plan->has_component_boundaries()) { - plan_session_->fill_same_level_and_physical(state, context_.geom); + plan_session_->fill_same_level_and_physical(state, context_.geom, point); return; } bind_registry_(point, state, nullptr, nullptr); @@ -266,6 +281,14 @@ class PreparedGridBoundarySession final { plan_session_->add_residual(point, registry_, context_.geom); } + void transform_fluxes(MultiFab& state, MultiFab& fx, MultiFab& fy, + const runtime::multiblock::BoundaryEvaluationPoint& point) const { + if (!plan_session_ || !context_.boundary_plan->has_flux_transformations()) + return; + bind_registry_(point, state, nullptr, nullptr); + plan_session_->transform_fluxes(point, state, registry_, context_.geom, fx, fy); + } + void apply_jvp(MultiFab& state, const MultiFab& direction, MultiFab& output, const runtime::multiblock::BoundaryEvaluationPoint& point) const { if (!plan_session_ || !context_.boundary_plan->has_component_boundaries()) @@ -432,6 +455,21 @@ inline void fill_grid_ghosts(MultiFab& state, const PreparedGridBoundarySession& session.fill(state, point); } +inline void transform_grid_boundary_fluxes( + MultiFab& state, MultiFab& fx, MultiFab& fy, const GridContext& context, + const runtime::multiblock::BoundaryEvaluationPoint& point) { + if (context.boundary_plan && context.boundary_plan->has_flux_transformations()) + context.boundary_plan->transform_fluxes_control( + point, state, context.aux, context.geom, fx, fy, + ExecutionLane::world(context.boundary_plan->identity(), "::boundary-flux-control")); +} + +inline void transform_grid_boundary_fluxes( + MultiFab& state, MultiFab& fx, MultiFab& fy, const PreparedGridBoundarySession& session, + const runtime::multiblock::BoundaryEvaluationPoint& point) { + session.transform_fluxes(state, fx, fy, point); +} + inline void add_grid_boundary_residual(MultiFab& state, MultiFab& residual, const GridContext& context, const runtime::multiblock::BoundaryEvaluationPoint& point) { @@ -661,9 +699,12 @@ struct BlockClosures { /// Embedded-boundary twin of @ref project. Only active cell centres are projected, preserving /// the caller-owned state outside the physical domain exactly. std::function project_masked; - /// Explicit provider capability. A mode absent from this bitset is rejected before execution; - /// no runtime path may infer support from a non-empty fallback closure. - std::uint8_t supported_geometry_modes = kCartesianGeometrySupport; + /// Geometry selected by the base residual. Embedded-boundary modes replace Cartesian only; a + /// polar block therefore retains Polar when GeometryMode is None. + SpatialProviderGeometry base_spatial_geometry = SpatialProviderGeometry::Cartesian; + /// Exact dimension x geometry x operation provider matrix. A missing cell is rejected before + /// execution; no runtime path may infer characteristic or metric support from a residual closure. + SpatialProviderCapabilities spatial_provider = make_cartesian_spatial_provider(kNativeDimension); }; } // namespace pops diff --git a/include/pops/runtime/dynamic/component_consumers.hpp b/include/pops/runtime/dynamic/component_consumers.hpp index ccdaeb506..92ce1ab09 100644 --- a/include/pops/runtime/dynamic/component_consumers.hpp +++ b/include/pops/runtime/dynamic/component_consumers.hpp @@ -360,6 +360,76 @@ inline int apply_ghost_boundary(const PopsGhostBoundaryApiV1& api, void* state, return api.apply_region_batch(state, &request, &status); } +inline int transform_boundary_flux(const PopsBoundaryFluxApiV1& api, void* state, + const PopsBoundaryFluxRequestV1& request, + PopsBoundaryFluxResultV1& result) { + require_operation(api.transform_faces != nullptr, "transform_faces"); + validate_execution_context(request.execution); + validate_logical_time(request.logical_time); + validate_boundary_region(request.region); + if (request.struct_size < sizeof(PopsBoundaryFluxRequestV1) || + result.struct_size < sizeof(PopsBoundaryFluxResultV1) || + !component_text(request.provider_identity) || !component_text(request.state_identity) || + request.face_measures == nullptr || result.actions == nullptr || + request.region.kind != POPS_BOUNDARY_FACE_V1 || request.region.codimension != 1 || + request.region.axis_count != 1) + throw std::invalid_argument("boundary flux transformation request is incomplete"); + validate_execution_field(request.execution, request.base_outward_normal_flux, + "boundary base outward flux"); + validate_execution_field(request.execution, request.coordinates, "boundary flux coordinates"); + validate_execution_field(request.execution, request.outward_normals, "boundary outward normals"); + validate_execution_field(request.execution, result.outward_normal_flux, + "boundary transformed outward flux"); + if (!same_field_domain(request.base_outward_normal_flux, result.outward_normal_flux) || + !same_spatial_domain(request.base_outward_normal_flux, request.coordinates) || + !same_spatial_domain(request.base_outward_normal_flux, request.outward_normals) || + request.coordinates.component_count != static_cast(request.region.dimension) || + request.outward_normals.component_count != static_cast(request.region.dimension)) + throw std::invalid_argument("boundary flux field descriptors disagree"); + const std::uint32_t normal_axis = 1u << static_cast(request.region.axes[0]); + if (request.base_outward_normal_flux.centering != POPS_FIELD_CENTERING_FACE_V1 || + request.base_outward_normal_flux.centering_axes != normal_axis) + throw std::invalid_argument( + "boundary base outward flux is not centered on its authenticated face axis"); + const std::size_t point_count = field_point_count(request.base_outward_normal_flux); + const auto* coordinates = static_cast(request.coordinates.data); + const auto* normals = static_cast(request.outward_normals.data); + for (std::size_t point = 0; point < point_count; ++point) + if (!std::isfinite(request.face_measures[point]) || request.face_measures[point] <= 0.0) + throw std::invalid_argument("boundary flux face measure is not positive and finite"); + for (std::size_t j = 0; j < request.coordinates.extents[1]; ++j) + for (std::size_t i = 0; i < request.coordinates.extents[0]; ++i) + for (std::size_t component = 0; component < request.coordinates.component_count; + ++component) { + const auto coordinate_offset = + static_cast(i) * request.coordinates.axis_strides[0] + + static_cast(j) * request.coordinates.axis_strides[1] + + static_cast(component) * request.coordinates.component_stride; + const auto normal_offset = + static_cast(i) * request.outward_normals.axis_strides[0] + + static_cast(j) * request.outward_normals.axis_strides[1] + + static_cast(component) * request.outward_normals.component_stride; + const double expected = component == static_cast(request.region.axes[0]) + ? static_cast(request.region.sides[0]) + : 0.0; + if (!std::isfinite(coordinates[coordinate_offset]) || + !std::isfinite(normals[normal_offset]) || normals[normal_offset] != expected) + throw std::invalid_argument( + "boundary flux coordinates or outward normal disagree with the oriented face"); + } + validate_const_fields(request.dependencies, request.dependency_count, + "boundary flux dependencies"); + for (std::size_t index = 0; index < request.dependency_count; ++index) { + validate_execution_field(request.execution, request.dependencies[index].values, + "boundary flux dependency"); + if (!same_spatial_domain(request.base_outward_normal_flux, request.dependencies[index].values)) + throw std::invalid_argument( + "boundary flux dependency does not cover the transformed face points"); + } + validate_scalars(request.parameters, request.parameter_count, "boundary flux parameters"); + return api.transform_faces(state, &request, &result); +} + inline int evaluate_field_boundary(const PopsFieldBoundaryClosureApiV1& api, void* state, const PopsFieldBoundaryRequestV1& request, PopsComponentStatusV1& status, bool jvp) { @@ -596,6 +666,81 @@ inline int apply_transfer(const PopsTransferApiV1& api, void* state, return api.apply(state, &request, &status); } +template +inline bool same_reflux_face_shape(const Left& left, const Right& right) { + if (left.dimension != right.dimension || left.component_count != right.component_count || + left.scalar_type != right.scalar_type || left.memory_space != right.memory_space) + return false; + for (std::int32_t axis = 0; axis < 3; ++axis) + if (left.extents[axis] != right.extents[axis] || + left.ghost_lower[axis] != right.ghost_lower[axis] || + left.ghost_upper[axis] != right.ghost_upper[axis]) + return false; + return true; +} + +inline int apply_reflux_interface_batch(const PopsRefluxApiV1& api, void* state, + const PopsRefluxRequestV1& request, + PopsComponentStatusV1& status) { + require_operation(api.apply_interface_batch != nullptr, "apply_interface_batch"); + if (request.struct_size < sizeof(PopsRefluxRequestV1) || + !component_text(request.transition_identity) || request.parent_level < 0 || + request.child_level != request.parent_level + 1 || request.face_count == 0 || + request.faces == nullptr || request.logical_time.level != request.parent_level) + throw std::invalid_argument("reflux request is incomplete"); + validate_logical_time(request.logical_time); + validate_noncollective_execution_context(request.execution); + + std::unordered_set identities; + for (std::size_t index = 0; index < request.face_count; ++index) { + const auto& face = request.faces[index]; + if (face.struct_size < sizeof(PopsRefluxFaceV1) || !component_text(face.interface_identity) || + !identities.insert(face.interface_identity).second || face.axis < 0 || face.axis >= 2 || + (face.side != POPS_REFLUX_FACE_LOW_V1 && face.side != POPS_REFLUX_FACE_HIGH_V1) || + !std::isfinite(face.inverse_coarse_cell_spacing) || face.inverse_coarse_cell_spacing <= 0.0) + throw std::invalid_argument("reflux face descriptor is incomplete"); + + validate_execution_field(request.execution, face.coarse_integrated_flux, + "reflux coarse integrated flux"); + validate_execution_field(request.execution, face.fine_integrated_flux, + "reflux fine integrated flux"); + validate_execution_field(request.execution, face.correction, "reflux correction"); + const auto centering_axis = 1u << static_cast(face.axis); + if (face.coarse_integrated_flux.centering != POPS_FIELD_CENTERING_FACE_V1 || + face.fine_integrated_flux.centering != POPS_FIELD_CENTERING_FACE_V1 || + face.coarse_integrated_flux.centering_axes != centering_axis || + face.fine_integrated_flux.centering_axes != centering_axis || + face.correction.centering != POPS_FIELD_CENTERING_CELL_V1 || + face.correction.centering_axes != 0 || + face.coarse_integrated_flux.ownership != POPS_FIELD_OWNERSHIP_RUNTIME_BORROWED_V1 || + face.fine_integrated_flux.ownership != POPS_FIELD_OWNERSHIP_RUNTIME_BORROWED_V1 || + face.correction.ownership != POPS_FIELD_OWNERSHIP_RUNTIME_BORROWED_V1 || + !same_reflux_face_shape(face.coarse_integrated_flux, face.fine_integrated_flux) || + !same_reflux_face_shape(face.coarse_integrated_flux, face.correction) || + face.coarse_integrated_flux.extents[face.axis] != 1 || + std::string(face.coarse_integrated_flux.layout_identity) != + face.correction.layout_identity || + std::string(face.coarse_integrated_flux.patch_identity) != face.correction.patch_identity) + throw std::invalid_argument( + "reflux face fluxes and correction disagree on shape, centering or ownership"); + for (std::int32_t axis = 0; axis < face.coarse_integrated_flux.dimension; ++axis) + if (face.coarse_integrated_flux.ghost_lower[axis] != 0 || + face.coarse_integrated_flux.ghost_upper[axis] != 0) + throw std::invalid_argument("reflux face views cannot carry ghost cells"); + } + + status = unwritten_component_status(); + const int code = api.apply_interface_batch(state, &request, &status); + if (!component_status_is_well_formed(status)) + throw std::runtime_error("native Reflux component returned an invalid status"); + if ((code == 0) != (status.code == 0) || + (code == 0 && status.action != POPS_COMPONENT_CONTINUE_V1) || + (code != 0 && status.action == POPS_COMPONENT_CONTINUE_V1) || + (code != 0 && !component_text(status.reason))) + throw std::runtime_error("native Reflux component returned an inconsistent outcome"); + return code; +} + inline std::string writer_geometry_key(const char* layout, std::int32_t level) { return std::string(layout) + "\n" + std::to_string(level); } diff --git a/include/pops/runtime/dynamic/component_loader.hpp b/include/pops/runtime/dynamic/component_loader.hpp index 0cb9ffc46..ce9483ae0 100644 --- a/include/pops/runtime/dynamic/component_loader.hpp +++ b/include/pops/runtime/dynamic/component_loader.hpp @@ -392,6 +392,9 @@ class LoadedComponent final { if ((header->prepare == nullptr) != (header->destroy == nullptr)) throw std::runtime_error( "native component interface prepare/destroy callbacks must be paired"); + if (!generated_native_interface_table_is_complete(row.interface_id, row.table, + row.table_size)) + throw std::runtime_error("native component interface table misses a required operation"); } for (const auto& required : expected.interfaces) { bool found = false; diff --git a/include/pops/runtime/module_capabilities.hpp b/include/pops/runtime/module_capabilities.hpp index 3715dda77..5de37c4de 100644 --- a/include/pops/runtime/module_capabilities.hpp +++ b/include/pops/runtime/module_capabilities.hpp @@ -228,29 +228,27 @@ inline std::vector native_capability_routes( capability_route("riemann:hll", "available", "requires physical_flux and wave_speeds", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route("riemann:hllc", "available", - "requires Euler/HLLC model capabilities; polar route is unavailable", + "requires exact HLLC model capabilities on every geometry", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route("riemann:roe", "available", - "requires Roe dissipation capability; polar route is unavailable", + "requires exact Roe dissipation capability on every geometry", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route("reconstruction:firstorder", "available", "ghost_depth=1", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route("reconstruction:muscl", "available", - "ghost_depth=2; native limiters minmod/vanleer", kLayoutRouteTokensCsv, - "production", "host", mpi, gpu), + "ghost_depth=2; native limiters minmod/vanleer/mc/superbee", + kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route( "reconstruction:weno5", "available", "ghost_depth=3; uniform and ratio-2 2D AMR routes are native; AMR selects the " "conservative order-5 coarse/fine provider for cell averages from resolved capabilities", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), - capability_route("limiter:mc", "unavailable", - "catalogued but no native C++ limiter symbol exists", kLayoutRouteTokensCsv, - "none", "host", mpi, gpu, "limiter=MC()", "Minmod() or VanLeer()", - "use pops.numerics.reconstruction.limiters.Minmod()"), - capability_route("limiter:superbee", "unavailable", - "catalogued but no native C++ limiter symbol exists", kLayoutRouteTokensCsv, - "none", "host", mpi, gpu, "limiter=Superbee()", "Minmod() or VanLeer()", - "use pops.numerics.reconstruction.limiters.VanLeer()"), + capability_route("limiter:mc", "available", + "native POPS_HD MC slope policy; formal_order=2; ghost_depth=2", + kLayoutRouteTokensCsv, "production", "host", mpi, gpu), + capability_route("limiter:superbee", "available", + "native POPS_HD Superbee slope policy; formal_order=2; ghost_depth=2", + kLayoutRouteTokensCsv, "production", "host", mpi, gpu), capability_route("elliptic:geometric_mg", "available", "native multigrid route; supports variable epsilon", kLayoutRouteTokensCsv, "production", "host", mpi, gpu), @@ -318,6 +316,15 @@ inline std::vector native_capability_routes( capability_route("program_context:amr", status_from_bool(caps.supports_amr), "AMR program install requires target='amr_system'", "amr", "production", "host", mpi, gpu), + capability_route( + "amr:shared_interface_implicit_jacvec_pair", "unavailable", + "the host/serial level_rhs_jacvec_pair primitive and resolve-evidence-gated compile " + "route exist, but no generated Program executes the implicit solve/matvec end to end", + "amr", "none", "host", false, false, + "generated shared-interface implicit JVP solve", + "native host/serial pair primitive plus compile-only generated route", + "keep ADC-758 open and add an end-to-end generated bind/solve/matvec proof before " + "advertising a production route"), capability_route("output:scientific_v1", "available", "typed SERIAL/ROOT/COLLECTIVE/PER_RANK publication; each format advertises " "its exact supported modes", diff --git a/include/pops/runtime/multiblock/interface_flux_scheduler.hpp b/include/pops/runtime/multiblock/interface_flux_scheduler.hpp index ad7838a9c..6065eff73 100644 --- a/include/pops/runtime/multiblock/interface_flux_scheduler.hpp +++ b/include/pops/runtime/multiblock/interface_flux_scheduler.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -133,9 +134,19 @@ class InterfaceFluxScheduler { MultiFab& right_state, const Geometry& right_geometry, const PopsExecutionContextV1& execution, InterfaceFluxEvaluatorFactory evaluator_factory) { - const bool collective_world = comm_active() && n_ranks() > 1; + // MultiFab/DistributionMapping owners still use the process-world rank space. Keep that + // authority only for admission; numerical collectives use the communicator authenticated by + // the ExecutionContext and retained by the prepared route. + const CommunicatorView field_rank_space = + comm_active() ? world_communicator_view() : CommunicatorView{}; + const bool collective_world = field_rank_space.active() && field_rank_space.size() > 1; + const CommunicatorView admission_communicator = + collective_world ? field_rank_space : CommunicatorView{}; bool distributed = false; + CommunicatorView execution_communicator; + int communicator_rank = 0; int communicator_size = 1; + std::string communicator_identity = "serial"; int component_count = 0; int left_faces = 0; Real left_normal = Real(0); @@ -155,34 +166,64 @@ class InterfaceFluxScheduler { if (route.left_side == route.right_side) throw std::invalid_argument("multi-block interface faces do not have opposite orientation"); component::validate_execution_context(execution); - const std::string communicator_identity(execution.communicator_identity); - if (communicator_identity == "MPI_COMM_WORLD") { + communicator_identity.assign(execution.communicator_identity); + if (communicator_identity != "serial" && + communicator_identity != POPS_EXECUTION_NONCOLLECTIVE_IDENTITY_V1) { #ifdef POPS_HAS_MPI if (!comm_active()) throw std::invalid_argument( - "multi-block interface MPI_COMM_WORLD capability is not active"); - int communicator_relation = MPI_UNEQUAL; - ::pops::detail::require_mpi_success( - MPI_Comm_compare(MPI_Comm_f2c(static_cast(execution.communicator_f_handle)), - MPI_COMM_WORLD, &communicator_relation), - "MPI_Comm_compare(interface execution context)"); - if (communicator_relation != MPI_IDENT || + "multi-block interface communicator capability is not active"); + const MPI_Comm communicator = + MPI_Comm_f2c(static_cast(execution.communicator_f_handle)); + if (communicator == MPI_COMM_NULL || MPI_Type_f2c(static_cast(execution.communicator_datatype_f_handle)) != MPI_DOUBLE) throw std::invalid_argument( - "multi-block interface execution handles do not identify exact " - "MPI_COMM_WORLD/MPI_DOUBLE"); - communicator_size = n_ranks(); + "multi-block interface execution handles do not identify a live " + "communicator/MPI_DOUBLE authority"); + int communicator_relation = MPI_UNEQUAL; + ::pops::detail::require_mpi_success( + MPI_Comm_compare(communicator, field_rank_space.native_handle(), + &communicator_relation), + "MPI_Comm_compare(interface field rank space)"); + if (communicator_relation != MPI_IDENT && communicator_relation != MPI_CONGRUENT) + throw std::invalid_argument( + "multi-block interface communicator must preserve the field rank space"); + execution_communicator = CommunicatorView{communicator}; + communicator_rank = execution_communicator.rank(); + communicator_size = execution_communicator.size(); distributed = communicator_size > 1; #else throw std::invalid_argument( - "multi-block interface scheduler received MPI_COMM_WORLD from a serial build"); + "multi-block interface scheduler received a distributed context from a serial build"); #endif + } else if (communicator_identity == POPS_EXECUTION_NONCOLLECTIVE_IDENTITY_V1) { + throw std::invalid_argument( + "multi-block interface scheduler requires collective execution authority"); #ifdef POPS_HAS_MPI } else if (comm_active() && n_ranks() > 1) { throw std::invalid_argument( "multi-block interface cannot use a serial execution identity in an active " "multi-rank MPI world"); +#endif + } + if (!interfaces_.empty()) { + const PreparedInterface& existing = interfaces_.front(); + if (existing.communicator_identity != communicator_identity || + existing.communicator_size != communicator_size) + throw std::invalid_argument( + "multi-block interface routes require one exact execution communicator"); +#ifdef POPS_HAS_MPI + if (distributed) { + int relation = MPI_UNEQUAL; + ::pops::detail::require_mpi_success( + MPI_Comm_compare(existing.communicator.native_handle(), + execution_communicator.native_handle(), &relation), + "MPI_Comm_compare(installed interface communicators)"); + if (relation != MPI_IDENT) + throw std::invalid_argument( + "multi-block interface routes require the same communicator context"); + } #endif } if (left_state.box_array().size() < 1 || right_state.box_array().size() < 1) @@ -288,21 +329,24 @@ class InterfaceFluxScheduler { throw std::invalid_argument( "multi-block interface faces do not coincide in physical space"); - left_cells = boundary_cells_(left_state, route.left_axis, route.left_side, left_faces); - right_cells = boundary_cells_(right_state, route.right_axis, route.right_side, right_faces); + left_cells = boundary_cells_(left_state, route.left_axis, route.left_side, left_faces, + communicator_rank); + right_cells = boundary_cells_(right_state, route.right_axis, route.right_side, right_faces, + communicator_rank); } catch (...) { structural_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, structural_failure, + finish_collective_preflight_(admission_communicator, structural_failure, "route/layout/execution preflight"); - if (distributed && !registry_agrees_across_ranks_()) + if (distributed && !registry_agrees_across_ranks_(execution_communicator)) throw std::runtime_error("multi-block interface prepared registry differs across MPI ranks"); const std::string collective_identity = collective_plan_identity_( route, left_state, left_geometry, right_state, right_geometry, left_normal, right_normal, - left_faces, component_count, communicator_size); + left_faces, component_count, communicator_identity, communicator_size); if (distributed && !all_ranks_agree_exact_ordered_byte_pairs( - {{std::string_view(route.identity), std::string_view(collective_identity)}})) + {{std::string_view(route.identity), std::string_view(collective_identity)}}, + execution_communicator)) throw std::runtime_error( "multi-block interface prepared route/layout differs across MPI ranks"); PreparedInterface prepared; @@ -322,7 +366,12 @@ class InterfaceFluxScheduler { left_faces, component_count, distributed, + execution_communicator, + communicator_rank, communicator_size, + communicator_identity, + execution.memory_space, + execution.device_identity, collective_identity, InterfaceFluxEvaluator{}, 0}; @@ -337,7 +386,7 @@ class InterfaceFluxScheduler { } catch (...) { materialization_failure = std::current_exception(); } - finish_collective_preflight_(distributed, materialization_failure, + finish_collective_preflight_(execution_communicator, materialization_failure, "prepared-route materialization"); // Component prepare may allocate resources or have observable external effects. Invoke it only // after every route/layout/geometry capability has been proved, but before mutating the scheduler @@ -353,7 +402,8 @@ class InterfaceFluxScheduler { } catch (...) { evaluator_prepare_failure = std::current_exception(); } - finish_collective_preflight_(distributed, evaluator_prepare_failure, "evaluator preparation"); + finish_collective_preflight_(execution_communicator, evaluator_prepare_failure, + "evaluator preparation"); prepared.evaluator = std::move(evaluator); interfaces_.push_back(std::move(prepared)); } @@ -373,14 +423,29 @@ class InterfaceFluxScheduler { void apply(const BoundaryEvaluationPoint& point, const std::vector& states, const std::vector& rhs, InterfaceFluxFragmentPublication* publication = nullptr) { - const bool collective_world = comm_active() && n_ranks() > 1; + apply(point, std::span(states.data(), states.size()), + std::span(rhs.data(), rhs.size()), publication); + } + + void apply(const BoundaryEvaluationPoint& point, std::span states, + std::span rhs, + InterfaceFluxFragmentPublication* publication = nullptr) { + if (interfaces_.empty()) { + validate_point_(point); + if (publication != nullptr) + validate_fragment_publication_(point, *publication); + return; + } + const CommunicatorView execution_communicator = interfaces_.front().communicator; + const bool collective = execution_communicator.active() && execution_communicator.size() > 1; std::exception_ptr point_failure; try { validate_point_(point); } catch (...) { point_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, point_failure, "evaluation-point preflight"); + finish_collective_preflight_(execution_communicator, point_failure, + "evaluation-point preflight"); std::exception_ptr publication_failure; try { if (publication != nullptr) @@ -388,27 +453,31 @@ class InterfaceFluxScheduler { } catch (...) { publication_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, publication_failure, + finish_collective_preflight_(execution_communicator, publication_failure, "interface-fragment publication preflight"); - if (collective_world) { - const long minimum_publication = all_reduce_min(publication != nullptr ? 1L : 0L); - const long maximum_publication = all_reduce_max(publication != nullptr ? 1L : 0L); + if (collective) { + const long minimum_publication = + all_reduce_min(publication != nullptr ? 1L : 0L, execution_communicator); + const long maximum_publication = + all_reduce_max(publication != nullptr ? 1L : 0L, execution_communicator); if (minimum_publication != maximum_publication) throw std::runtime_error( "multi-block interface fragment publication presence differs across MPI ranks"); } - if (collective_world && !registry_agrees_across_ranks_()) + if (collective && !registry_agrees_across_ranks_(execution_communicator)) throw std::runtime_error("multi-block interface prepared registry differs across MPI ranks"); const std::string point_identity = collective_point_identity_(point); - if (collective_world && !all_ranks_agree_exact_ordered_byte_pairs( - {{std::string_view("point"), std::string_view(point_identity)}})) + if (collective && !all_ranks_agree_exact_ordered_byte_pairs( + {{std::string_view("point"), std::string_view(point_identity)}}, + execution_communicator)) throw std::runtime_error( "multi-block interface BoundaryEvaluationPoint differs across MPI ranks"); - if (collective_world && publication != nullptr) { + if (collective && publication != nullptr) { const std::string publication_identity = collective_fragment_publication_identity_(*publication); if (!all_ranks_agree_exact_ordered_byte_pairs( - {{std::string_view("publication"), std::string_view(publication_identity)}})) + {{std::string_view("publication"), std::string_view(publication_identity)}}, + execution_communicator)) throw std::runtime_error( "multi-block interface fragment publication differs across MPI ranks"); } @@ -441,11 +510,11 @@ class InterfaceFluxScheduler { } catch (...) { active_mask_failure = std::current_exception(); } - finish_collective_preflight_(prepared.distributed, active_mask_failure, + finish_collective_preflight_(prepared.communicator, active_mask_failure, "active-mask preflight"); if (prepared.distributed) { - const long minimum_active = all_reduce_min(active ? 1L : 0L); - const long maximum_active = all_reduce_max(active ? 1L : 0L); + const long minimum_active = all_reduce_min(active ? 1L : 0L, prepared.communicator); + const long maximum_active = all_reduce_max(active ? 1L : 0L, prepared.communicator); if (minimum_active != maximum_active) throw std::runtime_error("multi-block interface active mask differs across MPI ranks"); } @@ -477,6 +546,38 @@ class InterfaceFluxScheduler { return false; } + /// Authenticate the deliberately narrow implicit two-block route before a Krylov matvec mutates + /// either endpoint scratch. One and only one prepared interface must connect the requested pair + /// on this level; otherwise a packed two-sided direction would have ambiguous trace ownership. + void require_exact_jacvec_pair(int level, std::size_t first_block, + std::size_t second_block) const { + if (level < 0 || first_block == second_block) + throw std::invalid_argument("multi-block implicit JVP pair is invalid"); + std::size_t level_routes = 0; + const PreparedInterface* matched = nullptr; + for (const PreparedInterface& prepared : interfaces_) { + if (prepared.route.level != level) + continue; + ++level_routes; + if ((prepared.route.left_block == first_block && + prepared.route.right_block == second_block) || + (prepared.route.left_block == second_block && + prepared.route.right_block == first_block)) + matched = &prepared; + } + if (level_routes != 1 || matched == nullptr) + throw std::runtime_error( + "multi-block implicit JVP requires one exact prepared two-block interface route"); + if (matched->distributed || matched->communicator_size != 1 || + matched->communicator_identity != "serial") + throw std::runtime_error( + "multi-block implicit JVP requires serial rank-one execution"); + if (matched->memory_space != POPS_MEMORY_SPACE_HOST_V1 || + (matched->device_identity != "host" && matched->device_identity != "cpu")) + throw std::runtime_error( + "multi-block implicit JVP requires host-memory execution"); + } + /// Rebuild every layout-bound trace plan against a replacement AMR hierarchy. The numerical /// flux evaluator and its accepted evaluation count are retained; boxes, ownership, boundary-cell /// maps, collective identity and persistent scratch are prepared afresh. The returned scheduler @@ -493,7 +594,9 @@ class InterfaceFluxScheduler { authority != InterfaceRematerializationAuthority::BindBootstrap) throw std::invalid_argument( "multi-block interface rematerialization has an invalid lifecycle authority"); - const bool collective_world = comm_active() && n_ranks() > 1; + const CommunicatorView execution_communicator = + interfaces_.empty() ? CommunicatorView{} : interfaces_.front().communicator; + const bool collective = execution_communicator.active() && execution_communicator.size() > 1; InterfaceFluxScheduler candidate; std::exception_ptr allocation_failure; try { @@ -501,7 +604,7 @@ class InterfaceFluxScheduler { } catch (...) { allocation_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, allocation_failure, + finish_collective_preflight_(execution_communicator, allocation_failure, "replacement registry allocation"); for (const PreparedInterface& prepared : interfaces_) { @@ -521,7 +624,7 @@ class InterfaceFluxScheduler { } catch (...) { structural_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, structural_failure, + finish_collective_preflight_(execution_communicator, structural_failure, "replacement route/layout preflight"); std::exception_ptr storage_failure; try { @@ -533,7 +636,7 @@ class InterfaceFluxScheduler { } catch (...) { storage_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, storage_failure, + finish_collective_preflight_(execution_communicator, storage_failure, "replacement registry materialization"); } @@ -551,9 +654,9 @@ class InterfaceFluxScheduler { } catch (...) { structural_registry_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, structural_registry_failure, + finish_collective_preflight_(execution_communicator, structural_registry_failure, "replacement registry structural completeness"); - if (collective_world && !candidate.registry_agrees_across_ranks_()) + if (collective && !candidate.registry_agrees_across_ranks_(execution_communicator)) throw std::runtime_error( "multi-block interface replacement registry differs across MPI ranks"); return candidate; @@ -577,16 +680,18 @@ class InterfaceFluxScheduler { /// enters tagging or clustering. The later rematerialized() call repeats the same proof against /// the candidate hierarchy before its detached registry is swapped into the runtime. void require_runtime_rematerialization_ready(int active_level_count) const { - const bool collective_world = comm_active() && n_ranks() > 1; + const CommunicatorView execution_communicator = + interfaces_.empty() ? CommunicatorView{} : interfaces_.front().communicator; + const bool collective = execution_communicator.active() && execution_communicator.size() > 1; std::exception_ptr structural_failure; try { require_complete_active_level_registry(active_level_count); } catch (...) { structural_failure = std::current_exception(); } - finish_collective_preflight_(collective_world, structural_failure, + finish_collective_preflight_(execution_communicator, structural_failure, "accepted registry structural rematerialization preflight"); - if (collective_world && !registry_agrees_across_ranks_()) + if (collective && !registry_agrees_across_ranks_(execution_communicator)) throw std::runtime_error("multi-block interface accepted registry differs across MPI ranks"); } @@ -642,7 +747,12 @@ class InterfaceFluxScheduler { int face_count = 0; int component_count = 0; bool distributed = false; + CommunicatorView communicator; + int communicator_rank = 0; int communicator_size = 1; + std::string communicator_identity; + PopsMemorySpaceV1 memory_space = POPS_MEMORY_SPACE_HOST_V1; + std::string device_identity; std::string collective_identity; InterfaceFluxEvaluator evaluator; std::size_t evaluation_count = 0; @@ -659,9 +769,12 @@ class InterfaceFluxScheduler { const Geometry& left_geometry, MultiFab& right_state, const Geometry& right_geometry) { - if (prepared.distributed && (!comm_active() || n_ranks() != prepared.communicator_size)) + if (prepared.distributed && (!prepared.communicator.active() || + prepared.communicator.size() != prepared.communicator_size || + prepared.communicator.rank() != prepared.communicator_rank)) throw std::runtime_error( - "multi-block interface MPI world changed before hierarchy rematerialization"); + "multi-block interface execution communicator changed before hierarchy " + "rematerialization"); if (!prepared.distributed && comm_active() && n_ranks() > 1) throw std::runtime_error( "serial multi-block interface cannot rematerialize in a multi-rank MPI world"); @@ -722,17 +835,18 @@ class InterfaceFluxScheduler { replacement.left_ranks = left_state.dmap().ranks(); replacement.right_boxes = right_state.box_array().boxes(); replacement.right_ranks = right_state.dmap().ranks(); - replacement.left_cells = - boundary_cells_(left_state, route.left_axis, route.left_side, left_faces); - replacement.right_cells = - boundary_cells_(right_state, route.right_axis, route.right_side, right_faces); + replacement.left_cells = boundary_cells_(left_state, route.left_axis, route.left_side, + left_faces, prepared.communicator_rank); + replacement.right_cells = boundary_cells_(right_state, route.right_axis, route.right_side, + right_faces, prepared.communicator_rank); replacement.left_normal_spacing = left_normal; replacement.right_normal_spacing = right_normal; replacement.face_measure = left_tangential; replacement.face_count = left_faces; - replacement.collective_identity = collective_plan_identity_( - route, left_state, left_geometry, right_state, right_geometry, left_normal, right_normal, - left_faces, prepared.component_count, prepared.communicator_size); + replacement.collective_identity = + collective_plan_identity_(route, left_state, left_geometry, right_state, right_geometry, + left_normal, right_normal, left_faces, prepared.component_count, + prepared.communicator_identity, prepared.communicator_size); const std::size_t packed_size = static_cast(left_faces) * static_cast(prepared.component_count); if (packed_size > static_cast(std::numeric_limits::max()) / 2) @@ -765,10 +879,12 @@ class InterfaceFluxScheduler { } } - static void finish_collective_preflight_(bool collective, const std::exception_ptr& local_failure, + static void finish_collective_preflight_(const CommunicatorView& communicator, + const std::exception_ptr& local_failure, const char* phase) { - const long failure_count = - collective ? all_reduce_sum(local_failure ? 1L : 0L) : (local_failure ? 1L : 0L); + const bool collective = communicator.active() && communicator.size() > 1; + const long failure_count = collective ? all_reduce_sum(local_failure ? 1L : 0L, communicator) + : (local_failure ? 1L : 0L); if (failure_count == 0) return; if (local_failure) @@ -818,7 +934,8 @@ class InterfaceFluxScheduler { static std::string collective_plan_identity_( const AxisAlignedInterface& route, const MultiFab& left_state, const Geometry& left_geometry, const MultiFab& right_state, const Geometry& right_geometry, Real left_normal, - Real right_normal, int face_count, int component_count, int communicator_size) { + Real right_normal, int face_count, int component_count, + std::string_view communicator_identity, int communicator_size) { std::string bytes; append_identity_text_(bytes, "pops.multiblock.interface-plan.v2"); append_identity_text_(bytes, route.identity); @@ -854,6 +971,7 @@ class InterfaceFluxScheduler { append_identity_scalar_(bytes, right_normal); append_identity_scalar_(bytes, face_count); append_identity_scalar_(bytes, component_count); + append_identity_text_(bytes, communicator_identity); append_identity_scalar_(bytes, communicator_size); return bytes; } @@ -906,7 +1024,7 @@ class InterfaceFluxScheduler { return bytes; } - bool registry_agrees_across_ranks_() const { + bool registry_agrees_across_ranks_(const CommunicatorView& communicator) const { std::vector> identities; std::exception_ptr allocation_failure; try { @@ -916,9 +1034,8 @@ class InterfaceFluxScheduler { } catch (...) { allocation_failure = std::current_exception(); } - finish_collective_preflight_(comm_active() && n_ranks() > 1, allocation_failure, - "registry identity allocation"); - return all_ranks_agree_exact_ordered_byte_pairs(identities); + finish_collective_preflight_(communicator, allocation_failure, "registry identity allocation"); + return all_ranks_agree_exact_ordered_byte_pairs(identities, communicator); } static int tangential_count_(const Box2D& box, InterfaceAxis axis) { @@ -974,7 +1091,8 @@ class InterfaceFluxScheduler { } static std::vector boundary_cells_(const MultiFab& field, InterfaceAxis axis, - InterfaceSide side, int face_count) { + InterfaceSide side, int face_count, + int communicator_rank) { const Box2D domain = field.box_array().bounding_box(); const int normal_axis = axis == InterfaceAxis::X ? 0 : 1; const int tangent_axis = 1 - normal_axis; @@ -998,7 +1116,7 @@ class InterfaceFluxScheduler { throw std::invalid_argument( "multi-block interface boundary decomposition has a gap at one face cell"); const int local_owner = field.local_index_of(global_owner); - if ((field.dmap()[global_owner] == my_rank()) != (local_owner >= 0)) + if ((field.dmap()[global_owner] == communicator_rank) != (local_owner >= 0)) throw std::logic_error( "multi-block interface local ownership differs from its DistributionMapping"); cells.push_back(BoundaryCell{local_owner, i, j}); @@ -1065,32 +1183,34 @@ class InterfaceFluxScheduler { static bool runtime_field_matches_(const MultiFab& field, const std::vector& expected_boxes, - const std::vector& expected_ranks, int component_count) { + const std::vector& expected_ranks, int component_count, + int communicator_rank) { int expected_local_size = 0; for (const int owner : expected_ranks) - if (owner == my_rank()) + if (owner == communicator_rank) ++expected_local_size; return field.box_array().boxes() == expected_boxes && field.dmap().ranks() == expected_ranks && field.local_size() == expected_local_size && field.ncomp() == component_count; } static void require_distributed_flux_consensus_(std::vector& flux, - std::vector& reference) { + std::vector& reference, + const CommunicatorView& communicator) { #ifdef POPS_HAS_MPI if (reference.size() != flux.size()) throw std::logic_error("multi-block interface consensus scratch changed size"); std::copy(flux.begin(), flux.end(), reference.begin()); - ::pops::detail::require_mpi_success( - MPI_Bcast(reference.data(), static_cast(reference.size()), MPI_DOUBLE, 0, - MPI_COMM_WORLD), - "MPI_Bcast(multi-block shared flux)"); + broadcast_bytes_inplace(reinterpret_cast(reference.data()), + reference.size() * sizeof(Real), 0, communicator); const bool equal = std::memcmp(reference.data(), flux.data(), flux.size() * sizeof(Real)) == 0; - if (all_reduce_sum(equal ? 0L : 1L) != 0) + if (all_reduce_sum(equal ? 0L : 1L, communicator) != 0) throw std::runtime_error( "multi-block interface evaluator returned rank-dependent shared flux"); std::copy(reference.begin(), reference.end(), flux.begin()); #else (void)flux; + (void)reference; + (void)communicator; throw std::logic_error( "distributed multi-block flux consensus is unavailable in a serial build"); #endif @@ -1099,19 +1219,22 @@ class InterfaceFluxScheduler { static void apply_one_(PreparedInterface& prepared, const BoundaryEvaluationPoint& point, MultiFab& left_state, MultiFab& right_state, MultiFab& left_rhs, MultiFab& right_rhs, InterfaceFluxFragmentPublication* publication) { - if (prepared.distributed && (!comm_active() || n_ranks() != prepared.communicator_size)) - throw std::runtime_error("multi-block interface MPI world changed after route preparation"); + if (prepared.distributed && (!prepared.communicator.active() || + prepared.communicator.size() != prepared.communicator_size || + prepared.communicator.rank() != prepared.communicator_rank)) + throw std::runtime_error( + "multi-block interface execution communicator changed after route preparation"); const bool layouts_match = runtime_field_matches_(left_state, prepared.left_boxes, prepared.left_ranks, - prepared.component_count) && + prepared.component_count, prepared.communicator_rank) && runtime_field_matches_(right_state, prepared.right_boxes, prepared.right_ranks, - prepared.component_count) && + prepared.component_count, prepared.communicator_rank) && runtime_field_matches_(left_rhs, prepared.left_boxes, prepared.left_ranks, - prepared.component_count) && + prepared.component_count, prepared.communicator_rank) && runtime_field_matches_(right_rhs, prepared.right_boxes, prepared.right_ranks, - prepared.component_count); + prepared.component_count, prepared.communicator_rank); if (prepared.distributed) { - if (all_reduce_sum(layouts_match ? 0L : 1L) != 0) + if (all_reduce_sum(layouts_match ? 0L : 1L, prepared.communicator) != 0) throw std::runtime_error( "multi-block interface runtime fields differ from their prepared layouts on one " "or more MPI ranks"); @@ -1165,7 +1288,7 @@ class InterfaceFluxScheduler { } } if (prepared.distributed) - all_reduce_sum_inplace(prepared.traces.data(), prepared.traces.size()); + all_reduce_sum_inplace(prepared.traces.data(), prepared.traces.size(), prepared.communicator); const InterfaceFluxBatch batch{left, right, prepared.flux.data(), prepared.face_count, prepared.component_count}; @@ -1176,7 +1299,7 @@ class InterfaceFluxScheduler { evaluator_failure = std::current_exception(); } if (prepared.distributed) { - if (all_reduce_sum(evaluator_failure ? 1L : 0L) != 0) + if (all_reduce_sum(evaluator_failure ? 1L : 0L, prepared.communicator) != 0) throw std::runtime_error("multi-block interface evaluator failed on one or more MPI ranks"); } else if (evaluator_failure) { std::rethrow_exception(evaluator_failure); @@ -1185,10 +1308,10 @@ class InterfaceFluxScheduler { for (const Real value : prepared.flux) finite_flux = finite_flux && std::isfinite(static_cast(value)); if (prepared.distributed) { - if (all_reduce_sum(finite_flux ? 0L : 1L) != 0) + if (all_reduce_sum(finite_flux ? 0L : 1L, prepared.communicator) != 0) throw std::runtime_error( "multi-block interface evaluator returned a non-finite flux on one or more MPI ranks"); - require_distributed_flux_consensus_(prepared.flux, prepared.consensus); + require_distributed_flux_consensus_(prepared.flux, prepared.consensus, prepared.communicator); } else if (!finite_flux) { throw std::runtime_error("multi-block interface evaluator returned a non-finite flux"); } @@ -1199,7 +1322,7 @@ class InterfaceFluxScheduler { } catch (...) { publication_failure = std::current_exception(); } - finish_collective_preflight_(prepared.distributed, publication_failure, + finish_collective_preflight_(prepared.communicator, publication_failure, "interface-fragment accumulation"); } ++prepared.evaluation_count; diff --git a/include/pops/runtime/output/hdf5_collective.hpp b/include/pops/runtime/output/hdf5_collective.hpp index 9a5e4bf51..c89db165b 100644 --- a/include/pops/runtime/output/hdf5_collective.hpp +++ b/include/pops/runtime/output/hdf5_collective.hpp @@ -6,10 +6,6 @@ #include -namespace pops { -class WorldCommunicator; -} - namespace pops::runtime::output { /// Non-owning, contiguous NumPy-compatible array view used by the native HDF5 adapter. @@ -56,8 +52,6 @@ struct ParallelHdf5Capability { /// rank is allowed to enter HDF5. An empty string means that local validation succeeded. void collective_hdf5_input_consensus(const CommunicatorView& communicator, const std::string& local_error); -void collective_hdf5_input_consensus(const WorldCommunicator& world, - const std::string& local_error); /// Write one exact scientific-output artifact collectively on an explicit native communicator. /// @@ -71,9 +65,5 @@ void write_collective_hdf5(const CommunicatorView& communicator, const std::stri const std::string& manifest_json, const std::vector& root_arrays, const std::vector& fields); -void write_collective_hdf5(const WorldCommunicator& world, const std::string& path, - const std::string& manifest_json, - const std::vector& root_arrays, - const std::vector& fields); } // namespace pops::runtime::output diff --git a/include/pops/runtime/output_piece_collective.hpp b/include/pops/runtime/output_piece_collective.hpp index 258c52778..57b30bb4a 100644 --- a/include/pops/runtime/output_piece_collective.hpp +++ b/include/pops/runtime/output_piece_collective.hpp @@ -5,10 +5,10 @@ /// /// Local providers are evaluated on every rank under an all-rank error consensus. Metadata and /// IEEE-754 values are framed in a versioned, endian-stable native wire payload and transferred by -/// WorldCommunicator's chunked MPI_Gatherv transport. Only rank zero materializes the global piece -/// vector; Python never gathers NumPy arrays or executes an MPI collective. +/// an explicitly owned consumer lane. Only rank zero materializes the global piece vector; Python +/// never gathers NumPy arrays or executes an MPI collective. -#include +#include #include #include @@ -185,13 +185,21 @@ inline std::string current_exception_text() { /// Evaluate a local OutputPiece provider and gather its exact result onto MPI rank zero. template -std::vector output_pieces_to_root(const WorldCommunicator& world, +std::vector output_pieces_to_root(const ObserverMpiLane& lane, std::string operation_identity, Provider&& provider) { - world.require_active_mpi_world(); - const int rank = world.rank(); +#ifndef POPS_HAS_MPI + (void)lane; + (void)operation_identity; + (void)provider; + throw std::runtime_error("native output-piece ROOT gather requires an MPI-enabled build"); +#endif + if (!lane.active()) + throw std::runtime_error( + "native output-piece root gather requires an active consumer MPI lane"); + const int rank = lane.rank(); - const std::vector operations = world.allgather_bytes(operation_identity); + const std::vector operations = lane.allgather_bytes(operation_identity); if (!std::all_of(operations.begin(), operations.end(), [&](const std::string& value) { return value == operation_identity; })) throw std::invalid_argument("output-piece root gather arguments differ across MPI ranks"); @@ -215,19 +223,19 @@ std::vector output_pieces_to_root(const WorldCommunicator& world, local_error = detail::current_exception_text(); } - const std::vector errors = world.allgather_bytes(local_error); + const std::vector errors = lane.allgather_bytes(local_error); for (std::size_t source = 0; source < errors.size(); ++source) { if (!errors[source].empty()) throw std::runtime_error("native output-piece provider failed on rank " + std::to_string(source) + ": " + errors[source]); } - const std::optional> gathered = world.gather_bytes(packed, 0); + const std::optional> gathered = lane.gather_bytes(packed, 0); std::vector result; std::string root_error; if (rank == 0) { try { - if (!gathered || gathered->size() != static_cast(world.size())) + if (!gathered || gathered->size() != static_cast(lane.size())) throw std::runtime_error("native output-piece root gather has invalid rank cardinality"); for (std::size_t source = 0; source < gathered->size(); ++source) { std::vector decoded = @@ -248,7 +256,7 @@ std::vector output_pieces_to_root(const WorldCommunicator& world, root_error = detail::current_exception_text(); } } - root_error = world.broadcast_bytes(std::move(root_error), 0); + root_error = lane.broadcast_bytes(std::move(root_error), 0); if (!root_error.empty()) throw std::runtime_error("native output-piece reconstruction failed: " + root_error); return result; diff --git a/include/pops/runtime/program/amr_program_checkpoint.hpp b/include/pops/runtime/program/amr_program_checkpoint.hpp index c0f5ebbc1..53fbb3ac2 100644 --- a/include/pops/runtime/program/amr_program_checkpoint.hpp +++ b/include/pops/runtime/program/amr_program_checkpoint.hpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace pops::runtime::program { @@ -52,6 +53,7 @@ struct AmrProgramSyncEvent { struct AmrProgramAcceptedState { std::vector level_clocks; std::map logical_clock_ticks; + CellTemporalPartitionAcceptedState temporal_partition; /// Rank-independent canonical image of the runtime-owned AMR tagging hysteresis. std::vector tagging_hysteresis_state; std::map history_owners; @@ -448,12 +450,25 @@ Map read_map(Reader& in, ReadValue&& read_value) { inline std::vector serialize_amr_program_accepted_state( const AmrProgramAcceptedState& state) { using namespace checkpoint_detail; + validate_cell_temporal_partition_state(state.temporal_partition); Writer out; - out.u64(0x3454534153504f50ULL); // "POPSAST4", little-endian bytes + out.u64(0x3554534153504f50ULL); // "POPSAST5", little-endian bytes out.size(state.level_clocks.size()); for (const auto& clock : state.level_clocks) write_clock(out, clock); write_map(out, state.logical_clock_ticks, [](Writer& w, std::int64_t value) { w.i64(value); }); + out.u64(static_cast(state.temporal_partition.kind)); + out.string(state.temporal_partition.provider_identity); + out.u64(state.temporal_partition.topology_epoch); + out.i64(state.temporal_partition.synchronization_tick); + out.i64(state.temporal_partition.tick_denominator); + out.size(state.temporal_partition.cells.size()); + for (const CellTemporalPartitionRecord& cell : state.temporal_partition.cells) { + out.i32(cell.level); + out.u64(cell.cell); + out.i32(cell.rung); + out.i64(cell.accepted_tick); + } out.bytes(state.tagging_hysteresis_state); write_map(out, state.history_owners, [](Writer& w, int v) { w.i32(v); }); write_map(out, state.history_states, [](Writer& w, const std::string& v) { w.string(v); }); @@ -520,7 +535,8 @@ inline AmrProgramAcceptedState deserialize_amr_program_accepted_state( const std::vector& bytes) { using namespace checkpoint_detail; Reader in(bytes); - if (in.u64() != 0x3454534153504f50ULL) + const std::uint64_t magic = in.u64(); + if (magic != 0x3554534153504f50ULL) throw std::runtime_error( "invalid AMR Program accepted-state payload: unsupported magic/version"); AmrProgramAcceptedState state; @@ -529,6 +545,28 @@ inline AmrProgramAcceptedState deserialize_amr_program_accepted_state( clock = read_clock(in); state.logical_clock_ticks = read_map(in, [](Reader& r) { return r.i64(); }); + const std::uint64_t kind = in.u64(); + if (kind > static_cast(TemporalPartitionKind::CellLocal)) + throw std::runtime_error( + "invalid AMR Program accepted-state payload: unsupported temporal partition kind"); + state.temporal_partition.kind = static_cast(kind); + state.temporal_partition.provider_identity = in.string(); + state.temporal_partition.topology_epoch = in.u64(); + state.temporal_partition.synchronization_tick = in.i64(); + state.temporal_partition.tick_denominator = in.i64(); + state.temporal_partition.cells.resize(in.size()); + for (CellTemporalPartitionRecord& cell : state.temporal_partition.cells) { + cell.level = in.i32(); + cell.cell = in.u64(); + cell.rung = in.i32(); + cell.accepted_tick = in.i64(); + } + try { + validate_cell_temporal_partition_state(state.temporal_partition); + } catch (const std::exception& error) { + throw std::runtime_error(std::string("invalid AMR Program accepted-state payload: ") + + error.what()); + } state.tagging_hysteresis_state = in.bytes(); state.history_owners = read_map>(in, [](Reader& r) { return r.i32(); }); diff --git a/include/pops/runtime/program/amr_program_context.hpp b/include/pops/runtime/program/amr_program_context.hpp index 4cf7c6b37..a97b70ad9 100644 --- a/include/pops/runtime/program/amr_program_context.hpp +++ b/include/pops/runtime/program/amr_program_context.hpp @@ -30,18 +30,21 @@ #include // saxpy / lincomb #include // MultiFab #include +#include #include #include #include #include #include #include // AmrRuntime (the engine the driver wraps) +#include #include #include // GridContext (per-level Schur assembly seam, ADC-633) #include // AmrSystem (the facade: params / block map / engine) #include #include #include +#include #include #include // RuntimeParams #include // stable compiled-Program numeric protocol @@ -57,11 +60,13 @@ /// the unsupported polar stencil. The `{amr_install}` slot /// installs one recursive Berger-Oliger driver: child steps partition the parent window, each rate reads /// a mandatory old/new dense-output interpolation at its exact Program abscissa, and level sync is -/// conservative reflux followed by average-down. The single coarse system Poisson per macro-step -/// (OncePerStep) is injected coarse -> fine; unsupported per-stage fine re-solves fail loudly. Multistep -/// history rings (keep_history / T.prev) are owner/space/clock-qualified; their per-level slots are -/// remapped through regrid and v3 checkpoint native replay. GPU execution stays device-clean by -/// construction: every per-cell op is for_each_cell / a POPS_HD named functor reused from the engine. +/// conservative reflux followed by average-down. The single default system Poisson per macro-step +/// (OncePerStep) is injected coarse -> fine; a field-coupled Jacobian perturbation instead re-evaluates +/// its exact named prepared provider from the active hierarchy level and restores the accepted state +/// transactionally. Multistep history rings (keep_history / T.prev) are owner/space/clock-qualified; +/// their per-level slots are remapped through regrid and v3 checkpoint native replay. GPU execution +/// stays device-clean by construction: every per-cell op is for_each_cell / a POPS_HD named functor +/// reused from the engine. namespace pops { namespace runtime { namespace program { @@ -94,7 +99,6 @@ class AmrProgramContext : public ProgramExecutionServices { "multi-block AmrRuntime build before installing a compiled time Program over the " "hierarchy"); require_supported_program_refinement_ratios_(*eng_); - stage_restore_scratch_.reserve(eng_->n_blocks()); materialize_capture_flux_scratch_(); hierarchy_tensor_solver_registry_ = facade_->hierarchy_tensor_solver_provider_registry(); } @@ -105,7 +109,6 @@ class AmrProgramContext : public ProgramExecutionServices { // the production facade constructor above remains fail-closed when the engine was not built. if (eng_ != nullptr) { require_supported_program_refinement_ratios_(*eng_); - stage_restore_scratch_.reserve(eng_->n_blocks()); materialize_capture_flux_scratch_(); } if (facade_ != nullptr) @@ -122,9 +125,6 @@ class AmrProgramContext : public ProgramExecutionServices { /// clears the per-step effective-flux ledger + the live-state-ring record (ADC-639); the PERSISTENT /// per-ring flux strips (ring_flux_) survive across steps, as the multistep ring itself does. void reset_step() const { - default_solve_report_.reset(); - for (auto& [_, report] : named_solve_reports_) - report = SolveReport{}; // Keep exact-layout EdgeFlux storage resident across accepted macro steps. Presence is tracked // separately, so stale numerical values are unreachable while their pinned allocations remain // available to the next replay. @@ -162,7 +162,7 @@ class AmrProgramContext : public ProgramExecutionServices { template void advance_hierarchy(double dt, Body&& body) const { advance_attempt_(dt, "AmrProgramContext::advance_hierarchy", CouplingSchedule::RecursiveCatchUp, - [&](const amr::ClockWindow& root) { advance_level_(0, root, dt, body); }); + {}, [&](const amr::ClockWindow& root) { advance_level_(0, root, dt, body); }); } /// Execute one hierarchy-wide Program body inside the same accepted-step transaction as the @@ -174,7 +174,7 @@ class AmrProgramContext : public ProgramExecutionServices { void advance_synchronized_hierarchy(double dt, Body&& body) const { advance_attempt_( dt, "AmrProgramContext::advance_synchronized_hierarchy", CouplingSchedule::HierarchyBarrier, - [&](const amr::ClockWindow& root) { + {}, [&](const amr::ClockWindow& root) { current_window_ = root; current_level_dt_ = dt; active_parent_.reset(); @@ -195,6 +195,133 @@ class AmrProgramContext : public ProgramExecutionServices { }); } + using SameLevelCellTemporalExecutor = + PreparedBatchedCellTemporalExecutor; + + /// Prepare the exact bounded production cell-local route selected by generated Python code. + /// + /// Preparation is an accepted-boundary operation and is collective when MPI is present. The + /// current provider intentionally refuses MPI, GPU, multiple blocks/levels/boxes, non-default + /// cadence and heterogeneous rungs before installing any competing temporal authority. + void prepare_same_level_cell_temporal_execution(std::string clock_identity, + std::int64_t tick_denominator, + int rung = 0) const { + if (facade_ == nullptr || eng_ == nullptr) + throw std::logic_error( + "cell-local AMR Program preparation requires a live facade and runtime"); + if (attempt_snapshot_active_ || active_parent_ || current_window_) + throw std::logic_error( + "cell-local AMR Program preparation requires a clean accepted boundary"); + if (same_level_cell_temporal_executor_ || !same_level_cell_temporal_clock_identity_.empty()) + throw std::logic_error("cell-local AMR Program execution may be prepared exactly once"); + if (clock_identity.empty()) + throw std::invalid_argument( + "cell-local AMR Program execution requires a non-empty clock identity"); + if (facade_->program_substeps() != 1 || facade_->program_stride() != 1) + throw std::invalid_argument( + "cell-local AMR Program execution requires default substeps=1 and stride=1 cadence"); + + if (n_ranks() > 1) { + const long participants = all_reduce_sum(1L); + if (participants != static_cast(n_ranks())) + throw std::runtime_error("cell-local AMR Program MPI preparation did not reach every rank"); + throw std::runtime_error( + "cell-local AMR Program execution has no MPI-safe multi-box stage/flux provider"); + } + if (!PreparedSameLevelTransportEulerStageFluxProvider::supports_default_execution_space()) + throw std::runtime_error("cell-local AMR Program execution has no device-clean GPU provider"); + + ensure_level_clocks_(); + CellTemporalPartitionAcceptedState partition; + const std::vector accepted_bytes = facade_->program_accepted_state(); + if (accepted_bytes.empty()) { + const std::int64_t synchronization_tick = exact_physical_tick_( + facade_->time(), tick_denominator, "cell-local AMR Program initial time"); + partition = prepare_same_level_transport_euler_partition(*eng_, synchronization_tick, + tick_denominator, rung); + } else { + const AmrProgramAcceptedState accepted = + deserialize_amr_program_accepted_state(accepted_bytes); + validate_program_accepted_state_(accepted); + partition = accepted.temporal_partition; + if (partition.kind != TemporalPartitionKind::CellLocal || + partition.provider_identity != kSameLevelTransportEulerStageFluxProvider || + partition.tick_denominator != tick_denominator) + throw std::runtime_error( + "restored AMR Program checkpoint targets another temporal execution provider"); + if (std::any_of( + partition.cells.begin(), partition.cells.end(), + [rung](const CellTemporalPartitionRecord& cell) { return cell.rung != rung; })) + throw std::runtime_error( + "restored AMR Program checkpoint targets another prepared cell rung"); + const std::int64_t physical_tick = exact_physical_tick_( + facade_->time(), tick_denominator, "restored cell-local AMR Program time"); + if (physical_tick != partition.synchronization_tick) + throw std::runtime_error( + "restored cell-local AMR Program clock differs from its physical time"); + } + + auto ledger = std::make_shared( + eng_->topology_epoch(), eng_->topology_materialization_generation(), 0, 0, + partition.cells.size(), eng_->level_state(0, 0).ncomp()); + ledger->invalidate_accepted_publication(partition.synchronization_tick, + partition.tick_denominator); + PreparedSameLevelTransportEulerStageFluxProvider provider(*eng_, partition, ledger, + clock_identity); + auto executor = std::make_unique(partition, std::move(provider)); + + // Publish the selected route only after every allocation and exact provider check succeeded. + same_level_cell_temporal_clock_identity_ = std::move(clock_identity); + same_level_cell_temporal_tick_denominator_ = tick_denominator; + same_level_cell_temporal_rung_ = rung; + same_level_cell_temporal_ledger_ = std::move(ledger); + same_level_cell_temporal_executor_ = std::move(executor); + } + + /// Execute one accepted Program interval through the prepared local-stage/space-time-flux route. + void advance_same_level_cell_temporal(double dt) const { + if (!same_level_cell_temporal_executor_) + throw std::logic_error( + "cell-local AMR Program execution was not prepared by the installed artifact"); + const std::string provider_identity = same_level_cell_temporal_executor_->provider_identity(); + advance_attempt_( + dt, "AmrProgramContext::advance_same_level_cell_temporal", + CouplingSchedule::RecursiveCatchUp, provider_identity, + [this, dt](const amr::ClockWindow& root) { + // Importing an externally restored accepted state may rematerialize the exact provider. + // Reacquire it after import instead of retaining a pointer across that boundary. + SameLevelCellTemporalExecutor* const executor = same_level_cell_temporal_executor_.get(); + if (executor == nullptr) + throw std::logic_error( + "cell-local AMR Program execution lost its prepared provider after restore"); + const CellTemporalPartitionAcceptedState accepted = executor->checkpoint(); + const std::int64_t begin_tick = + exact_physical_tick_(root.begin.physical_time, accepted.tick_denominator, + "cell-local AMR Program accepted time"); + if (begin_tick != accepted.synchronization_tick) + throw std::runtime_error( + "cell-local AMR Program partition clock differs from its accepted level clock"); + const std::int64_t delta_tick = + exact_duration_tick_(dt, accepted.tick_denominator, "cell-local AMR Program dt"); + if (accepted.synchronization_tick > std::numeric_limits::max() - delta_tick) + throw std::overflow_error("cell-local AMR Program target tick overflow"); + executor->begin_attempt(accepted.synchronization_tick + delta_tick); + executor->advance_to_barrier(); + executor->commit(); + }); + } + + /// Last accepted same-level integrated face-flux publication. + const SameLevelCellIntegratedFluxLedger& accepted_same_level_cell_flux_ledger() const { + if (!same_level_cell_temporal_ledger_ || !same_level_cell_temporal_executor_) + throw std::logic_error("AMR Program has no prepared cell-local flux ledger"); + if (facade_->program_accepted_state_revision() != accepted_state_revision_) + throw std::logic_error("cell-local AMR Program flux ledger is stale after an outer rollback"); + if (same_level_cell_temporal_ledger_->publication_generation() == 0) + throw std::logic_error("cell-local AMR Program has no accepted interval-flux publication"); + return *same_level_cell_temporal_ledger_; + } + using ProgramExecutionServices::install; /// Generated AMR artifacts retain their context in @p owner and install a second closure beside @@ -243,7 +370,8 @@ class AmrProgramContext : public ProgramExecutionServices { synchronize_level_pair_(child, 0, ledger_end, *current_sync_clock_); finalize_history_rotation_(); } - /// Head-of-step regrid at the engine's cadence (the SAME union-tags regrid the native step runs). + /// Head-of-step regrid at the Program-owned cadence. The spatial runtime exposes the prepared + /// interval and the immediate regrid primitive, but never compares an accepted clock itself. /// A changed topology also rebinds lagged conservative histories and their compact interface-flux /// authority before the Program reads prev(k); a layout-identical regrid remains bit-identical. void regrid_if_due(int macro_step) const { @@ -252,27 +380,289 @@ class AmrProgramContext : public ProgramExecutionServices { regrid_if_due_at_(macro_step, facade_->time()); } + /// Publish one accepted fine-level owner migration through the same hierarchy/ledger authority as + /// scientific regrid. The spatial runtime owns field/history redistribution and provider + /// rematerialization; this Program layer redistributes compact lagged fluxes through the checkpoint + /// rematerializer and republishes the accepted clock/history image atomically. Cell-local temporal + /// providers remain refused until their stage/flux resources gain a restartable rematerializer. + bool apply_rebalance_decision(int level, const RebalanceDecision& decision) const { + std::exception_ptr local_failure; + HistoryFluxTopology before; + AmrProgramRankOwnership source_ownership; + AmrProgramRankOwnership target_ownership; + std::string local_program_payload; + std::string call_contract; + try { + if (facade_ == nullptr || eng_ == nullptr) + throw std::logic_error("AMR Program rebalance requires its runtime facade and engine"); + require_restart_regrid_boundary_(); + if (facade_->has_active_step_transaction()) + throw std::logic_error("AMR Program rebalance cannot overlap a facade step transaction"); + import_program_accepted_state_(true); + if (temporal_partition_.checkpoint().kind == TemporalPartitionKind::CellLocal) + throw std::logic_error( + "AMR Program rebalance does not yet support cell-local stage providers or flux " + "ledgers"); + if (macro_step() < 0 || macro_step() > std::numeric_limits::max() || + !std::isfinite(facade_->time())) + throw std::logic_error("AMR Program rebalance requires a representable accepted clock"); + if (decision.exact_contract.empty() || + decision.exact_contract != pops::detail::exact_rebalance_decision(decision)) + throw std::invalid_argument("AMR Program rebalance decision exact contract is invalid"); + ExactContractBuilder call; + call.text("pops.amr.program-rebalance-call") + .scalar(std::uint32_t{1}) + .scalar(level) + .bytes(decision.exact_contract); + call_contract = std::move(call).release(); + + before = history_flux_topology_snapshot_(); + if (history_flux_topology_.bound() && + !same_history_flux_topology_(history_flux_topology_, before)) + throw std::logic_error( + "AMR Program rebalance history authority differs from the accepted hierarchy"); + source_ownership = {n_ranks(), before.owners}; + target_ownership = source_ownership; + if (decision.accepted) { + if (level <= 0 || level >= nlev()) + throw std::out_of_range("AMR Program rebalance targets an inactive fine level"); + const std::size_t index = static_cast(level); + if (decision.proposed_mapping.size() != + static_cast(target_ownership.level_patch_owners[index].size())) + throw std::invalid_argument( + "AMR Program rebalance mapping differs from the accepted patch count"); + target_ownership.level_patch_owners[index] = decision.proposed_mapping.ranks(); + const std::vector& local_bytes = facade_->program_accepted_state(); + local_program_payload.reserve(local_bytes.size()); + for (const std::uint8_t byte : local_bytes) + local_program_payload.push_back(static_cast(byte)); + } + } catch (...) { + local_failure = std::current_exception(); + } + require_collective_rebalance_program_success_(local_failure, + "AMR Program rebalance rank-local preflight"); + if (!all_ranks_agree_exact_ordered_byte_pairs( + {{"pops.amr.program-rebalance-call", call_contract}})) + throw std::invalid_argument("AMR Program rebalance call differs across MPI ranks"); + + std::optional> rematerialized_program_state; + if (decision.accepted) { + const std::vector gathered_payloads = + WorldCommunicator::world().allgather_bytes(local_program_payload); + local_failure = nullptr; + try { + std::vector> source_payloads; + source_payloads.reserve(gathered_payloads.size()); + for (const std::string& payload : gathered_payloads) { + std::vector bytes; + bytes.reserve(payload.size()); + for (const char byte : payload) + bytes.push_back(static_cast(byte)); + source_payloads.push_back(std::move(bytes)); + } + AmrProgramAcceptedState rematerialized = + deserialize_amr_program_accepted_state(rematerialize_amr_program_accepted_state_bytes( + source_payloads, source_ownership, target_ownership, my_rank())); + // Lagged flux strips are ownership-rematerialized above. Accepted reports are different: + // their keys certify the old topology epoch, so retaining them after publication would make + // the otherwise exact accepted image fail its own topology qualification. + rematerialized.accepted_flux_ledger.clear(); + rematerialized.accepted_interface_flux_ledger.clear(); + rematerialized.accepted_sync.clear(); + rematerialized_program_state = serialize_amr_program_accepted_state(rematerialized); + } catch (...) { + local_failure = std::current_exception(); + } + require_collective_rebalance_program_success_( + local_failure, "AMR Program rebalance accepted-state rematerialization"); + } + + AttemptSnapshot saved; + local_failure = nullptr; + try { + capture_engine_attempt_snapshot_(saved, /*borrows_facade_snapshot=*/false); + capture_program_attempt_snapshot_(saved); + } catch (...) { + local_failure = std::current_exception(); + } + require_collective_rebalance_program_success_(local_failure, + "AMR Program rebalance snapshot capture"); + attempt_snapshot_active_ = true; + struct RebalanceAttemptLease { + bool& active; + ~RebalanceAttemptLease() { active = false; } + } lease{attempt_snapshot_active_}; + + bool applied = false; + local_failure = nullptr; + if (decision.accepted) { + try { + eng_->set_component_logical_time(macro_step(), facade_->time()); + } catch (...) { + local_failure = std::current_exception(); + } + require_collective_rebalance_program_success_( + local_failure, "AMR Program rebalance logical-time publication"); + } + local_failure = nullptr; + try { + applied = eng_->apply_rebalance_decision(level, decision); + if (applied) { + if (!rematerialized_program_state) + throw std::logic_error( + "AMR Program rebalance lost its prepared accepted-state rematerialization"); + materialize_capture_flux_scratch_(); + facade_->restore_program_accepted_state(*rematerialized_program_state); + import_program_accepted_state_(true); + automatic_regrid_macro_step_ = saved.automatic_regrid_macro_step; + ++history_flux_topology_rebind_count_; + ensure_level_clocks_(); + } + } catch (...) { + local_failure = std::current_exception(); + } + const long attempt_failures = + n_ranks() > 1 ? all_reduce_sum(local_failure ? 1L : 0L) : (local_failure ? 1L : 0L); + if (attempt_failures == 0) + return applied; + + const bool accepted_state_mutated = + facade_->program_accepted_state_revision() != saved.program_accepted_state_revision; + if (saved.engine_captured) + eng_->restore_step_snapshot(saved.engine); + if (eng_->topology_materialization_generation() != saved.engine_topology_generation) + invalidate_capture_flux_scratch_(); + if (accepted_state_mutated) + facade_->restore_program_accepted_state(saved.program_accepted_state); + restore_program_attempt_snapshot_(saved); + accepted_state_revision_ = saved.program_accepted_state_revision; + if (local_failure) + std::rethrow_exception(local_failure); + throw std::runtime_error("AMR Program rebalance failed on another MPI rank"); + } + private: + static std::int64_t exact_tick_(double value, std::int64_t denominator, bool require_positive, + const char* operation) { + if (!std::isfinite(value) || denominator <= 0 || + (require_positive ? !(value > 0.0) : value < 0.0)) + throw std::invalid_argument(std::string(operation) + + " requires a finite representable rational clock"); + const long double scaled = + static_cast(value) * static_cast(denominator); + const long double nearest = std::round(scaled); + const long double tolerance = 32.0L * + static_cast(std::numeric_limits::epsilon()) * + std::max(1.0L, std::abs(scaled)); + if (!std::isfinite(scaled) || std::abs(scaled - nearest) > tolerance || nearest < 0.0L || + nearest > static_cast(std::numeric_limits::max())) + throw std::invalid_argument(std::string(operation) + + " is not an integer tick over its declared denominator"); + const auto tick = static_cast(nearest); + if (require_positive && tick == 0) + throw std::invalid_argument(std::string(operation) + " advances zero declared ticks"); + return tick; + } + + static std::int64_t exact_physical_tick_(double value, std::int64_t denominator, + const char* operation) { + return exact_tick_(value, denominator, false, operation); + } + + static std::int64_t exact_duration_tick_(double value, std::int64_t denominator, + const char* operation) { + return exact_tick_(value, denominator, true, operation); + } + + [[nodiscard]] CellTemporalPartitionAcceptedState temporal_partition_checkpoint_() const { + if (same_level_cell_temporal_executor_) + return same_level_cell_temporal_executor_->checkpoint(); + return temporal_partition_.checkpoint(); + } + + void require_temporal_execution_route_(std::string_view provider_identity) const { + if (same_level_cell_temporal_executor_) { + BatchedCellTemporalPartition(same_level_cell_temporal_executor_->checkpoint()) + .require_prepared_execution_route(provider_identity); + return; + } + temporal_partition_.require_prepared_execution_route(provider_identity); + } + + void rollback_temporal_execution_route_() const noexcept { + if (same_level_cell_temporal_executor_) + same_level_cell_temporal_executor_->rollback(); + else + temporal_partition_.rollback(); + } + + void rebuild_same_level_cell_temporal_execution_( + const CellTemporalPartitionAcceptedState& partition) const { + if (same_level_cell_temporal_clock_identity_.empty() || + partition.provider_identity != kSameLevelTransportEulerStageFluxProvider || + partition.tick_denominator != same_level_cell_temporal_tick_denominator_ || + std::any_of(partition.cells.begin(), partition.cells.end(), + [this](const CellTemporalPartitionRecord& cell) { + return cell.rung != same_level_cell_temporal_rung_; + })) + throw std::runtime_error( + "restored cell-local AMR Program state differs from its installed provider"); + auto ledger = std::make_shared( + eng_->topology_epoch(), eng_->topology_materialization_generation(), 0, 0, + partition.cells.size(), eng_->level_state(0, 0).ncomp()); + ledger->invalidate_accepted_publication(partition.synchronization_tick, + partition.tick_denominator); + PreparedSameLevelTransportEulerStageFluxProvider provider( + *eng_, partition, ledger, same_level_cell_temporal_clock_identity_); + auto executor = std::make_unique(partition, std::move(provider)); + same_level_cell_temporal_ledger_ = std::move(ledger); + same_level_cell_temporal_executor_ = std::move(executor); + } + + void restore_temporal_execution_route_( + const CellTemporalPartitionAcceptedState& partition) const { + if (!same_level_cell_temporal_executor_) { + temporal_partition_.restore(partition); + return; + } + if (!same_level_cell_temporal_ledger_ || + same_level_cell_temporal_ledger_->topology_epoch() != eng_->topology_epoch() || + same_level_cell_temporal_ledger_->materialization_generation() != + eng_->topology_materialization_generation()) { + rebuild_same_level_cell_temporal_execution_(partition); + return; + } + same_level_cell_temporal_executor_->restore_accepted_boundary(partition); + } + void regrid_if_due_at_(std::int64_t macro_step, double physical_time) const { + if (!std::isfinite(physical_time)) + throw std::logic_error("AMR Program regrid requires a finite accepted physical time"); + if (macro_step < 0 || macro_step > std::numeric_limits::max()) + throw std::overflow_error( + "AMR Program regrid logical tick exceeds the runtime integer range"); + const int interval = eng_->regrid_interval(); + const bool regrid_due = interval > 0 && macro_step > 0 && macro_step % interval == 0; + const HistoryFluxTopology before = history_flux_topology_snapshot_(); if (history_flux_topology_.bound() && !same_history_flux_topology_(history_flux_topology_, before)) throw std::runtime_error( "AMR lagged-flux topology authority differs from the accepted hierarchy"); history_flux_topology_ = before; - if (!std::isfinite(physical_time)) - throw std::logic_error("AMR Program regrid requires a finite accepted physical time"); - if (macro_step < 0 || macro_step > std::numeric_limits::max()) - throw std::overflow_error( - "AMR Program regrid logical tick exceeds the runtime integer range"); // The Program owns the accepted clock. Publish its exact evaluation coordinate only at the // tagger/regrid boundary so direct AmrProgramContext and restarted executions cannot inherit // stale facade metadata. - const int runtime_tick = static_cast(macro_step); - eng_->set_component_logical_time(runtime_tick, physical_time); - eng_->regrid_if_due(runtime_tick); + if (regrid_due) { + require_regrid_rematerializable_temporal_partition(temporal_partition_checkpoint_()); + eng_->set_component_logical_time(macro_step, physical_time); + eng_->regrid(); + } // Regrid is a head-of-attempt operation. Rebuild every layout-bound face field and its // redistribution scratch here, before the first Program stage, never lazily from capture_into_. + // This is also required off-cadence: restart/rank rematerialization may have replaced topology + // storage between accepted steps without scheduling a new regrid. materialize_capture_flux_scratch_(); const HistoryFluxTopology after = history_flux_topology_snapshot_(); if (after.epoch != before.epoch && !same_history_flux_layout_(before, after)) @@ -287,119 +677,32 @@ class AmrProgramContext : public ProgramExecutionServices { bool history_flux_topology_bound() const { return history_flux_topology_.bound(); } int history_flux_topology_rebind_count() const { return history_flux_topology_rebind_count_; } - // --- field solve (the SHARED coarse Poisson) ------------------------------------------------------ - /// The default head-of-step elliptic solve: the coarse system Poisson + coarse->fine aux injection. - /// The AMR runtime runs it EXACTLY ONCE per macro-step (a level-0 / not-yet-solved guard): - /// calling it again at fine levels within the same macro-step is a no-op cache-hit (parity: the - /// body stays atomic, the solve fires once -- the OncePerStep cadence the native AMR step uses). + // --- explicitly coarse-only default field solve --------------------------------------------------- + /// Legacy/manual driver route for the hierarchy's default coarse-provider solve. Generated + /// Programs always carry an exact provider identity and use the level-qualified shared services. + SolveOutcome solve_default_field_on_coarse_level() const { + if (level_ != 0) + throw std::logic_error( + "AmrProgramContext::solve_default_field_on_coarse_level is level-0-only; a fine-level " + "field request requires an exact provider and evaluation point; " + "coarse-to-fine auxiliary injection is not a " + "fine-level solve"); + return eng_->solve_default_field(); + } + private: SolveOutcome program_execution_solve_fields_outcome_() const { - if (level_ == 0 || !default_solve_report_) { - default_solve_report_.reset(); - SolveOutcome outcome = eng_->solve_default_field(); - const SolveReport report = outcome.report(); - if (report.solved()) - default_solve_report_ = report; - return outcome; - } - if (all_reduce_max(eng_->field_solve_transaction_active() ? 1L : 0L) != 0) - throw std::logic_error( - "AMR fine-level field reuse requires the coarse SolveOutcome to be consumed first"); - return SolveOutcome::collective_world(*default_solve_report_); - } - /// Per-stage re-solve from a stage state is currently a coarse-only capability. A fine-level request - /// is rejected explicitly; it never consumes a stale injected auxiliary field. - SolveOutcome program_execution_solve_fields_from_state_outcome_(int b, MultiFab& u_stage) const { - if (level_ == 0) { - MultiFab& live = state(b); - MultiFab& saved = stage_state_scratch_for_(b, level_, live); - PureFieldAlgebra::copy_allocated(saved, live); - default_solve_report_.reset(); - SolveOutcome outcome = [&]() -> SolveOutcome { - try { - PureFieldAlgebra::copy_allocated(live, u_stage); - SolveOutcome candidate = eng_->solve_default_field(); - PureFieldAlgebra::copy_allocated(live, saved); - return candidate; - } catch (...) { - PureFieldAlgebra::copy_allocated(live, saved); - throw; - } - }(); - const SolveReport report = outcome.report(); - if (report.solved()) - default_solve_report_ = report; - return outcome; - } - deferred_op( - "solve_fields_from_state_default", - "the default per-stage fine-level field re-solve requires a composite stage solver; use " - "OncePerStep field cadence or an exact named field provider"); + return solve_default_field_on_coarse_level(); + } + SolveOutcome program_execution_solve_fields_from_state_outcome_(int, MultiFab&) const { + throw std::invalid_argument( + "AMR Program stage field solves require an exact provider identity and evaluation point"); } SolveOutcome program_execution_field_solve_from_state_at_outcome_( const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& provider_slot, int b, MultiFab& u_stage) const { - if (point.level < 0 || point.level >= eng_->nlev()) - throw std::out_of_range( - "AmrProgramContext::solve_fields_from_state_at level is out of range"); - if (point.level != 0) - deferred_op("solve_fields_from_state_at_fine_level", - "a fine-level stage perturbation requires a composite field solver"); - named_solve_reports_.erase(provider_slot); - MultiFab& live = eng_->level_state(static_cast(sys_block(b)), point.level); - MultiFab& saved = stage_state_scratch_for_(b, point.level, live); - PureFieldAlgebra::copy_allocated(saved, live); - SolveOutcome outcome = [&]() -> SolveOutcome { - try { - PureFieldAlgebra::copy_allocated(live, u_stage); - SolveOutcome candidate = eng_->solve_named_fields(&provider_slot); - PureFieldAlgebra::copy_allocated(live, saved); - return candidate; - } catch (...) { - PureFieldAlgebra::copy_allocated(live, saved); - named_solve_reports_.insert_or_assign(provider_slot, SolveReport{}); - throw; - } - }(); - const SolveReport report = outcome.report(); - named_solve_reports_.insert_or_assign(provider_slot, report); - return outcome; - } - /// Named multi-elliptic field re-solve. The coarse solve publishes and injects every level once; - /// fine levels consume only that exact provider-qualified report. - SolveOutcome program_execution_solve_named_field_from_state_outcome_(const std::string& field, - int b, - MultiFab& u_stage) const { - if (level_ != 0) { - if (all_reduce_max(eng_->field_solve_transaction_active() ? 1L : 0L) != 0) - throw std::logic_error( - "AMR fine-level field reuse requires the coarse SolveOutcome to be consumed first"); - const auto cached = named_solve_reports_.find(field); - if (cached == named_solve_reports_.end() || !cached->second.solved()) - throw std::runtime_error( - "AmrProgramContext::solve_fields_from_state(field): fine-level reuse requires an " - "accepted coarse SolveReport"); - return SolveOutcome::collective_world( - cached->second); // the coarse solve publishes/injects every level once per stage - } - MultiFab& live = state(b); - MultiFab& published = stage_state_scratch_for_(b, level_, live); - PureFieldAlgebra::copy_allocated(published, live); - SolveOutcome outcome = [&]() -> SolveOutcome { - try { - PureFieldAlgebra::copy_allocated(live, u_stage); - SolveOutcome candidate = eng_->solve_named_fields(&field); - PureFieldAlgebra::copy_allocated(live, published); - return candidate; - } catch (...) { - PureFieldAlgebra::copy_allocated(live, published); - named_solve_reports_.insert_or_assign(field, SolveReport{}); - throw; - } - }(); - const SolveReport report = outcome.report(); - named_solve_reports_.insert_or_assign(field, report); - return outcome; + return eng_->solve_named_fields_from_state_at(point, provider_slot, + static_cast(sys_block(b)), u_stage); } /// Retained default-provider overload: the final Program IR always carries an exact field identity, /// while an unqualified coupled solve has no provider authority and therefore fails loud. @@ -411,90 +714,13 @@ class AmrProgramContext : public ProgramExecutionServices { "exact field-qualified Program operation"); } - SolveOutcome program_execution_solve_named_field_from_blocks_outcome_( - const std::string& field, const std::vector& u_stages) const { - if (level_ != 0) { - if (all_reduce_max(eng_->field_solve_transaction_active() ? 1L : 0L) != 0) - throw std::logic_error( - "AMR fine-level field reuse requires the coarse SolveOutcome to be consumed first"); - const auto cached = named_solve_reports_.find(field); - if (cached == named_solve_reports_.end() || !cached->second.solved()) - throw std::runtime_error( - "AmrProgramContext::solve_fields_from_blocks(field): fine-level reuse requires an " - "accepted coarse SolveReport"); - return SolveOutcome::collective_world(cached->second); - } - if (u_stages.size() != static_cast(n_blocks())) - throw std::runtime_error( - "AmrProgramContext::solve_fields_from_blocks(field): stage vector size mismatch"); - ExclusiveUseGuard use(named_field_solve_in_use_, - "AMR simultaneous field-solve workspace is already in use"); - - // Validate the complete request before taking a snapshot or touching a live state. In particular, - // a stage may alias its own live block, but borrowing another block's live object would make the - // sequential substitutions order-dependent. - for (std::size_t p = 0; p < u_stages.size(); ++p) { - if (u_stages[p] == nullptr) - continue; - const MultiFab& live = state(static_cast(p)); - const MultiFab& stage = *u_stages[p]; - if (stage.box_array().boxes() != live.box_array().boxes() || - stage.dmap().ranks() != live.dmap().ranks() || stage.ncomp() != live.ncomp() || - stage.n_grow() != live.n_grow()) - throw std::invalid_argument( - "AMR simultaneous field solve stage does not match its exact level layout"); - for (std::size_t other = 0; other < facade_->program_block_map().size(); ++other) { - if (other != p && &stage == &state(static_cast(other))) - throw std::invalid_argument( - "AMR simultaneous field solve cannot use another block's live state as a stage " - "override"); - } - } - stage_restore_scratch_.clear(); - // Materialize and capture every accepted live image before mutating any block. Snapshot storage - // is context-owned and exact-layout; after warm-up this loop copies bytes but allocates nothing. - for (std::size_t p = 0; p < u_stages.size(); ++p) { - if (u_stages[p] == nullptr) - continue; - MultiFab& state_value = state(static_cast(p)); - MultiFab& published = stage_state_scratch_for_(static_cast(p), level_, state_value); - PureFieldAlgebra::copy_allocated(published, state_value); - stage_restore_scratch_.push_back({&state_value, &published}); - } - auto restore = [&]() { - for (const auto& [live, published] : stage_restore_scratch_) - PureFieldAlgebra::copy_allocated(*live, *published); - }; - SolveOutcome outcome = [&]() -> SolveOutcome { - try { - for (std::size_t p = 0; p < u_stages.size(); ++p) { - if (u_stages[p] != nullptr) - PureFieldAlgebra::copy_allocated(state(static_cast(p)), *u_stages[p]); - } - SolveOutcome candidate = eng_->solve_named_fields(&field); - restore(); - return candidate; - } catch (...) { - restore(); - named_solve_reports_.insert_or_assign(field, SolveReport{}); - throw; - } - }(); - const SolveReport report = outcome.report(); - named_solve_reports_.insert_or_assign(field, report); - return outcome; - } - - /// Generated allocation-free route. The static initializer-list request is copied into one - /// context-owned pointer workspace keyed by the exact IR identity; field and ordered block pack - /// cannot drift across replays. The vector overload above remains the manual C++ API. + /// Generated allocation-free route. The static initializer-list request is mapped into one + /// shared runtime-block pointer workspace keyed by the exact IR identity. The provider receives + /// the already authenticated runtime ordering and owns only the hierarchy solve dispatch. SolveOutcome program_execution_solve_generated_field_from_blocks_outcome_( - std::int64_t value_id, std::string_view field, - std::initializer_list overrides) const { - const std::vector& stages = - generated_field_solve_stages_(value_id, field, overrides); - return solve_fields_from_blocks(generated_field_solve_workspaces_.at(value_id).field_identity, - stages); + const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& field, + const std::vector& runtime_stages) const { + return eng_->solve_named_fields_from_states_at(point, field, runtime_stages); } public: @@ -616,7 +842,7 @@ class AmrProgramContext : public ProgramExecutionServices { OperatorFingerprint topology = ::pops::detail::layout_fingerprint(prototype, program_resource_vector_distribution()); ::pops::detail::fingerprint_geometry(topology, eng_->level_geom(level_)); - ::pops::detail::fingerprint_boundary(topology, eng_->transport_bc()); + ::pops::detail::fingerprint_boundary(topology, eng_->default_boundary_descriptor()); ::pops::detail::fingerprint_mix(topology, "amr-level-local"); ::pops::detail::fingerprint_mix(topology, static_cast(level_)); ::pops::detail::fingerprint_mix(topology, static_cast(nlev())); @@ -908,171 +1134,6 @@ class AmrProgramContext : public ProgramExecutionServices { return CaptureFluxScratchLease(*capture_flux_scratch_[index]); } - MultiFab& stage_state_scratch_for_(int program_block, int level, - const MultiFab& prototype) const { - const std::pair key{program_block, level}; - auto insertion = stage_state_scratch_.try_emplace(key, prototype.box_array(), prototype.dmap(), - prototype.ncomp(), prototype.n_grow()); - MultiFab& scratch = insertion.first->second; - const bool compatible = scratch.box_array().boxes() == prototype.box_array().boxes() && - scratch.dmap().ranks() == prototype.dmap().ranks() && - scratch.ncomp() == prototype.ncomp() && - scratch.n_grow() == prototype.n_grow(); - if (!compatible) - scratch = - MultiFab(prototype.box_array(), prototype.dmap(), prototype.ncomp(), prototype.n_grow()); - return scratch; - } - - struct GeneratedFieldSolveWorkspace { - std::string field_identity; - std::vector program_to_system; - std::vector program_stages; - std::vector expected_program_blocks; - bool expected_program_blocks_initialized = false; - }; - - const std::vector& generated_field_solve_stages_( - std::int64_t value_id, std::string_view field, - std::initializer_list overrides) const { - if (value_id < 0) - throw std::invalid_argument( - "generated AMR simultaneous field solve requires a non-negative IR identity"); - if (field.empty()) - throw std::invalid_argument( - "generated AMR simultaneous field solve requires a field identity"); - if (overrides.size() == 0) - throw std::invalid_argument( - "generated AMR simultaneous field solve requires at least one stage override"); - - auto [entry, inserted] = generated_field_solve_workspaces_.try_emplace(value_id); - GeneratedFieldSolveWorkspace& workspace = entry->second; - if (inserted) - workspace.field_identity.assign(field.data(), field.size()); - else if (std::string_view(workspace.field_identity) != field) - throw std::logic_error( - "generated AMR simultaneous field solve IR identity was reused for a different field"); - - const std::vector& block_map = facade_->program_block_map(); - if (block_map.empty()) - throw block_map_error_( - "AmrProgramContext::solve_fields_from_blocks: no explicit program-to-AMR block map is " - "installed; positional block identity is not supported"); - bool structure_matches = - workspace.program_to_system.size() == block_map.size() && - workspace.program_stages.size() == static_cast(n_blocks()); - for (std::size_t p = 0; structure_matches && p < block_map.size(); ++p) - structure_matches = workspace.program_to_system[p] == sys_block(static_cast(p)); - if (!structure_matches) { - workspace.program_to_system.resize(block_map.size()); - for (std::size_t p = 0; p < block_map.size(); ++p) - workspace.program_to_system[p] = sys_block(static_cast(p)); - workspace.program_stages.assign(static_cast(n_blocks()), nullptr); - workspace.expected_program_blocks.clear(); - workspace.expected_program_blocks_initialized = false; - } - - const bool learn_blocks = !workspace.expected_program_blocks_initialized; - if (learn_blocks) { - workspace.expected_program_blocks.clear(); - workspace.expected_program_blocks.reserve(overrides.size()); - } else if (workspace.expected_program_blocks.size() != overrides.size()) { - throw std::logic_error( - "generated AMR simultaneous field solve IR identity changed its block pack"); - } - std::fill(workspace.program_stages.begin(), workspace.program_stages.end(), nullptr); - std::size_t ordinal = 0; - for (const FieldStageOverride& override_value : overrides) { - if (override_value.program_block < 0 || - static_cast(override_value.program_block) >= block_map.size()) - throw std::out_of_range( - "generated AMR simultaneous field solve Program block is out of range"); - if (override_value.state == nullptr) - throw std::invalid_argument( - "generated AMR simultaneous field solve stage override cannot be null"); - const std::size_t slot = static_cast(override_value.program_block); - if (workspace.program_stages[slot] != nullptr) - throw std::invalid_argument( - "generated AMR simultaneous field solve contains a duplicate Program block"); - if (learn_blocks) - workspace.expected_program_blocks.push_back(override_value.program_block); - else if (workspace.expected_program_blocks[ordinal] != override_value.program_block) - throw std::logic_error( - "generated AMR simultaneous field solve IR identity changed its ordered block pack"); - const MultiFab& live = state(override_value.program_block); - const MultiFab& stage = *override_value.state; - if (stage.box_array().boxes() != live.box_array().boxes() || - stage.dmap().ranks() != live.dmap().ranks() || stage.ncomp() != live.ncomp() || - stage.n_grow() != live.n_grow()) - throw std::invalid_argument( - "generated AMR simultaneous field solve stage does not match its exact level layout"); - for (std::size_t other = 0; other < block_map.size(); ++other) { - if (other != slot && &stage == &state(static_cast(other))) - throw std::invalid_argument( - "generated AMR simultaneous field solve cannot use another block's live state as a " - "stage override"); - } - workspace.program_stages[slot] = override_value.state; - ++ordinal; - } - workspace.expected_program_blocks_initialized = true; - return workspace.program_stages; - } - - struct ProgramScratchKey { - ScratchKind kind = ScratchKind::Rhs; - std::int64_t value_id = -1; - int subslot = -1; - int level = -1; - - friend bool operator<(const ProgramScratchKey& lhs, const ProgramScratchKey& rhs) noexcept { - if (lhs.kind != rhs.kind) - return lhs.kind < rhs.kind; - if (lhs.value_id != rhs.value_id) - return lhs.value_id < rhs.value_id; - if (lhs.subslot != rhs.subslot) - return lhs.subslot < rhs.subslot; - return lhs.level < rhs.level; - } - }; - - struct ProgramScratchSlot { - MultiFab field; - std::uint64_t materialization_generation = std::numeric_limits::max(); - }; - - /// Provider-owned by design: a regrid, restart materialization or rejected-attempt rollback can - /// preserve the checkpointed epoch while replacing every hierarchy allocation. The shared - /// service therefore selects scratch semantically, but only this AMR storage provider may - /// authenticate and invalidate slots against both topology epoch and materialization generation. - MultiFab& program_scratch_for_(ScratchKind kind, std::int64_t value_id, int subslot, - const MultiFab& prototype, int n_comp, int n_ghost) const { - if (value_id < 0 || subslot < 0) - throw std::invalid_argument( - "AMR Program persistent scratch requires non-negative IR value and sub-slot identities"); - if (level_ < 0 || level_ >= nlev()) - throw std::out_of_range("AMR Program persistent scratch level is out of range"); - const std::uint64_t topology_epoch = eng_->topology_epoch(); - const std::uint64_t generation = eng_->topology_materialization_generation(); - if (program_scratch_topology_epoch_ != topology_epoch || - program_scratch_materialization_generation_ != generation) { - program_scratch_.clear(); - program_scratch_topology_epoch_ = topology_epoch; - program_scratch_materialization_generation_ = generation; - } - const ProgramScratchKey key{kind, value_id, subslot, level_}; - auto [entry, inserted] = program_scratch_.try_emplace(key); - ProgramScratchSlot& slot = entry->second; - if (inserted || slot.materialization_generation != generation || - !field_layout_matches_(slot.field, prototype, n_comp, n_ghost)) { - slot.field = MultiFab(prototype.box_array(), prototype.dmap(), n_comp, n_ghost); - slot.materialization_generation = generation; - count_scratch(slot.field); - } - slot.field.set_val(Real(0)); - return slot.field; - } - /// Fail loud for an op the codegen can emit but the installed AMR Program path does not wire (named-flux / /// scheduled Programs). [[noreturn]] so a non-void stub needs no dummy return -- the caller's signature /// stays byte-faithful to ProgramContext (the duck-typing requirement) without fabricating a value. @p @@ -1112,7 +1173,8 @@ class AmrProgramContext : public ProgramExecutionServices { amr::ClockStamp sync_clock = accepted; sync_clock.level = parent; for (int b = 0; b < n_blocks(); ++b) { - const std::size_t sb = static_cast(sys_block(b)); + const int runtime_block = sys_block(b); + const std::size_t sb = static_cast(runtime_block); if (capturing()) { sync_report_.push_back({parent, child, b, SyncPhase::Reflux, sync_clock}); const EdgeFlux coarse_role = reflux_flux_from_ledger_(b, parent, ledger_begin, ledger_end); @@ -1120,8 +1182,26 @@ class AmrProgramContext : public ProgramExecutionServices { if (coarse_role.empty() != fine_role.empty()) throw std::runtime_error( "AMR conservative ledger contains only one side of a parent/child flux pair"); - if (!coarse_role.empty()) - pops::detail::route_reflux_program(*eng_, sb, child, coarse_role, fine_role); + const bool capture_balance = + facade_->program_runtime_state_().automatic_balance_capture_due(); + std::vector integrated_reflux; + if (!coarse_role.empty()) { + pops::detail::route_reflux_program(*eng_, sb, child, coarse_role, fine_role, sync_clock, + capture_balance ? &integrated_reflux : nullptr); + } else if (capture_balance) { + integrated_reflux.assign(static_cast(eng_->level_state(sb, parent).ncomp()), + Real(0)); + } + if (capture_balance) { + const int components = eng_->level_state(sb, parent).ncomp(); + if (integrated_reflux.size() != static_cast(components)) + throw std::runtime_error( + "AMR automatic reflux balance contribution changed component width"); + for (int component = 0; component < components; ++component) + facade_->program_runtime_state_().record_automatic_balance_term( + runtime_block, parent, component, "reflux", + integrated_reflux[static_cast(component)], "AmrProgramContext"); + } } sync_report_.push_back({parent, child, b, SyncPhase::AverageDown, sync_clock}); eng_->average_down_level(sb, child); @@ -1163,7 +1243,7 @@ class AmrProgramContext : public ProgramExecutionServices { // Keep rate_id separate: it remains the conservative-ledger/provenance identity for this // individual residual even when the prepared boundary registry observes the atomic group point. const auto boundary_point = - grouped_point == nullptr ? boundary_point_(rate_id) : *grouped_point; + grouped_point == nullptr ? program_execution_boundary_point_(rate_id) : *grouped_point; if (active_parent_ && active_parent_->child_level == level_) { const amr::Rational target_phase = active_parent_->child_window.begin.phase + stage_time_ * (active_parent_->child_window.end.phase - @@ -1242,7 +1322,7 @@ class AmrProgramContext : public ProgramExecutionServices { template void advance_attempt_(double dt, const char* operation, CouplingSchedule coupling_schedule, - Advance&& advance) const { + std::string_view prepared_provider_identity, Advance&& advance) const { if (!(dt > 0.0)) throw std::invalid_argument(std::string(operation) + " requires dt > 0"); if (attempt_snapshot_active_) @@ -1278,6 +1358,10 @@ class AmrProgramContext : public ProgramExecutionServices { AttemptSnapshot& saved = attempt_snapshot_; capture_engine_attempt_snapshot_(saved, borrows_facade_snapshot); import_program_accepted_state_(); + // The hierarchy-global Program body has no prepared cell-local stage/space-time-flux provider. + // Authenticate that absence explicitly: a cell-local checkpoint must use the dedicated batched + // executor and cannot fall through here before the Program body or any published clock mutates. + require_temporal_execution_route_(prepared_provider_identity); capture_program_attempt_snapshot_(saved); conservative_ledger_.begin(); try { @@ -1382,6 +1466,16 @@ class AmrProgramContext : public ProgramExecutionServices { } void validate_program_accepted_state_(const AmrProgramAcceptedState& state) const { + validate_cell_temporal_partition_state(state.temporal_partition); + if (state.temporal_partition.kind == TemporalPartitionKind::CellLocal) { + if (state.temporal_partition.topology_epoch != eng_->topology_epoch()) + throw std::runtime_error( + "AMR Program cell-local temporal partition targets another topology epoch"); + for (const CellTemporalPartitionRecord& cell : state.temporal_partition.cells) + if (cell.level >= nlev()) + throw std::runtime_error( + "AMR Program cell-local temporal partition targets an inactive level"); + } if (state.level_clocks.size() != static_cast(nlev())) throw std::runtime_error( "AMR Program accepted state does not match the restored hierarchy level count"); @@ -1392,6 +1486,14 @@ class AmrProgramContext : public ProgramExecutionServices { throw std::runtime_error( "AMR Program accepted state contains a non-accepted or misqualified level clock"); } + if (state.temporal_partition.kind == TemporalPartitionKind::CellLocal) { + const std::int64_t level_tick = exact_physical_tick_( + state.level_clocks.front().physical_time, state.temporal_partition.tick_denominator, + "AMR Program cell-local accepted level clock"); + if (level_tick != state.temporal_partition.synchronization_tick) + throw std::runtime_error( + "AMR Program cell-local partition tick differs from its accepted level clock"); + } const std::int64_t accepted_step = state.level_clocks.empty() ? macro_step() : state.level_clocks.front().macro_step; if (state.logical_clock_ticks != clock_schedule_.accepted_ticks(accepted_step)) @@ -1507,6 +1609,7 @@ class AmrProgramContext : public ProgramExecutionServices { const std::int64_t accepted_step = level_clocks_.empty() ? macro_step() : level_clocks_.front().macro_step; state.logical_clock_ticks = clock_schedule_.accepted_ticks(accepted_step); + state.temporal_partition = temporal_partition_checkpoint_(); state.tagging_hysteresis_state = eng_->checkpoint_tagging_state(); state.history_owners = history_owners_; state.history_states = history_state_ids_; @@ -1561,6 +1664,7 @@ class AmrProgramContext : public ProgramExecutionServices { const std::int64_t accepted_step = level_clocks_.empty() ? macro_step() : level_clocks_.front().macro_step; clock_schedule_.restore_accepted_ticks(state.logical_clock_ticks, accepted_step); + restore_temporal_execution_route_(state.temporal_partition); history_owners_ = std::move(state.history_owners); history_state_ids_ = std::move(state.history_states); history_space_ids_ = std::move(state.history_spaces); @@ -1575,6 +1679,9 @@ class AmrProgramContext : public ProgramExecutionServices { accepted_flux_report_ = std::move(state.accepted_flux_ledger); accepted_interface_flux_report_ = std::move(state.accepted_interface_flux_ledger); accepted_sync_report_ = std::move(state.accepted_sync); + if (same_level_cell_temporal_ledger_ && same_level_cell_temporal_executor_) + same_level_cell_temporal_ledger_->invalidate_accepted_publication( + state.temporal_partition.synchronization_tick, state.temporal_partition.tick_denominator); // Commit the already authenticated runtime-owned payload last. No throwing operation follows // this point, so a rejected decode/qualification leaves the previously accepted state intact. eng_->commit_checkpoint_tagging_state(std::move(tagging_state)); @@ -1593,6 +1700,17 @@ class AmrProgramContext : public ProgramExecutionServices { throw std::logic_error("AMR RegridOnRestart requires a clean accepted Program boundary"); } + static void require_collective_rebalance_program_success_(const std::exception_ptr& local_failure, + const char* context) { + const long failure_count = + n_ranks() > 1 ? all_reduce_sum(local_failure ? 1L : 0L) : (local_failure ? 1L : 0L); + if (failure_count == 0) + return; + if (local_failure) + std::rethrow_exception(local_failure); + throw std::runtime_error(std::string(context) + " failed on another MPI rank"); + } + /// Validate every rank-local prerequisite before peers enter the native scientific regrid. /// Importing the accepted Program image is rollback-safe and local; one explicit status reduction /// closes that phase before the runtime enters its topology-registry collective preflights. @@ -1601,6 +1719,7 @@ class AmrProgramContext : public ProgramExecutionServices { try { require_restart_regrid_boundary_(); import_program_accepted_state_(true); + require_regrid_rematerializable_temporal_partition(temporal_partition_checkpoint_()); const std::int64_t accepted_step = macro_step(); const double accepted_time = facade_->time(); if (accepted_step < 0 || accepted_step > std::numeric_limits::max() || @@ -1705,6 +1824,9 @@ class AmrProgramContext : public ProgramExecutionServices { std::uint64_t engine_topology_generation = 0; std::vector program_accepted_state; std::uint64_t program_accepted_state_revision = 0; + CellTemporalPartitionAcceptedState temporal_partition; + SameLevelCellIntegratedFluxLedgerAcceptedState same_level_cell_temporal_ledger; + bool same_level_cell_temporal_ledger_captured = false; std::set active_flux; std::map flux; std::map> flux_contributions; @@ -1732,8 +1854,6 @@ class AmrProgramContext : public ProgramExecutionServices { ClockScheduleState clock_schedule; std::vector live_state_rings; bool rotate_pending = false; - std::optional default_solve_report; - std::map named_solve_reports; int level = 0; amr::Rational stage_time{0, 1}; std::optional active_parent; @@ -1928,6 +2048,12 @@ class AmrProgramContext : public ProgramExecutionServices { throw std::logic_error( "AMR Program accepted state changed while capturing an attempt snapshot"); snapshot.program_accepted_state_revision = accepted_revision; + snapshot.temporal_partition = temporal_partition_checkpoint_(); + snapshot.same_level_cell_temporal_ledger_captured = + static_cast(same_level_cell_temporal_ledger_); + if (same_level_cell_temporal_ledger_) + same_level_cell_temporal_ledger_->copy_accepted_state_into( + snapshot.same_level_cell_temporal_ledger); copy_set_in_place_(snapshot.active_flux, active_flux_ledger_); for (auto entry = snapshot.flux.begin(); entry != snapshot.flux.end();) { @@ -1989,8 +2115,6 @@ class AmrProgramContext : public ProgramExecutionServices { clock_schedule_.copy_into(snapshot.clock_schedule); copy_vector_values_in_place_(snapshot.live_state_rings, live_state_rings_); snapshot.rotate_pending = rotate_pending_; - snapshot.default_solve_report = default_solve_report_; - copy_map_values_in_place_(snapshot.named_solve_reports, named_solve_reports_); snapshot.level = level_; snapshot.stage_time = stage_time_; snapshot.active_parent = active_parent_; @@ -2039,14 +2163,20 @@ class AmrProgramContext : public ProgramExecutionServices { snapshot.clock_schedule.copy_into(clock_schedule_); copy_vector_values_in_place_(live_state_rings_, snapshot.live_state_rings); rotate_pending_ = snapshot.rotate_pending; - default_solve_report_ = snapshot.default_solve_report; - copy_map_values_in_place_(named_solve_reports_, snapshot.named_solve_reports); level_ = snapshot.level; stage_time_ = snapshot.stage_time; active_parent_ = snapshot.active_parent; current_window_ = snapshot.window; current_sync_clock_ = snapshot.sync_clock; current_level_dt_ = snapshot.level_dt; + rollback_temporal_execution_route_(); + restore_temporal_execution_route_(snapshot.temporal_partition); + if (snapshot.same_level_cell_temporal_ledger_captured) { + if (!same_level_cell_temporal_ledger_) + throw std::logic_error("cell-local AMR Program rollback lost its prepared flux ledger"); + same_level_cell_temporal_ledger_->restore_accepted_state( + snapshot.same_level_cell_temporal_ledger); + } } template @@ -2387,7 +2517,7 @@ class AmrProgramContext : public ProgramExecutionServices { false}; } - runtime::multiblock::BoundaryEvaluationPoint boundary_point_(int stage) const { + runtime::multiblock::BoundaryEvaluationPoint program_execution_boundary_point_(int stage) const { require_rate_identity_(stage); if (primary_clock_.empty() || !current_window_ || !std::isfinite(current_level_dt_) || current_level_dt_ <= 0.0) @@ -3062,10 +3192,6 @@ class AmrProgramContext : public ProgramExecutionServices { facade_->install_program_step(std::move(step)); } - runtime::multiblock::BoundaryEvaluationPoint program_execution_boundary_point_( - int stage_id) const { - return boundary_point_(stage_id); - } void program_execution_rhs_into_(int program_block, int runtime_block, MultiFab& state, MultiFab& rhs, int rate_id) const { if (capturing()) { @@ -3073,7 +3199,7 @@ class AmrProgramContext : public ProgramExecutionServices { return; } eng_->level_rhs_into_at(static_cast(runtime_block), level_, - boundary_point_(rate_id), state, rhs); + program_execution_boundary_point_(rate_id), state, rhs); } bool program_execution_has_boundary_linearization_(int runtime_block) const { return eng_->has_boundary_linearization(static_cast(runtime_block)); @@ -3093,6 +3219,19 @@ class AmrProgramContext : public ProgramExecutionServices { eng_->level_rhs_core_into_at(static_cast(runtime_block), point.level, point, state, rhs, flux_only, *boundary); } + void program_execution_rhs_jacvec_pair_into_at_( + const runtime::multiblock::BoundaryEvaluationPoint& point, + int first_runtime_block, MultiFab& first_state, MultiFab& first_rhs, + bool first_flux_only, int second_runtime_block, MultiFab& second_state, + MultiFab& second_rhs, bool second_flux_only) const { + if (point.level != level_) + throw std::runtime_error( + "AMR Program implicit interface JVP point differs from its active level"); + eng_->level_rhs_jacvec_pair( + level_, point, static_cast(first_runtime_block), first_state, first_rhs, + first_flux_only, static_cast(second_runtime_block), second_state, second_rhs, + second_flux_only); + } void program_execution_boundary_residual_into_at_( const runtime::multiblock::BoundaryEvaluationPoint& point, int runtime_block, MultiFab& state, MultiFab& residual, const PreparedGridBoundarySession* boundary) const { @@ -3122,7 +3261,7 @@ class AmrProgramContext : public ProgramExecutionServices { return; } eng_->level_neg_div_flux_into_at(static_cast(runtime_block), level_, - boundary_point_(rate_id), state, rhs); + program_execution_boundary_point_(rate_id), state, rhs); } [[noreturn]] void program_execution_neg_div_named_flux_into_( MultiFab& /*rhs*/, MultiFab& /*flux_x*/, MultiFab& /*flux_y*/, @@ -3137,7 +3276,7 @@ class AmrProgramContext : public ProgramExecutionServices { const bool has_interfaces = eng_->has_level_interfaces(level_); if (has_interfaces) register_interface_flux_group_(batch.group_id, batch.runtime_blocks, batch.rate_ids); - const auto group_point = boundary_point_(batch.group_id); + const auto group_point = program_execution_boundary_point_(batch.group_id); eng_->with_boundary_stage_states(group_point, batch.runtime_blocks, batch.states, [&] { for (std::size_t index = 0; index < batch.states.size(); ++index) { const auto& request = batch.requests.begin()[index]; @@ -3156,8 +3295,8 @@ class AmrProgramContext : public ProgramExecutionServices { return; } count_kernel(static_cast(batch.requests.size())); - eng_->level_rhs_group(level_, boundary_point_(batch.group_id), batch.runtime_blocks, - batch.states, batch.rhs, batch.flux_only); + eng_->level_rhs_group(level_, program_execution_boundary_point_(batch.group_id), + batch.runtime_blocks, batch.states, batch.rhs, batch.flux_only); } void program_execution_source_default_into_(int runtime_block, MultiFab& state, MultiFab& rhs) const { @@ -3166,6 +3305,45 @@ class AmrProgramContext : public ProgramExecutionServices { void program_execution_apply_projection_(int runtime_block, MultiFab& state) const { eng_->project_level_state(static_cast(runtime_block), level_, state); } + std::optional> program_execution_projection_balance_integrals_( + int runtime_block_value, const MultiFab& state) const { + const std::size_t runtime_block = static_cast(runtime_block_value); + if (level_ < 0 || level_ >= nlev()) + throw std::out_of_range("AMR Program projection balance active level is out of range"); + const MultiFab& live = eng_->level_state(runtime_block, level_); + if (state.box_array().boxes() != live.box_array().boxes() || + state.dmap().ranks() != live.dmap().ranks() || state.ncomp() != live.ncomp() || + state.n_grow() != live.n_grow() || state.local_size() != live.local_size()) + throw std::invalid_argument( + "AMR Program projection balance candidate changed its exact level layout"); + + std::vector views; + views.reserve(static_cast(nlev())); + for (int level = 0; level < nlev(); ++level) { + const Geometry geometry = eng_->level_geom(level); + const MultiFab* values = level == level_ ? &state : &eng_->level_state(runtime_block, level); + views.push_back({values, geometry.dx(), geometry.dy()}); + } + const int next = level_ + 1 < nlev() ? level_ + 1 : -1; + MultiFab mask = pops::runtime::amr::composite_detail::active_mask(views, level_, next); + std::vector result(static_cast(state.ncomp()), 0.0); + for (int component = 0; component < state.ncomp(); ++component) + result[static_cast(component)] = + static_cast(pops::runtime::amr::composite_detail::local_sum( + state, mask, component, pops::runtime::amr::composite_detail::CompositeSumKind::Sum)); + if (!eng_->level_is_replicated(level_)) + all_reduce_sum_inplace(result.data(), result.size()); + const Geometry geometry = eng_->level_geom(level_); + const double cell_measure = + static_cast(geometry.dx()) * static_cast(geometry.dy()); + if (!std::isfinite(cell_measure) || cell_measure <= 0.0) + throw std::runtime_error( + "AMR Program projection balance requires a positive finite cell measure"); + std::vector integrated(result.size(), Real(0)); + for (std::size_t component = 0; component < result.size(); ++component) + integrated[component] = static_cast(cell_measure * result[component]); + return integrated; + } Real program_execution_hmin_() const { return eng_->level_hmin(level_); } Real program_execution_max_wave_speed_(int runtime_block, const MultiFab& state) const { return eng_->level_max_speed(static_cast(runtime_block), level_, state); @@ -3189,7 +3367,7 @@ class AmrProgramContext : public ProgramExecutionServices { const Geometry geometry = eng_->level_geom(level_); GridContext context; context.dom = geometry.domain; - context.bc = eng_->transport_bc(); + context.bc = eng_->default_boundary_descriptor(); context.geom = geometry; context.aux = &const_cast(eng_->aux(level_)); return context; @@ -3275,8 +3453,8 @@ class AmrProgramContext : public ProgramExecutionServices { const HistoryRegistration& registration) const { return pops::detail::AmrHistoryOps::initialized(*eng_, registration.name); } - double program_execution_history_slot_dt_storage_( - const HistoryRegistration& registration, int lag) const { + double program_execution_history_slot_dt_storage_(const HistoryRegistration& registration, + int lag) const { return pops::detail::AmrHistoryOps::slot_dt(*eng_, registration.name, lag); } void program_execution_set_history_initialized_storage_(const HistoryRegistration& registration, @@ -3436,51 +3614,42 @@ class AmrProgramContext : public ProgramExecutionServices { current_level_dt_ = rollback.parent_dt; stage_time_ = rollback.stage; } - SolveReport program_execution_solve_fields_from_state_at_( - const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& provider_slot, - int block, MultiFab& state) const { - return consume_field_outcome_(solve_fields_from_state_at(point, provider_slot, block, state)); - } - MultiFab& program_execution_scratch_(ScratchKind kind, std::int64_t value_id, int subslot, - const MultiFab& prototype, int n_comp, int n_ghost) const { - return program_scratch_for_(kind, value_id, subslot, prototype, n_comp, n_ghost); - } void program_execution_validate_commit_aliases_(bool has_aliased_source) const { if (has_aliased_source && capturing()) throw std::invalid_argument( "AmrProgramContext::commit_many aliased target/source requires a flat hierarchy; " "materialize an explicit provisional state before a conservative multi-level commit"); } + void program_execution_validate_commit_candidates_( + std::initializer_list> commits) const { + for (const auto& [target, candidate] : commits) + for (std::size_t block = 0; block < eng_->n_blocks(); ++block) + if (target == &eng_->level_state(block, level_)) { + eng_->require_recoverable_block_candidate_( + block, *candidate, + "AMR Program terminal state publication for runtime block " + + std::to_string(block) + " level " + std::to_string(level_)); + break; + } + } ProgramRuntimeState& program_execution_runtime_state_() const { return facade_->program_runtime_state_(); } ProgramClockCoordinate program_execution_clock_coordinate_() const { return {static_cast(facade_->time()), facade_->macro_step(), level_}; } - void program_execution_set_field_timepoint_(const std::string& field, - const FieldLogicalTimePoint& point) const { - facade_->set_field_logical_timepoint(field, point); + void program_execution_record_balance_term_(const std::string& route, const std::string& term, + Real value) const { + facade_->record_program_balance_term(route, term, value); } - void program_execution_set_field_parameters_(const std::string& field, - const std::vector& parameters) const { - facade_->set_field_boundary_parameters(field, parameters); - } - void program_execution_set_field_kernel_(const std::string& field, - const CompiledFieldBoundaryKernel& kernel) const { - facade_->set_field_boundary_kernel(field, kernel); + bool program_execution_balance_consumer_is_due_(const std::string& contract, + const std::string& route, int every_n) const { + return facade_->program_balance_consumer_is_due(contract, route, every_n); } + AmrSystem& program_execution_field_facade_() const { return *facade_; } AmrSystem* facade_; AmrRuntime* eng_; mutable int level_ = 0; - mutable std::optional default_solve_report_; - mutable std::map named_solve_reports_; - mutable bool named_field_solve_in_use_ = false; - mutable std::map, MultiFab> stage_state_scratch_; - mutable std::map generated_field_solve_workspaces_; - mutable std::map program_scratch_; - mutable std::uint64_t program_scratch_topology_epoch_ = std::numeric_limits::max(); - mutable std::uint64_t program_scratch_materialization_generation_ = - std::numeric_limits::max(); mutable std::vector> stage_restore_scratch_; // Eager, exact-layout face fields used by flux-materialising residuals. Indexed block-major by // [runtime block * capture_flux_scratch_levels_ + level]; never resized from a stage. @@ -3534,6 +3703,15 @@ class AmrProgramContext : public ProgramExecutionServices { mutable bool restart_regrid_prepared_ = false; mutable int automatic_regrid_macro_step_ = -1; mutable std::vector level_clocks_; + // Exactly one route is authoritative. The batched partition is the global/fail-closed fallback; + // once the installed artifact prepares the scientific provider, every checkpoint/attempt/rollback + // access is routed through this executor and the fallback is unreachable. + mutable BatchedCellTemporalPartition temporal_partition_; + mutable std::unique_ptr same_level_cell_temporal_executor_; + mutable std::shared_ptr same_level_cell_temporal_ledger_; + mutable std::string same_level_cell_temporal_clock_identity_; + mutable std::int64_t same_level_cell_temporal_tick_denominator_ = 1; + mutable int same_level_cell_temporal_rung_ = 0; mutable std::uint64_t accepted_state_revision_ = 0; mutable std::uint64_t operator_topology_revision_counter_ = 0; mutable std::uint64_t observed_operator_topology_epoch_ = diff --git a/include/pops/runtime/program/cell_temporal_partition.hpp b/include/pops/runtime/program/cell_temporal_partition.hpp new file mode 100644 index 000000000..937546713 --- /dev/null +++ b/include/pops/runtime/program/cell_temporal_partition.hpp @@ -0,0 +1,260 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::runtime::program { + +inline constexpr const char* kGlobalTemporalPartitionProvider = "pops.temporal-partition.global@1"; + +enum class TemporalPartitionKind : std::uint8_t { Global = 0, CellLocal = 1 }; + +/// Rank-independent identity and accepted logical clock of one cell. +/// +/// ``cell`` is a provider-owned canonical cell id within ``level``. It deliberately does not carry +/// an MPI rank or patch-local address, so ownership migration can rematerialize device storage from +/// the same accepted scientific image. +struct CellTemporalPartitionRecord { + int level = 0; + std::uint64_t cell = 0; + int rung = 0; + std::int64_t accepted_tick = 0; + + friend bool operator==(const CellTemporalPartitionRecord&, + const CellTemporalPartitionRecord&) = default; +}; + +/// Compact accepted-boundary image for a prepared temporal-partition provider. +/// +/// Physical time is represented by integer ticks over one provider-owned denominator. Accepted +/// checkpoints are synchronization barriers: every record must be at ``synchronization_tick``. +/// Attempt-local clocks live in ``BatchedCellTemporalPartition`` and can never leak into this image. +struct CellTemporalPartitionAcceptedState { + TemporalPartitionKind kind = TemporalPartitionKind::Global; + std::string provider_identity = kGlobalTemporalPartitionProvider; + std::uint64_t topology_epoch = 0; + std::int64_t synchronization_tick = 0; + std::int64_t tick_denominator = 1; + std::vector cells; + + friend bool operator==(const CellTemporalPartitionAcceptedState&, + const CellTemporalPartitionAcceptedState&) = default; +}; + +inline void validate_cell_temporal_partition_state( + const CellTemporalPartitionAcceptedState& state) { + if (state.provider_identity.empty()) + throw std::invalid_argument("temporal partition provider identity cannot be empty"); + if (state.synchronization_tick < 0 || state.tick_denominator <= 0) + throw std::invalid_argument( + "temporal partition accepted clock requires non-negative ticks and a positive denominator"); + if (state.kind == TemporalPartitionKind::Global) { + if (state.provider_identity != kGlobalTemporalPartitionProvider || state.topology_epoch != 0 || + !state.cells.empty()) + throw std::invalid_argument( + "global temporal partition state cannot carry topology or cell-local clocks"); + return; + } + if (state.kind != TemporalPartitionKind::CellLocal) + throw std::invalid_argument("temporal partition state has an unsupported kind"); + if (state.provider_identity == kGlobalTemporalPartitionProvider || state.cells.empty()) + throw std::invalid_argument( + "cell-local temporal partition state requires its prepared provider and cell clocks"); + + std::tuple previous{-1, 0}; + bool first = true; + for (const CellTemporalPartitionRecord& cell : state.cells) { + if (cell.level < 0 || cell.rung < 0 || cell.rung > 30 || cell.accepted_tick < 0) + throw std::invalid_argument("temporal partition cell record is outside its bounded domain"); + const std::tuple identity{cell.level, cell.cell}; + if (!first && !(previous < identity)) + throw std::invalid_argument( + "temporal partition cell records must be unique and canonically ordered"); + if (cell.accepted_tick != state.synchronization_tick) + throw std::invalid_argument( + "temporal partition accepted checkpoint is not at a synchronization barrier"); + const std::int64_t stride = std::int64_t{1} << cell.rung; + if (cell.accepted_tick % stride != 0) + throw std::invalid_argument( + "temporal partition accepted tick is not aligned to its cell rung"); + previous = identity; + first = false; + } +} + +/// Require a temporal partition whose topology-bound execution resources can be rebuilt after a +/// scientific restart regrid. Global schedules carry no cell/storage identity. Cell-local schedules +/// additionally own a prepared stage provider and integrated flux ledger; until those accepted +/// resources have a versioned rematerialization contract, changing the hierarchy must fail before +/// the first native mutation. +inline void require_regrid_rematerializable_temporal_partition( + const CellTemporalPartitionAcceptedState& state) { + validate_cell_temporal_partition_state(state); + if (state.kind == TemporalPartitionKind::CellLocal) + throw std::runtime_error( + "AMR RegridOnRestart does not yet support cell-local temporal partitions; restore the " + "recorded hierarchy until the stage provider and integrated flux ledger can be " + "rematerialized"); +} + +/// Host authority for one bounded batch schedule and its transactional clocks. +/// +/// This class owns no numerical field and launches no per-cell task. A future Kokkos execution +/// provider consumes the canonical records in rung batches; this authority supplies exact attempt, +/// rollback, barrier, checkpoint and diagnostic semantics independently of the execution space. +class BatchedCellTemporalPartition { + public: + explicit BatchedCellTemporalPartition( + CellTemporalPartitionAcceptedState accepted = CellTemporalPartitionAcceptedState{}) + : accepted_(std::move(accepted)) { + validate_cell_temporal_partition_state(accepted_); + pending_ticks_.reserve(accepted_.cells.size()); + } + + const CellTemporalPartitionAcceptedState& accepted_state() const noexcept { return accepted_; } + bool attempt_active() const noexcept { return attempt_active_; } + + void begin_attempt(std::int64_t target_tick) { + if (attempt_active_) + throw std::logic_error("temporal partition attempt is already active"); + if (target_tick <= accepted_.synchronization_tick) + throw std::invalid_argument("temporal partition attempt target must advance accepted time"); + pending_ticks_.clear(); + pending_ticks_.reserve(accepted_.cells.size()); + for (const CellTemporalPartitionRecord& cell : accepted_.cells) { + const std::int64_t stride = std::int64_t{1} << cell.rung; + if ((target_tick - cell.accepted_tick) % stride != 0) + throw std::invalid_argument( + "temporal partition attempt target is unreachable for one prepared rung"); + pending_ticks_.push_back(cell.accepted_tick); + } + target_tick_ = target_tick; + attempt_active_ = true; + } + + /// Advance a canonically ordered batch of record indices belonging to exactly one rung. + void advance_batch(int rung, const std::vector& indices, std::int64_t target_tick) { + if (!attempt_active_) + throw std::logic_error("temporal partition batch requires an active attempt"); + if (indices.empty()) + throw std::invalid_argument("temporal partition batch cannot be empty"); + if (target_tick > target_tick_) + throw std::invalid_argument("temporal partition batch crosses its synchronization target"); + std::size_t previous = std::numeric_limits::max(); + for (std::size_t index : indices) { + if (index >= accepted_.cells.size() || + (previous != std::numeric_limits::max() && index <= previous)) + throw std::invalid_argument( + "temporal partition batch indices must be unique and canonically ordered"); + const CellTemporalPartitionRecord& cell = accepted_.cells[index]; + if (cell.rung != rung) + throw std::invalid_argument("temporal partition batch mixes prepared rungs"); + const std::int64_t stride = std::int64_t{1} << cell.rung; + if (target_tick <= pending_ticks_[index] || + (target_tick - pending_ticks_[index]) % stride != 0) + throw std::invalid_argument( + "temporal partition batch target is not a forward rung-aligned tick"); + previous = index; + } + for (std::size_t index : indices) + pending_ticks_[index] = target_tick; + } + + void require_barrier(const std::string& operation) const { + if (!attempt_active_) + return; + if (std::any_of(pending_ticks_.begin(), pending_ticks_.end(), + [this](std::int64_t tick) { return tick != target_tick_; })) + throw std::logic_error(operation + + " requires every cell-local clock at the synchronization barrier"); + } + + void commit() { + if (!attempt_active_) + throw std::logic_error("temporal partition commit requires an active attempt"); + require_barrier("temporal partition commit"); + for (std::size_t index = 0; index < accepted_.cells.size(); ++index) + accepted_.cells[index].accepted_tick = pending_ticks_[index]; + accepted_.synchronization_tick = target_tick_; + clear_attempt_(); + } + + void rollback() noexcept { clear_attempt_(); } + + CellTemporalPartitionAcceptedState checkpoint() const { + if (attempt_active_) + throw std::logic_error( + "temporal partition checkpoint requires an accepted synchronization barrier"); + return accepted_; + } + + void restore(CellTemporalPartitionAcceptedState accepted) { + if (attempt_active_) + throw std::logic_error("temporal partition restore cannot replace an active attempt"); + validate_cell_temporal_partition_state(accepted); + pending_ticks_.reserve(accepted.cells.size()); + accepted_ = std::move(accepted); + } + + /// Authenticate the execution provider selected for this accepted image. + /// + /// An empty identity denotes the hierarchy-global AMR driver. It is valid only for a global + /// partition. A cell-local image must instead name the exact prepared provider stored in its + /// checkpoint; callers cannot silently substitute the global driver or a different executor. + void require_prepared_execution_route(std::string_view prepared_provider_identity) const { + if (accepted_.kind == TemporalPartitionKind::Global) { + if (!prepared_provider_identity.empty()) + throw std::logic_error( + "global temporal partition cannot consume a cell-local prepared executor"); + return; + } + if (prepared_provider_identity.empty()) + throw std::logic_error( + "cell-local temporal partition requires a prepared local-stage and time-integrated " + "flux-ledger executor; the global AMR step cannot silently replace it"); + if (prepared_provider_identity != accepted_.provider_identity) + throw std::logic_error( + "cell-local temporal partition prepared-provider identity does not match its accepted " + "checkpoint"); + } + + void require_global_execution_route() const { require_prepared_execution_route({}); } + + std::vector> manifest() const { + std::map rung_counts; + for (const CellTemporalPartitionRecord& cell : accepted_.cells) + ++rung_counts[cell.rung]; + std::vector> rows; + rows.push_back( + {"summary", accepted_.kind == TemporalPartitionKind::Global ? "global" : "cell_local", + accepted_.provider_identity, std::to_string(accepted_.topology_epoch), + std::to_string(accepted_.synchronization_tick), std::to_string(accepted_.tick_denominator), + std::to_string(accepted_.cells.size())}); + for (const auto& [rung, count] : rung_counts) + rows.push_back({"rung", std::to_string(rung), std::to_string(count)}); + return rows; + } + + private: + void clear_attempt_() noexcept { + pending_ticks_.clear(); + target_tick_ = 0; + attempt_active_ = false; + } + + CellTemporalPartitionAcceptedState accepted_; + std::vector pending_ticks_; + std::int64_t target_tick_ = 0; + bool attempt_active_ = false; +}; + +} // namespace pops::runtime::program diff --git a/include/pops/runtime/program/cell_temporal_partition_executor.hpp b/include/pops/runtime/program/cell_temporal_partition_executor.hpp new file mode 100644 index 000000000..8c017814f --- /dev/null +++ b/include/pops/runtime/program/cell_temporal_partition_executor.hpp @@ -0,0 +1,567 @@ +#pragma once + +/// @file +/// @brief Prepared rung-batched execution for one transactional cell-local temporal partition. + +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#define POPS_CELL_TEMPORAL_INLINE_FUNCTION KOKKOS_INLINE_FUNCTION +#else +#define POPS_CELL_TEMPORAL_INLINE_FUNCTION inline +#endif + +namespace pops::runtime::program { + +/// Compile-time proof that one provider owns both operations required by a cell-local stage. +/// +/// The executor deliberately accepts no independent ``has_stage`` or ``has_ledger`` flags. A +/// provider must expose this exact tag and one combined device call, so a clock cannot advance after +/// evaluating a local stage without also invoking the provider-owned space-time flux transaction. +struct PreparedCellTemporalStageFluxContractV1 {}; + +struct CellTemporalAttemptDescriptor { + std::uint64_t topology_epoch = 0; + std::int64_t begin_tick = 0; + std::int64_t target_tick = 0; + std::int64_t tick_denominator = 1; + std::size_t cell_count = 0; +}; + +/// Exact local time passed to the prepared numerical provider. +struct CellTemporalStagePoint { + std::size_t record_index = 0; + int level = 0; + std::uint64_t cell = 0; + int rung = 0; + std::int64_t begin_tick = 0; + std::int64_t end_tick = 0; + std::int64_t tick_denominator = 1; +}; + +/// Host-side identity of one prepared same-rung launch. +/// +/// A numerical provider that needs a coherent read-only stage image (for example a finite-volume +/// residual assembled from neighbouring cells) may use this descriptor to materialize that image +/// before the combined per-cell stage/flux operation. It carries no rank-local pointers and is +/// therefore also part of the reviewable provider protocol rather than an executor side channel. +struct CellTemporalRungBatchDescriptor { + int rung = 0; + std::int64_t begin_tick = 0; + std::int64_t end_tick = 0; + std::int64_t tick_denominator = 1; + std::size_t cell_count = 0; +}; + +enum class CellTemporalStageDisposition : std::uint32_t { + Accepted = 0, + Rejected = 1, + Failed = 2, +}; + +/// Result of the combined local-stage and space-time-flux operation. +/// +/// ``Accepted`` means that the provider evaluated the stage at the exact rational time in +/// ``CellTemporalStagePoint`` and recorded its attempt-local, time-integrated interface flux. +/// Rejections and failures must carry a stable non-zero provider-owned reason code. +struct CellTemporalStageOutcome { + CellTemporalStageDisposition disposition = CellTemporalStageDisposition::Accepted; + std::uint32_t reason_code = 0; + + [[nodiscard]] POPS_CELL_TEMPORAL_INLINE_FUNCTION static constexpr CellTemporalStageOutcome + accepted() noexcept { + return {}; + } + [[nodiscard]] POPS_CELL_TEMPORAL_INLINE_FUNCTION static constexpr CellTemporalStageOutcome + rejected(std::uint32_t reason) noexcept { + return {CellTemporalStageDisposition::Rejected, reason}; + } + [[nodiscard]] POPS_CELL_TEMPORAL_INLINE_FUNCTION static constexpr CellTemporalStageOutcome failed( + std::uint32_t reason) noexcept { + return {CellTemporalStageDisposition::Failed, reason}; + } +}; + +class CellTemporalStageFailure : public std::runtime_error { + public: + CellTemporalStageFailure(CellTemporalStageDisposition disposition, std::uint32_t reason_code) + : std::runtime_error(message_(disposition, reason_code)), + disposition_(disposition), + reason_code_(reason_code) {} + + [[nodiscard]] CellTemporalStageDisposition disposition() const noexcept { return disposition_; } + [[nodiscard]] std::uint32_t reason_code() const noexcept { return reason_code_; } + + private: + static std::string message_(CellTemporalStageDisposition disposition, std::uint32_t reason_code) { + const char* kind = + disposition == CellTemporalStageDisposition::Rejected ? "rejected" : "failed"; + return "cell-local temporal stage " + std::string(kind) + " with provider reason code " + + std::to_string(reason_code); + } + + CellTemporalStageDisposition disposition_; + std::uint32_t reason_code_; +}; + +template +concept CellTemporalStageFluxDeviceView = + std::is_trivially_copyable_v && + requires(const DeviceView& view, CellTemporalStagePoint point) { + { + view.evaluate_local_stage_and_record_space_time_flux(point) + } noexcept -> std::same_as; + }; + +template +using CellTemporalStageFluxDeviceViewType = decltype(std::declval().device_view()); + +/// Host/device contract consumed by ``PreparedBatchedCellTemporalExecutor``. +/// +/// ``begin_attempt`` binds provider-owned scratch prepared before the hot rung loop. Device calls +/// may mutate only that scratch. ``prepare_commit_attempt`` re-authenticates every external +/// topology/storage authority after the final device fence and before accepted publication. +/// ``commit_attempt`` publishes only after that support decision and every local clock reaches the +/// barrier; ``rollback_attempt`` discards scratch after any rejection or exception. +template +concept CellTemporalStageFluxProvider = requires(Provider& provider, const Provider& const_provider, + ExactContractBuilder& contract, + CellTemporalAttemptDescriptor attempt) { + { Provider::provider_identity() } noexcept -> std::same_as; + { + Provider::stage_flux_contract() + } noexcept -> std::same_as; + { const_provider.serialize_exact_parameters(contract) } -> std::same_as; + { provider.begin_attempt(attempt) } noexcept -> std::same_as; + { provider.prepare_commit_attempt() } noexcept -> std::same_as; + { provider.commit_attempt() } noexcept -> std::same_as; + { provider.rollback_attempt() } noexcept -> std::same_as; + { const_provider.device_view() } noexcept; +} && CellTemporalStageFluxDeviceView>; + +/// Optional lifecycle for providers whose stage uses neighbouring cells. +/// +/// Both hooks are required together. ``begin_rung_batch`` may assemble a provider-owned immutable +/// stage image and may throw before the device launch. ``complete_rung_batch`` only rotates already +/// prepared attempt-local storage and must not publish accepted state. Publication remains solely in +/// ``commit_attempt`` after the synchronization barrier. +template +concept CellTemporalRungBatchLifecycle = + requires(Provider& provider, CellTemporalRungBatchDescriptor batch) { + { provider.begin_rung_batch(batch) } -> std::same_as; + { provider.complete_rung_batch(batch) } noexcept -> std::same_as; + }; + +/// Optional accepted-boundary resynchronization used when an outer Program transaction restores +/// native state and accepted checkpoint bytes after this executor had already committed locally. +/// +/// The executor validates the complete immutable prepared layout before invoking this hook. The +/// provider therefore only rebinds its accepted logical clock; it must not allocate, touch the live +/// numerical state or publish fluxes. +template +concept CellTemporalAcceptedBoundaryLifecycle = + requires(Provider& provider, const CellTemporalPartitionAcceptedState& accepted) { + { provider.restore_accepted_boundary(accepted) } noexcept -> std::same_as; + }; + +struct CellTemporalExecutionStats { + /// Number of combined stage/ledger kernels (or host batches without Kokkos), never per-cell. + std::uint64_t rung_batch_launches = 0; + std::uint64_t stage_evaluations = 0; +}; + +namespace cell_temporal_detail { + +inline std::string canonical_provider_identity(PreparedProviderIdentity identity) { + if (identity.name.empty() || identity.version == 0) + throw std::invalid_argument( + "cell-local temporal provider requires a non-empty name and non-zero version"); + return std::string(identity.name) + "@" + std::to_string(identity.version); +} + +inline std::string exact_execution_contract(const CellTemporalPartitionAcceptedState& state, + const auto& provider) { + ExactContractBuilder provider_parameters; + provider.serialize_exact_parameters(provider_parameters); + ExactContractBuilder contract; + contract.text("pops.cell-temporal-partition-executor") + .scalar(std::uint32_t{1}) + .text(state.provider_identity) + .scalar(state.topology_epoch) + .scalar(state.synchronization_tick) + .scalar(state.tick_denominator) + .sequence(state.cells, + [](ExactContractBuilder& item, const CellTemporalPartitionRecord& cell) { + item.scalar(std::int32_t{cell.level}) + .scalar(cell.cell) + .scalar(std::int32_t{cell.rung}) + .scalar(cell.accepted_tick); + }) + .bytes(provider_parameters.view()); + return std::move(contract).release(); +} + +struct DeviceCellTemporalRecord { + int level = 0; + std::uint64_t cell = 0; + int rung = 0; +}; + +inline constexpr std::uint32_t kMalformedOutcomeReason = std::numeric_limits::max(); + +template +struct EvaluateRungBatch { + const DeviceCellTemporalRecord* records = nullptr; + const std::size_t* record_indices = nullptr; + std::int64_t* pending_ticks = nullptr; + std::size_t batch_offset = 0; + std::int64_t begin_tick = 0; + std::int64_t end_tick = 0; + std::int64_t tick_denominator = 1; + DeviceView provider; + + [[nodiscard]] POPS_CELL_TEMPORAL_INLINE_FUNCTION static constexpr std::uint64_t encode_outcome( + CellTemporalStageOutcome outcome) noexcept { + const std::uint32_t disposition = static_cast(outcome.disposition); + const bool malformed = + disposition > static_cast(CellTemporalStageDisposition::Failed) || + ((disposition == 0) != (outcome.reason_code == 0)); + const std::uint32_t encoded_disposition = + malformed ? static_cast(CellTemporalStageDisposition::Failed) : disposition; + const std::uint32_t encoded_reason = malformed ? kMalformedOutcomeReason : outcome.reason_code; + return (static_cast(encoded_disposition) << 32u) | encoded_reason; + } + + POPS_CELL_TEMPORAL_INLINE_FUNCTION void operator()(std::int64_t local_index, + std::uint64_t& aggregate) const noexcept { + const std::size_t record_index = + record_indices[batch_offset + static_cast(local_index)]; + const DeviceCellTemporalRecord& record = records[record_index]; + const CellTemporalStagePoint point{record_index, record.level, record.cell, record.rung, + begin_tick, end_tick, tick_denominator}; + const CellTemporalStageOutcome outcome = + provider.evaluate_local_stage_and_record_space_time_flux(point); + const std::uint64_t encoded = encode_outcome(outcome); + if (encoded == 0) + pending_ticks[record_index] = end_tick; + if (encoded > aggregate) + aggregate = encoded; + } +}; + +} // namespace cell_temporal_detail + +/// Prepared executor for bounded cell-local rungs. +/// +/// Preparation groups canonical cell records into compact device-accessible rung arrays. One +/// combined stage/ledger kernel is launched for each active rung event, independently of the number +/// of cells in that rung. All clocks and provider flux records remain attempt-local until +/// ``commit``; any provider rejection automatically rolls back the complete attempt. +template +class PreparedBatchedCellTemporalExecutor { + public: + PreparedBatchedCellTemporalExecutor(CellTemporalPartitionAcceptedState accepted, + Provider provider) + : provider_(std::move(provider)), + partition_(std::move(accepted)), + provider_identity_( + cell_temporal_detail::canonical_provider_identity(Provider::provider_identity())), + exact_contract_( + cell_temporal_detail::exact_execution_contract(partition_.accepted_state(), provider_)), + records_(partition_.accepted_state().cells.size()), + pending_ticks_(partition_.accepted_state().cells.size()) { + partition_.require_prepared_execution_route(provider_identity_); + prepare_batches_(); + } + + PreparedBatchedCellTemporalExecutor(const PreparedBatchedCellTemporalExecutor&) = delete; + PreparedBatchedCellTemporalExecutor& operator=(const PreparedBatchedCellTemporalExecutor&) = + delete; + PreparedBatchedCellTemporalExecutor(PreparedBatchedCellTemporalExecutor&&) = delete; + PreparedBatchedCellTemporalExecutor& operator=(PreparedBatchedCellTemporalExecutor&&) = delete; + + ~PreparedBatchedCellTemporalExecutor() { rollback(); } + + [[nodiscard]] const std::string& provider_identity() const noexcept { return provider_identity_; } + [[nodiscard]] const std::string& exact_contract() const noexcept { return exact_contract_; } + [[nodiscard]] const CellTemporalPartitionAcceptedState& accepted_state() const noexcept { + return partition_.accepted_state(); + } + [[nodiscard]] CellTemporalPartitionAcceptedState checkpoint() const { + return partition_.checkpoint(); + } + [[nodiscard]] bool attempt_active() const noexcept { return attempt_active_; } + [[nodiscard]] std::size_t prepared_rung_count() const noexcept { return batches_.size(); } + [[nodiscard]] const CellTemporalExecutionStats& stats() const noexcept { return stats_; } + [[nodiscard]] static constexpr bool uses_kokkos() noexcept { +#if defined(POPS_HAS_KOKKOS) + return true; +#else + return false; +#endif + } + + /// Resynchronize this prepared executor with an exact accepted barrier restored by its owner. + /// + /// Preparation (cell identities, rungs, topology, denominator and provider) is immutable. A + /// rollback may only move the common accepted tick backwards or forwards within that authority. + /// Providers without the explicit lifecycle hook cannot be safely retained and fail closed. + void restore_accepted_boundary(CellTemporalPartitionAcceptedState accepted) { + if (attempt_active_) + throw std::logic_error( + "cell-local temporal executor cannot restore an active attempt"); + validate_cell_temporal_partition_state(accepted); + BatchedCellTemporalPartition candidate(accepted); + candidate.require_prepared_execution_route(provider_identity_); + const CellTemporalPartitionAcceptedState& current = partition_.accepted_state(); + if (accepted.kind != current.kind || accepted.provider_identity != current.provider_identity || + accepted.topology_epoch != current.topology_epoch || + accepted.tick_denominator != current.tick_denominator || + accepted.cells.size() != current.cells.size()) + throw std::invalid_argument( + "cell-local temporal executor restore targets another prepared authority"); + for (std::size_t index = 0; index < accepted.cells.size(); ++index) { + const CellTemporalPartitionRecord& next = accepted.cells[index]; + const CellTemporalPartitionRecord& prepared = current.cells[index]; + if (next.level != prepared.level || next.cell != prepared.cell || next.rung != prepared.rung) + throw std::invalid_argument( + "cell-local temporal executor restore changes a prepared cell or rung"); + } + if constexpr (!CellTemporalAcceptedBoundaryLifecycle) { + throw std::logic_error( + "cell-local temporal provider cannot resynchronize an accepted rollback boundary"); + } else { + std::string restored_contract = + cell_temporal_detail::exact_execution_contract(accepted, provider_); + provider_.restore_accepted_boundary(accepted); + partition_.restore(std::move(accepted)); + for (RungBatch& batch : batches_) + batch.current_tick = partition_.accepted_state().synchronization_tick; + for (std::size_t index = 0; index < partition_.accepted_state().cells.size(); ++index) + pending_ticks_[index] = partition_.accepted_state().cells[index].accepted_tick; + target_tick_ = 0; + exact_contract_ = std::move(restored_contract); + } + } + + void begin_attempt(std::int64_t target_tick) { + partition_.begin_attempt(target_tick); + const CellTemporalPartitionAcceptedState& accepted = partition_.accepted_state(); + for (std::size_t index = 0; index < accepted.cells.size(); ++index) + pending_ticks_[index] = accepted.cells[index].accepted_tick; + for (RungBatch& batch : batches_) + batch.current_tick = accepted.synchronization_tick; + + const CellTemporalAttemptDescriptor descriptor{ + accepted.topology_epoch, accepted.synchronization_tick, target_tick, + accepted.tick_denominator, accepted.cells.size()}; + const PreparedProviderSupport support = provider_.begin_attempt(descriptor); + if (!support.well_formed() || !support.accepted()) { + provider_.rollback_attempt(); + partition_.rollback(); + const std::string reason = !support.well_formed() + ? "malformed prepared-provider support decision" + : std::string(support.reason); + throw std::runtime_error("cell-local temporal provider refused attempt preparation: " + + reason); + } + target_tick_ = target_tick; + attempt_active_ = true; + } + + /// Execute every local rung event needed to reach the declared synchronization barrier. + void advance_to_barrier() { + if (!attempt_active_) + throw std::logic_error("cell-local temporal execution requires an active attempt"); + while (RungBatch* batch = next_batch_()) + execute_batch_(*batch); + partition_.require_barrier("cell-local temporal executor"); + } + + void commit() { + if (!attempt_active_) + throw std::logic_error("cell-local temporal commit requires an active attempt"); + partition_.require_barrier("cell-local temporal provider commit"); + try { + CellTemporalPartitionAcceptedState next = partition_.accepted_state(); + next.synchronization_tick = target_tick_; + for (CellTemporalPartitionRecord& cell : next.cells) + cell.accepted_tick = target_tick_; + std::string next_exact_contract = + cell_temporal_detail::exact_execution_contract(next, provider_); + const PreparedProviderSupport support = provider_.prepare_commit_attempt(); + if (!support.well_formed() || !support.accepted()) { + const std::string reason = !support.well_formed() + ? "malformed prepared-provider support decision" + : std::string(support.reason); + throw std::runtime_error("cell-local temporal provider refused accepted publication: " + + reason); + } + provider_.commit_attempt(); + partition_.commit(); + exact_contract_ = std::move(next_exact_contract); + target_tick_ = 0; + attempt_active_ = false; + } catch (...) { + abort_attempt_(); + throw; + } + } + + void rollback() noexcept { + if (!attempt_active_) + return; + provider_.rollback_attempt(); + partition_.rollback(); + target_tick_ = 0; + attempt_active_ = false; + } + + private: + struct RungBatch { + int rung = 0; + std::int64_t stride = 1; + std::int64_t current_tick = 0; + std::size_t offset = 0; + std::vector indices; + }; + + void prepare_batches_() { + const auto& cells = partition_.accepted_state().cells; + std::map> grouped; + for (std::size_t index = 0; index < cells.size(); ++index) { + records_[index] = {cells[index].level, cells[index].cell, cells[index].rung}; + pending_ticks_[index] = cells[index].accepted_tick; + grouped[cells[index].rung].push_back(index); + } + record_indices_.reserve(cells.size()); + batches_.reserve(grouped.size()); + for (auto& [rung, indices] : grouped) { + const std::size_t offset = record_indices_.size(); + record_indices_.insert(record_indices_.end(), indices.begin(), indices.end()); + batches_.push_back({rung, std::int64_t{1} << rung, + partition_.accepted_state().synchronization_tick, offset, + std::move(indices)}); + } + } + + [[nodiscard]] RungBatch* next_batch_() noexcept { + RungBatch* selected = nullptr; + std::int64_t selected_end = std::numeric_limits::max(); + for (RungBatch& batch : batches_) { + if (batch.current_tick >= target_tick_) + continue; + const std::int64_t end_tick = batch.current_tick + batch.stride; + if (end_tick < selected_end || + (end_tick == selected_end && (selected == nullptr || batch.rung < selected->rung))) { + selected = &batch; + selected_end = end_tick; + } + } + return selected; + } + + void abort_attempt_() noexcept { + provider_.rollback_attempt(); + partition_.rollback(); + target_tick_ = 0; + attempt_active_ = false; + } + + void execute_batch_(RungBatch& batch) { + const std::int64_t begin_tick = batch.current_tick; + const std::int64_t end_tick = begin_tick + batch.stride; + if (end_tick > target_tick_) { + abort_attempt_(); + throw std::logic_error("prepared cell-local rung crosses its synchronization barrier"); + } + + const CellTemporalRungBatchDescriptor descriptor{batch.rung, begin_tick, end_tick, + partition_.accepted_state().tick_denominator, + batch.indices.size()}; + std::uint64_t aggregate = 0; + try { + if constexpr (CellTemporalRungBatchLifecycle) + provider_.begin_rung_batch(descriptor); + using DeviceView = CellTemporalStageFluxDeviceViewType; + const DeviceView view = provider_.device_view(); + const cell_temporal_detail::EvaluateRungBatch kernel{ + records_.data(), + record_indices_.data(), + pending_ticks_.data(), + batch.offset, + begin_tick, + end_tick, + partition_.accepted_state().tick_denominator, + view}; +#if defined(POPS_HAS_KOKKOS) + using Policy = + Kokkos::RangePolicy>; + Kokkos::parallel_reduce("pops_cell_temporal_stage_flux_batch", + Policy(0, static_cast(batch.indices.size())), kernel, + Kokkos::Max(aggregate)); + device_fence(); +#else + for (std::size_t index = 0; index < batch.indices.size(); ++index) + kernel(static_cast(index), aggregate); +#endif + ++stats_.rung_batch_launches; + stats_.stage_evaluations += static_cast(batch.indices.size()); + if (aggregate != 0) { + const auto disposition = + static_cast(static_cast(aggregate >> 32u)); + const std::uint32_t reason = static_cast(aggregate); + throw CellTemporalStageFailure(disposition, reason); + } + if constexpr (CellTemporalRungBatchLifecycle) + provider_.complete_rung_batch(descriptor); + } catch (...) { + abort_attempt_(); + throw; + } + try { + partition_.advance_batch(batch.rung, batch.indices, end_tick); + } catch (...) { + abort_attempt_(); + throw; + } + batch.current_tick = end_tick; + } + + Provider provider_; + BatchedCellTemporalPartition partition_; + std::string provider_identity_; + std::string exact_contract_; + std::vector> + records_; + std::vector> record_indices_; + std::vector> pending_ticks_; + std::vector batches_; + CellTemporalExecutionStats stats_; + std::int64_t target_tick_ = 0; + bool attempt_active_ = false; +}; + +} // namespace pops::runtime::program + +#undef POPS_CELL_TEMPORAL_INLINE_FUNCTION diff --git a/include/pops/runtime/program/external_riemann_brick.hpp b/include/pops/runtime/program/external_riemann_brick.hpp index 3ebcaa630..26853aa2e 100644 --- a/include/pops/runtime/program/external_riemann_brick.hpp +++ b/include/pops/runtime/program/external_riemann_brick.hpp @@ -34,7 +34,7 @@ #include // build_block, block_n_ghost #include // dispatch_limiter: ONE limiter-route dispatch generator (ADC-640) #include // validate_limiter -#include // NoSlope / Minmod / VanLeer / Weno5 +#include // Prepared reconstruction policies #include // portable dlopen<->LoadLibraryW (ADC-99) diff --git a/include/pops/runtime/program/program_context.hpp b/include/pops/runtime/program/program_context.hpp index e8db16c7d..f669b3978 100644 --- a/include/pops/runtime/program/program_context.hpp +++ b/include/pops/runtime/program/program_context.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -107,26 +108,15 @@ class ProgramContext : public ProgramExecutionServices { const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& provider_slot, int b, MultiFab& u_stage) const { count_kernel(); + if (provider_slot.empty()) + throw std::invalid_argument( + "System::solve_fields_from_state_at requires an exact provider slot"); sys_->prepare_named_field_publication_storage_(provider_slot); return run_field_solve_transaction_([&]() { return sys_->solve_fields_from_state_at_in_place_(point, provider_slot, sys_block(b), u_stage); }); } - /// Named multi-elliptic field solve (ADC-428): re-solve the SECOND elliptic field @p field from block - /// @p b's stage state @p u_stage and write its phi (+ centered grad) into the field's OWN aux - /// components (distinct from the shared phi/grad the default solve_fields fills). Forwards to - /// System::solve_fields_from_state(field, b, u_stage). The codegen lowers - /// P.solve_fields(field=name, state=U) to this; a default (unnamed) solve_fields keeps the overload - /// above, byte-identical. - SolveOutcome program_execution_solve_named_field_from_state_outcome_(const std::string& field, - int b, - MultiFab& u_stage) const { - count_kernel(); - sys_->prepare_named_field_publication_storage_(field); - return run_field_solve_transaction_( - [&]() { return sys_->solve_fields_from_state_in_place_(field, sys_block(b), u_stage); }); - } /// Coupled multi-block field solve (Spec 3 criterion 24, ADC-457): re-solve the elliptic fields and /// re-fill the shared aux from the SIMULTANEOUS stage states of MULTIPLE blocks at once -- the system /// Poisson RHS is Sum_s elliptic_rhs_s(U_s), every coupled block reading its OWN stage state (not a @@ -166,27 +156,16 @@ class ProgramContext : public ProgramExecutionServices { [&]() { return solve_default_field_workspace_(workspace); }); } - SolveOutcome program_execution_solve_named_field_from_blocks_outcome_( - const std::string& field, const std::vector& u_stages) const { - count_kernel(); - FieldSolveWorkspace& workspace = manual_named_field_solve_workspace_(field); - fill_manual_field_stages_(workspace, u_stages, /*require_exact_size=*/true); - sys_->prepare_named_field_publication_storage_(field); - return run_field_solve_transaction_( - [&]() { return solve_named_field_workspace_(field, workspace); }); - } - - /// Allocation-free generated route. The exact IR identity owns one context-local pointer/snapshot - /// workspace; @p field and the ordered Program block pack are authenticated on every replay. The - /// old vector overloads above remain available for manual C++ callers. + /// Allocation-free generated route. The shared Program service already authenticated the exact IR + /// identity, provider field, ordered block pack and runtime layouts; Uniform owns only publication + /// storage and terminal System dispatch. SolveOutcome program_execution_solve_generated_field_from_blocks_outcome_( - std::int64_t value_id, std::string_view field, - std::initializer_list overrides) const { + const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& field, + const std::vector& runtime_stages) const { count_kernel(); - FieldSolveWorkspace& workspace = generated_field_solve_workspace_(value_id, field, overrides); - sys_->prepare_named_field_publication_storage_(workspace.generated_field_identity); + sys_->prepare_named_field_publication_storage_(field); return run_field_solve_transaction_([&]() { - return solve_named_field_workspace_(workspace.generated_field_identity, workspace); + return sys_->solve_fields_from_blocks_at_in_place_(point, field, runtime_stages); }); } @@ -194,10 +173,6 @@ class ProgramContext : public ProgramExecutionServices { std::vector program_to_system; std::vector program_stages; std::vector system_stages; - std::vector expected_program_blocks; - std::string generated_field_identity; - bool expected_program_blocks_initialized = false; - bool in_use = false; }; struct FieldPublicationTransaction { @@ -239,8 +214,6 @@ class ProgramContext : public ProgramExecutionServices { struct FieldSolveWorkspaceRegistry { FieldSolveWorkspace manual_default; - std::map> manual_named; - std::map generated; FieldPublicationTransaction publication; }; @@ -373,8 +346,6 @@ class ProgramContext : public ProgramExecutionServices { workspace.program_to_system.assign(block_map.begin(), block_map.end()); workspace.program_stages.assign(block_map.size(), nullptr); workspace.system_stages.assign(system_blocks, nullptr); - workspace.expected_program_blocks.clear(); - workspace.expected_program_blocks_initialized = false; } void require_program_stage_layout_(int program_block, const MultiFab& stage) const { @@ -419,78 +390,6 @@ class ProgramContext : public ProgramExecutionServices { return workspace; } - FieldSolveWorkspace& manual_named_field_solve_workspace_(const std::string& field) const { - if (field.empty()) - throw std::invalid_argument( - "Program named simultaneous field solve requires a field identity"); - if (!field_solve_workspace_registry_) - throw std::logic_error("Program field-solve workspace registry is unavailable"); - auto& workspaces = field_solve_workspace_registry_->manual_named; - auto found = workspaces.find(field); - if (found == workspaces.end()) - found = workspaces.try_emplace(field).first; - prepare_field_solve_structure_(found->second); - return found->second; - } - - FieldSolveWorkspace& generated_field_solve_workspace_( - std::int64_t value_id, std::string_view field, - std::initializer_list overrides) const { - if (value_id < 0) - throw std::invalid_argument( - "generated simultaneous field solve requires a non-negative IR identity"); - if (field.empty()) - throw std::invalid_argument("generated simultaneous field solve requires a field identity"); - if (overrides.size() == 0) - throw std::invalid_argument( - "generated simultaneous field solve requires at least one stage override"); - if (!field_solve_workspace_registry_) - throw std::logic_error("Program field-solve workspace registry is unavailable"); - - auto [entry, inserted] = field_solve_workspace_registry_->generated.try_emplace(value_id); - FieldSolveWorkspace& workspace = entry->second; - if (inserted) - workspace.generated_field_identity.assign(field.data(), field.size()); - else if (std::string_view(workspace.generated_field_identity) != field) - throw std::logic_error( - "generated simultaneous field solve IR identity was reused for a different field"); - prepare_field_solve_structure_(workspace); - - const bool learn_blocks = !workspace.expected_program_blocks_initialized; - if (learn_blocks) { - workspace.expected_program_blocks.clear(); - workspace.expected_program_blocks.reserve(overrides.size()); - } else if (workspace.expected_program_blocks.size() != overrides.size()) { - throw std::logic_error( - "generated simultaneous field solve IR identity changed its block pack"); - } - std::fill(workspace.program_stages.begin(), workspace.program_stages.end(), nullptr); - std::size_t ordinal = 0; - for (const FieldStageOverride& override_value : overrides) { - if (override_value.program_block < 0 || - static_cast(override_value.program_block) >= workspace.program_stages.size()) - throw std::out_of_range("generated simultaneous field solve Program block is out of range"); - if (override_value.state == nullptr) - throw std::invalid_argument( - "generated simultaneous field solve stage override cannot be null"); - if (workspace.program_stages[static_cast(override_value.program_block)] != - nullptr) - throw std::invalid_argument( - "generated simultaneous field solve contains a duplicate Program block"); - if (learn_blocks) - workspace.expected_program_blocks.push_back(override_value.program_block); - else if (workspace.expected_program_blocks[ordinal] != override_value.program_block) - throw std::logic_error( - "generated simultaneous field solve IR identity changed its ordered block pack"); - require_program_stage_layout_(override_value.program_block, *override_value.state); - workspace.program_stages[static_cast(override_value.program_block)] = - override_value.state; - ++ordinal; - } - workspace.expected_program_blocks_initialized = true; - return workspace; - } - SolveReport solve_default_field_workspace_(FieldSolveWorkspace& workspace) const { std::fill(workspace.system_stages.begin(), workspace.system_stages.end(), nullptr); for (std::size_t p = 0; p < workspace.program_to_system.size(); ++p) { @@ -500,62 +399,7 @@ class ProgramContext : public ProgramExecutionServices { return sys_->solve_fields_from_blocks_in_place_(workspace.system_stages); } - SolveReport solve_named_field_workspace_(const std::string& field, - FieldSolveWorkspace& workspace) const { - ExclusiveUseGuard use(workspace.in_use, - "Program simultaneous field-solve workspace is already in use"); - std::fill(workspace.system_stages.begin(), workspace.system_stages.end(), nullptr); - bool has_override = false; - for (std::size_t p = 0; p < workspace.program_stages.size(); ++p) { - if (workspace.program_stages[p] == nullptr) - continue; - workspace.system_stages[static_cast(workspace.program_to_system[p])] = - workspace.program_stages[p]; - has_override = true; - } - if (!has_override) - throw std::runtime_error( - "ProgramContext::solve_fields_from_blocks(field): no stage override was supplied"); - return sys_->solve_fields_from_blocks_in_place_(field, workspace.system_stages); - } - - struct ScratchKey { - ScratchKind kind = ScratchKind::Rhs; - std::int64_t value_id = -1; - int subslot = -1; - - friend bool operator<(const ScratchKey& lhs, const ScratchKey& rhs) noexcept { - if (lhs.kind != rhs.kind) - return lhs.kind < rhs.kind; - if (lhs.value_id != rhs.value_id) - return lhs.value_id < rhs.value_id; - return lhs.subslot < rhs.subslot; - } - }; - - struct ScratchRegistry { - std::map fields; - }; - - MultiFab& program_scratch_for_(ScratchKind kind, std::int64_t value_id, int subslot, - const MultiFab& prototype, int n_comp, int n_ghost) const { - if (value_id < 0 || subslot < 0) - throw std::invalid_argument( - "Program persistent scratch requires non-negative IR value and sub-slot identities"); - if (!scratch_registry_) - throw std::logic_error("Program persistent scratch registry is unavailable"); - const ScratchKey key{kind, value_id, subslot}; - auto [entry, inserted] = scratch_registry_->fields.try_emplace(key); - MultiFab& field = entry->second; - if (inserted || !field_layout_matches_(field, prototype, n_comp, n_ghost)) { - field = MultiFab(prototype.box_array(), prototype.dmap(), n_comp, n_ghost); - count_scratch(field); - } - field.set_val(Real(0)); - return field; - } - - runtime::multiblock::BoundaryEvaluationPoint boundary_point_(int stage) const { + runtime::multiblock::BoundaryEvaluationPoint program_execution_boundary_point_(int stage) const { require_rate_identity_(stage); if (primary_clock_.empty() || !std::isfinite(current_dt_) || current_dt_ <= 0.0) throw std::runtime_error("Program boundary evaluation has no prepared clock/dt"); @@ -577,13 +421,9 @@ class ProgramContext : public ProgramExecutionServices { sys_->install_program_step(std::move(step)); } - runtime::multiblock::BoundaryEvaluationPoint program_execution_boundary_point_( - int stage_id) const { - return boundary_point_(stage_id); - } void program_execution_rhs_into_(int /*program_block*/, int runtime_block, MultiFab& state, MultiFab& rhs, int rate_id) const { - sys_->block_rhs_into_at(boundary_point_(rate_id), runtime_block, state, rhs); + sys_->block_rhs_into_at(program_execution_boundary_point_(rate_id), runtime_block, state, rhs); } bool program_execution_has_boundary_linearization_(int runtime_block) const { return sys_->block_has_boundary_linearization(runtime_block); @@ -620,7 +460,8 @@ class ProgramContext : public ProgramExecutionServices { void program_execution_neg_div_flux_default_into_(int /*program_block*/, int runtime_block, MultiFab& state, MultiFab& rhs, int rate_id) const { - sys_->block_neg_div_flux_into_at(boundary_point_(rate_id), runtime_block, state, rhs); + sys_->block_neg_div_flux_into_at(program_execution_boundary_point_(rate_id), runtime_block, + state, rhs); } void program_execution_neg_div_named_flux_into_(MultiFab& rhs, MultiFab& flux_x, MultiFab& flux_y, MultiFab& divergence_scratch, @@ -650,8 +491,8 @@ class ProgramContext : public ProgramExecutionServices { } void program_execution_rhs_group_(const RhsGroupBatch& batch) const { count_kernel(static_cast(batch.requests.size())); - sys_->block_rhs_group(boundary_point_(batch.group_id), batch.runtime_blocks, batch.states, - batch.rhs, batch.flux_only); + sys_->block_rhs_group(program_execution_boundary_point_(batch.group_id), batch.runtime_blocks, + batch.states, batch.rhs, batch.flux_only); } void program_execution_source_default_into_(int runtime_block, MultiFab& state, MultiFab& rhs) const { @@ -660,6 +501,29 @@ class ProgramContext : public ProgramExecutionServices { void program_execution_apply_projection_(int runtime_block, MultiFab& state) const { sys_->block_project(runtime_block, state); } + std::optional> program_execution_projection_balance_integrals_( + int runtime_block, const MultiFab& state) const { + // The public polar diagnostic path has no exact per-cell volume provider yet. Keep automatic + // evidence absent instead of relabelling Cartesian dx*dy as a polar measure; authored balance + // terms remain available and the future selector must fail closed on this missing producer. + if (sys_->program_is_polar()) + return std::nullopt; + const GridContext context = sys_->grid_context(runtime_block); + const Real cell_measure = context.geom.dx() * context.geom.dy(); + if (!std::isfinite(static_cast(cell_measure)) || cell_measure <= Real(0)) + throw std::runtime_error( + "Uniform Program projection balance requires a positive finite cell measure"); + RelativeCellMeasure measure; + if (context.domain_mask != nullptr) { + measure.active_cells = context.domain_mask; + measure.inverse_volume_fraction = context.eb_inverse_volume_fraction; + } + std::vector result(static_cast(state.ncomp()), Real(0)); + for (int component = 0; component < state.ncomp(); ++component) + result[static_cast(component)] = + cell_measure * pops::reduce_sum(state, component, measure); + return result; + } Real program_execution_hmin_() const { return sys_->cfl_min_dx(); } Real program_execution_max_wave_speed_(int runtime_block, const MultiFab& state) const { return sys_->block_max_speed(runtime_block, state); @@ -781,8 +645,8 @@ class ProgramContext : public ProgramExecutionServices { const HistoryRegistration& registration) const { return sys_->history_initialized(registration.name); } - double program_execution_history_slot_dt_storage_( - const HistoryRegistration& registration, int lag) const { + double program_execution_history_slot_dt_storage_(const HistoryRegistration& registration, + int lag) const { return sys_->history_slot_dt(registration.name, lag); } void program_execution_set_history_initialized_storage_(const HistoryRegistration& registration, @@ -869,34 +733,31 @@ class ProgramContext : public ProgramExecutionServices { logical_phase_span_ = rollback.phase_span; logical_physical_time_offset_ = rollback.physical_time_offset; } - SolveReport program_execution_solve_fields_from_state_at_( - const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& provider_slot, - int block, MultiFab& state) const { - return consume_field_outcome_(solve_fields_from_state_at(point, provider_slot, block, state)); - } - MultiFab& program_execution_scratch_(ScratchKind kind, std::int64_t value_id, int subslot, - const MultiFab& prototype, int n_comp, int n_ghost) const { - return program_scratch_for_(kind, value_id, subslot, prototype, n_comp, n_ghost); - } void program_execution_validate_commit_aliases_(bool /*has_aliased_source*/) const noexcept {} + void program_execution_validate_commit_candidates_( + std::initializer_list> commits) const { + for (const auto& [target, candidate] : commits) + for (int block = 0; block < sys_->n_blocks(); ++block) + if (target == &sys_->block_state(block)) { + sys_->validate_program_state_publication_candidate(block, *candidate); + break; + } + } ProgramRuntimeState& program_execution_runtime_state_() const { return sys_->program_runtime_state_(); } ProgramClockCoordinate program_execution_clock_coordinate_() const { return {static_cast(sys_->time()), sys_->macro_step(), -1}; } - void program_execution_set_field_timepoint_(const std::string& field, - const FieldLogicalTimePoint& point) const { - sys_->set_field_logical_timepoint(field, point); - } - void program_execution_set_field_parameters_(const std::string& field, - const std::vector& parameters) const { - sys_->set_field_boundary_parameters(field, parameters); + void program_execution_record_balance_term_(const std::string& route, const std::string& term, + Real value) const { + sys_->record_program_balance_term(route, term, value); } - void program_execution_set_field_kernel_(const std::string& field, - const CompiledFieldBoundaryKernel& kernel) const { - sys_->set_field_boundary_kernel(field, kernel); + bool program_execution_balance_consumer_is_due_(const std::string& contract, + const std::string& route, int every_n) const { + return sys_->program_balance_consumer_is_due(contract, route, every_n); } + System& program_execution_field_facade_() const { return *sys_; } mutable double current_dt_ = 0.0; mutable amr::Rational logical_phase_begin_{0, 1}; mutable amr::Rational logical_phase_span_{1, 1}; @@ -905,7 +766,6 @@ class ProgramContext : public ProgramExecutionServices { mutable std::shared_ptr polar_unit_tt_; mutable std::shared_ptr field_solve_workspace_registry_ = std::make_shared(); - mutable std::shared_ptr scratch_registry_ = std::make_shared(); System* sys_; }; diff --git a/include/pops/runtime/program/program_execution_services.hpp b/include/pops/runtime/program/program_execution_services.hpp index 05b616c32..78e4c84be 100644 --- a/include/pops/runtime/program/program_execution_services.hpp +++ b/include/pops/runtime/program/program_execution_services.hpp @@ -46,6 +46,23 @@ namespace pops::runtime::program { +namespace detail { +struct ProgramComponentSpanCopyKernel { + Array4 destination; + ConstArray4 source; + int destination_component = 0; + int source_component = 0; + int component_count = 0; + + POPS_HD void operator()(int i, int j) const { + for (int component = 0; component < component_count; ++component) + destination(i, j, destination_component + component) = + source(i, j, source_component + component); + } +}; +static_assert(std::is_trivially_copyable_v); +} // namespace detail + /// Backend-independent Program operations shared by every execution topology. /// /// The provider owns topology, storage and explicitly qualified non-Cartesian stencil capabilities @@ -153,9 +170,9 @@ class ProgramExecutionServices { protected: /// Scope one mutable prepared workspace to a single synchronous Program operation. /// - /// Uniform and AMR providers own different workspace storage, but they share the same - /// fail-before-mutation and release-on-exit policy. Keeping that policy here prevents a provider - /// from silently forgetting the exceptional-exit release path. + /// ProgramExecutionServices owns the topology-independent workspace storage and the common + /// fail-before-mutation/release-on-exit policy. Providers receive only authenticated packs, so + /// exceptional-exit release cannot drift between Uniform and AMR implementations. class ExclusiveUseGuard { public: ExclusiveUseGuard(bool& in_use, std::string_view conflict_message) : in_use_(&in_use) { @@ -185,8 +202,13 @@ class ProgramExecutionServices { return provider_().program_execution_runtime_state_(); } - void require_active_field_evaluation_level_( - const runtime::multiblock::BoundaryEvaluationPoint& point) const { + void require_field_evaluation_point_(const runtime::multiblock::BoundaryEvaluationPoint& point, + std::string_view route) const { + if (point.clock.empty() || point.tick < 0 || point.substep < 0 || point.stage < 0 || + !(point.dt > 0.0) || !std::isfinite(point.dt) || !std::isfinite(point.physical_time) || + point.stage_fraction < amr::Rational(0, 1) || amr::Rational(1, 1) < point.stage_fraction) + throw std::invalid_argument(std::string(route) + + " requires a complete exact BoundaryEvaluationPoint"); const int active_level = provider_().program_execution_resource_level_(); if (point.level != active_level) throw std::invalid_argument( @@ -215,28 +237,41 @@ class ProgramExecutionServices { MultiFab& state) const { if (provider_slot.empty()) throw std::invalid_argument("Program field solve requires an exact provider slot"); - require_active_field_evaluation_level_(point); + require_field_evaluation_point_(point, "Program single-state field solve"); return provider_().program_execution_field_solve_from_state_at_outcome_(point, provider_slot, block, state); } - SolveOutcome solve_fields_from_state(const std::string& field, int block, MultiFab& state) const { - return provider_().program_execution_solve_named_field_from_state_outcome_(field, block, state); - } - SolveOutcome solve_fields_from_blocks(const std::vector& states) const { return provider_().program_execution_solve_fields_from_blocks_outcome_(states); } - SolveOutcome solve_fields_from_blocks(const std::string& field, - const std::vector& states) const { - return provider_().program_execution_solve_named_field_from_blocks_outcome_(field, states); - } + SolveOutcome solve_fields_from_blocks_at( + const runtime::multiblock::BoundaryEvaluationPoint& point, std::int64_t value_id, + std::string_view field, std::initializer_list overrides) const { + if (field.empty()) + throw std::invalid_argument("Program field solve requires an exact provider slot"); + require_field_evaluation_point_(point, "Program simultaneous field solve"); + if (value_id < 0) + throw std::invalid_argument( + "generated simultaneous field solve requires a non-negative IR identity"); + if (overrides.size() == 0) + throw std::invalid_argument( + "generated simultaneous field solve requires at least one stage override"); - SolveOutcome solve_fields_from_blocks(std::int64_t value_id, std::string_view field, - std::initializer_list overrides) const { - return provider_().program_execution_solve_generated_field_from_blocks_outcome_(value_id, field, - overrides); + auto [entry, inserted] = generated_field_solve_workspaces_.try_emplace(value_id); + GeneratedFieldSolveWorkspace& workspace = entry->second; + if (inserted) + workspace.field_identity.assign(field.data(), field.size()); + else if (std::string_view(workspace.field_identity) != field) + throw std::logic_error( + "generated simultaneous field solve IR identity was reused for a different field"); + + ExclusiveUseGuard use(workspace.in_use, + "Program simultaneous field-solve workspace is already in use"); + prepare_generated_field_solve_workspace_(workspace, overrides); + return provider_().program_execution_solve_generated_field_from_blocks_outcome_( + point, workspace.field_identity, workspace.runtime_stages); } /// One topology-independent subdivision of the active logical interval. @@ -333,15 +368,14 @@ class ProgramExecutionServices { Body&& body) const { if (provider_slot.empty()) throw std::invalid_argument("Program field solve requires an exact provider slot"); - require_active_field_evaluation_level_(point); const auto restore = [&]() { - const SolveReport restored = provider_().program_execution_solve_fields_from_state_at_( - point, provider_slot, block, restore_state); + const SolveReport restored = consume_field_outcome_( + solve_fields_from_state_at(point, provider_slot, block, restore_state)); if (!restored.solved_value_available()) throw_field_solve_failure_(restored, "restoring the frozen field state"); }; - const SolveReport prepared = provider_().program_execution_solve_fields_from_state_at_( - point, provider_slot, block, evaluation_state); + const SolveReport prepared = consume_field_outcome_( + solve_fields_from_state_at(point, provider_slot, block, evaluation_state)); if (!prepared.solved_value_available()) { restore(); throw_field_solve_failure_(prepared, "evaluating the perturbed field state"); @@ -435,6 +469,46 @@ class ProgramExecutionServices { &boundary); } + /// Copy one valid-cell component span between fields with the same distributed layout. + /// Generated packed multi-block operators use this allocation-free primitive to gather/scatter + /// endpoint vectors without exposing native storage or MPI ownership to Python. + void copy_component_span(MultiFab& destination, int destination_component, + const MultiFab& source, int source_component, + int component_count) const { + if (component_count <= 0 || destination_component < 0 || source_component < 0 || + destination_component > destination.ncomp() - component_count || + source_component > source.ncomp() - component_count) + throw std::invalid_argument("Program component-span copy has an invalid component range"); + if (destination.box_array().boxes() != source.box_array().boxes() || + destination.dmap().ranks() != source.dmap().ranks() || + destination.local_size() != source.local_size()) + throw std::invalid_argument( + "Program component-span copy requires identical distributed field layouts"); + for (int local = 0; local < destination.local_size(); ++local) { + if (destination.global_index(local) != source.global_index(local)) + throw std::logic_error("Program component-span copy found inconsistent local ownership"); + for_each_cell( + destination.box(local), + detail::ProgramComponentSpanCopyKernel{ + destination.fab(local).array(), source.fab(local).const_array(), + destination_component, source_component, component_count}); + } + } + + /// Execute the two perturbed endpoint residuals in one shared-interface scheduler call. + void rhs_jacvec_pair_into_at( + const runtime::multiblock::BoundaryEvaluationPoint& point, + int first_block, MultiFab& first_state, MultiFab& first_rhs, bool first_flux_only, + int second_block, MultiFab& second_state, MultiFab& second_rhs, + bool second_flux_only) const { + if (first_block == second_block) + throw std::invalid_argument("Program implicit interface JVP requires two distinct blocks"); + count_kernel(2); + provider_().program_execution_rhs_jacvec_pair_into_at_( + point, sys_block(first_block), first_state, first_rhs, first_flux_only, + sys_block(second_block), second_state, second_rhs, second_flux_only); + } + void boundary_residual_into_at(const runtime::multiblock::BoundaryEvaluationPoint& point, int block, MultiFab& state, MultiFab& residual) const { count_kernel(); @@ -515,9 +589,33 @@ class ProgramExecutionServices { /// Project one candidate state through the exact authored block closure. /// /// Program-to-runtime block qualification is topology-independent. The provider owns only the - /// Uniform or level-qualified native projection call. + /// Uniform or level-qualified native projection call. When a generated Balance route is due, the + /// provider also supplies exact metric-integrated component values before and after projection; + /// their signed delta stays qualified by runtime block/level/component in the attempt mailbox. void apply_projection(int block, MultiFab& state) const { - provider_().program_execution_apply_projection_(sys_block(block), state); + ProgramRuntimeState& runtime = program_runtime_state_(); + const int runtime_block = sys_block(block); + if (!runtime.automatic_balance_capture_due()) { + provider_().program_execution_apply_projection_(runtime_block, state); + return; + } + const std::optional> before = + provider_().program_execution_projection_balance_integrals_(runtime_block, state); + provider_().program_execution_apply_projection_(runtime_block, state); + if (!before) + return; + const std::optional> after = + provider_().program_execution_projection_balance_integrals_(runtime_block, state); + if (!after || before->size() != after->size() || + before->size() != static_cast(state.ncomp())) + throw std::runtime_error( + "Program projection balance provider changed its conservative component width"); + const int level = program_resource_field_level(); + for (int component = 0; component < state.ncomp(); ++component) + runtime.record_automatic_balance_term(runtime_block, level, component, "projection", + (*after)[static_cast(component)] - + (*before)[static_cast(component)], + "ProgramExecutionServices"); } /// Minimum physical cell size used by the native CFL authority. @@ -555,21 +653,20 @@ class ProgramExecutionServices { } MultiFab& rhs_scratch(std::int64_t value_id, int subslot, const MultiFab& prototype) const { - return provider_().program_execution_scratch_(ScratchKind::Rhs, value_id, subslot, prototype, - prototype.ncomp(), prototype.n_grow()); + return persistent_scratch_(ScratchKind::Rhs, value_id, subslot, prototype, prototype.ncomp(), + prototype.n_grow()); } MultiFab& scratch_state(std::int64_t value_id, int subslot, const MultiFab& prototype) const { - return provider_().program_execution_scratch_(ScratchKind::State, value_id, subslot, prototype, - prototype.ncomp(), prototype.n_grow()); + return persistent_scratch_(ScratchKind::State, value_id, subslot, prototype, prototype.ncomp(), + prototype.n_grow()); } MultiFab& scalar_scratch(std::int64_t value_id, int subslot, const MultiFab& prototype, int n_comp = 1, int n_ghost = 1) const { if (n_comp < 1 || n_ghost < 0) throw std::invalid_argument("Program scalar scratch requires n_comp >= 1 and n_ghost >= 0"); - return provider_().program_execution_scratch_(ScratchKind::Scalar, value_id, subslot, prototype, - n_comp, n_ghost); + return persistent_scratch_(ScratchKind::Scalar, value_id, subslot, prototype, n_comp, n_ghost); } /// Zero-copy access to one authored block's live state through the provider's storage authority. @@ -651,7 +748,7 @@ class ProgramExecutionServices { OperatorEvaluationSnapshot snapshot = provider_().program_execution_operator_evaluation_snapshot_(authority, topology, resources, revision); - active_operator_snapshot_revision_ = revision; + active_operator_snapshot_ = snapshot; return snapshot; } @@ -663,10 +760,15 @@ class ProgramExecutionServices { OperatorFingerprint topology, OperatorFingerprint resources, std::uint64_t revision) const { - const std::uint64_t probe_revision = - revision == active_operator_snapshot_revision_ ? revision : UINT64_C(0); - return provider_().program_execution_operator_evaluation_snapshot_(authority, topology, - resources, probe_revision); + const bool active = + active_operator_snapshot_ && revision == active_operator_snapshot_->revision; + OperatorEvaluationSnapshot probe = provider_().program_execution_operator_evaluation_snapshot_( + authority, topology, resources, active ? revision : UINT64_C(0)); + if (!active || probe != *active_operator_snapshot_) { + invalidate_active_operator_snapshot_(); + probe.revision = 0; + } + return probe; } /// Apply the topology-qualified scalar Laplacian. @@ -894,8 +996,7 @@ class ProgramExecutionServices { if (!std::isfinite(static_cast(target_offset))) throw std::invalid_argument("linear history interpolation offset must be finite"); - HistoryRegistration registration = - history_registration_(name, max_lag, /*ncomp=*/-1, owner); + HistoryRegistration registration = history_registration_(name, max_lag, /*ncomp=*/-1, owner); if (!provider_().program_execution_history_initialized_storage_(registration)) throw std::runtime_error( "linear history interpolation requires an initialized native history"); @@ -939,13 +1040,11 @@ class ProgramExecutionServices { const double logical_fraction = coordinate + static_cast(older_lag); const double target_time = older_time + logical_fraction * bracket_dt; const double timestamp_fraction = (target_time - older_time) / (newer_time - older_time); - if (!std::isfinite(timestamp_fraction) || timestamp_fraction < 0.0 || - timestamp_fraction > 1.0) + if (!std::isfinite(timestamp_fraction) || timestamp_fraction < 0.0 || timestamp_fraction > 1.0) throw std::runtime_error( "linear history interpolation target does not bracket native timestamps"); - registration = - ensure_history_registered_(name, older_lag, /*ncomp=*/-1, owner); + registration = ensure_history_registered_(name, older_lag, /*ncomp=*/-1, owner); MultiFab& older = provider_().program_execution_read_history_storage_( registration, older_lag, HistoryReadMode::RequireInitialized); MultiFab& newer = provider_().program_execution_read_history_storage_( @@ -1216,6 +1315,12 @@ class ProgramExecutionServices { std::find(targets.begin(), targets.end(), commit.second) != targets.end(); }); provider_().program_execution_validate_commit_aliases_(has_aliased_source); + // A terminal candidate may combine transport, model-local sources, coupled sources, or an + // implicit solve. The topology provider owns its exact block identity and validates every + // live-state publication through that block's prepared variable-recovery authority. This + // read-only preflight runs before the first copy, so refusal cannot expose a partially committed + // multi-block endpoint. + provider_().program_execution_validate_commit_candidates_(commits); if (!has_aliased_source) { for (const auto& [target, source] : commits) @@ -1253,14 +1358,9 @@ class ProgramExecutionServices { std::initializer_list candidates) const { if (!std::isfinite(static_cast(dt)) || dt < Real(0)) throw std::invalid_argument("Program coupling application requires a finite non-negative dt"); - if (coupling_workspace_.in_use) - throw std::logic_error("Program coupling workspace is already in use"); + ExclusiveUseGuard use(coupling_workspace_.in_use, + "Program coupling workspace is already in use"); prepare_coupling_workspace_(candidates); - struct WorkspaceUse { - bool& flag; - explicit WorkspaceUse(bool& value) : flag(value) { flag = true; } - ~WorkspaceUse() { flag = false; } - } use(coupling_workspace_.in_use); const std::size_t applied = provider_().program_execution_apply_coupling_(dt, coupling_workspace_.runtime_states); count_kernel(static_cast(applied)); @@ -1307,6 +1407,11 @@ class ProgramExecutionServices { return profiler().schedule_decision(due, cache_backed); } + bool balance_consumer_is_due(const std::string& contract, const std::string& route, + int every_n) const { + return provider_().program_execution_balance_consumer_is_due_(contract, route, every_n); + } + /// Scheduler cache semantics shared by every capable Program storage provider. /// /// The service owns cadence, profiling and value movement. A provider supplies only the @@ -1401,6 +1506,14 @@ class ProgramExecutionServices { program_runtime_state_().record_diagnostic(name, value); } + void record_balance_term(const std::string& route, const std::string& term, Real value) const { + provider_().program_execution_record_balance_term_(route, term, value); + } + + void note_automatic_balance_capture_due(bool due) const { + program_runtime_state_().note_automatic_balance_capture_due(due, "ProgramExecutionServices"); + } + void note_step_projection(const std::string& name) const { program_runtime_state_().note_step_projection(name); } @@ -1409,17 +1522,17 @@ class ProgramExecutionServices { void set_field_logical_timepoint(const std::string& field, const FieldLogicalTimePoint& point) const { - provider_().program_execution_set_field_timepoint_(field, point); + provider_().program_execution_field_facade_().set_field_logical_timepoint(field, point); } void set_field_boundary_parameters(const std::string& field, const std::vector& parameters) const { - provider_().program_execution_set_field_parameters_(field, parameters); + provider_().program_execution_field_facade_().set_field_boundary_parameters(field, parameters); } void set_field_boundary_kernel(const std::string& field, const CompiledFieldBoundaryKernel& kernel) const { - provider_().program_execution_set_field_kernel_(field, kernel); + provider_().program_execution_field_facade_().set_field_boundary_kernel(field, kernel); } Profiler& profiler() const { return program_runtime_state_().profiler(); } @@ -1522,6 +1635,34 @@ class ProgramExecutionServices { mutable amr::Rational stage_time_{0, 1}; private: + struct ProgramScratchKey { + ScratchKind kind = ScratchKind::Rhs; + std::int64_t value_id = -1; + int subslot = -1; + int level = -1; + + friend bool operator<(const ProgramScratchKey& lhs, const ProgramScratchKey& rhs) noexcept { + if (lhs.kind != rhs.kind) + return lhs.kind < rhs.kind; + if (lhs.value_id != rhs.value_id) + return lhs.value_id < rhs.value_id; + if (lhs.subslot != rhs.subslot) + return lhs.subslot < rhs.subslot; + return lhs.level < rhs.level; + } + }; + + struct ProgramScratchSlot { + MultiFab field; + std::uint64_t materialization_generation = std::numeric_limits::max(); + }; + + struct ProgramScratchRegistry { + std::map fields; + std::uint64_t topology_epoch = std::numeric_limits::max(); + std::uint64_t materialization_generation = std::numeric_limits::max(); + }; + struct HistoryBinding { int program_owner = -1; std::string state_identity; @@ -1537,11 +1678,55 @@ class ProgramExecutionServices { bool in_use = false; }; + struct GeneratedFieldSolveWorkspace { + std::string field_identity; + std::vector program_to_runtime; + std::vector runtime_stages; + std::vector expected_program_blocks; + bool expected_program_blocks_initialized = false; + bool in_use = false; + }; + const Provider& provider_() const { return static_cast(*this); } - void invalidate_active_operator_snapshot_() const noexcept { - active_operator_snapshot_revision_ = 0; + /// Acquire one generated persistent field from the common resource registry. + /// + /// Providers authenticate the active topology and level through the existing resource hooks; + /// allocation, invalidation, exact-layout reuse and retry zeroing remain one shared semantic + /// operation. The shared owner also preserves Uniform copy semantics without a second context + /// implementation. + MultiFab& persistent_scratch_(ScratchKind kind, std::int64_t value_id, int subslot, + const MultiFab& prototype, int n_comp, int n_ghost) const { + if (value_id < 0 || subslot < 0) + throw std::invalid_argument( + "Program persistent scratch requires non-negative IR value and sub-slot identities"); + const ProgramResourceTopology topology = program_resource_topology(); + const int level = this->level(); + if (level < 0 || level >= topology.levels) + throw std::out_of_range("Program persistent scratch level is out of range"); + if (!scratch_registry_) + throw std::logic_error("Program persistent scratch registry is unavailable"); + if (scratch_registry_->topology_epoch != topology.epoch || + scratch_registry_->materialization_generation != topology.generation) { + scratch_registry_->fields.clear(); + scratch_registry_->topology_epoch = topology.epoch; + scratch_registry_->materialization_generation = topology.generation; + } + + const ProgramScratchKey key{kind, value_id, subslot, level}; + auto [entry, inserted] = scratch_registry_->fields.try_emplace(key); + ProgramScratchSlot& slot = entry->second; + if (inserted || slot.materialization_generation != topology.generation || + !field_layout_matches_(slot.field, prototype, n_comp, n_ghost)) { + slot.field = MultiFab(prototype.box_array(), prototype.dmap(), n_comp, n_ghost); + slot.materialization_generation = topology.generation; + count_scratch(slot.field); + } + slot.field.set_val(Real(0)); + return slot.field; } + + void invalidate_active_operator_snapshot_() const noexcept { active_operator_snapshot_.reset(); } void require_lane_or_prepared_laplacian_() const { if (provider_().program_execution_is_polar_geometry_()) throw std::logic_error( @@ -1673,6 +1858,86 @@ class ProgramExecutionServices { provider_().program_execution_select_resource_level_(selected); } + void prepare_generated_field_solve_workspace_( + GeneratedFieldSolveWorkspace& workspace, + std::initializer_list overrides) const { + const std::vector& block_map = program_runtime_state_().block_map(); + const std::size_t runtime_blocks = static_cast(program_resource_topology().blocks); + if (block_map.empty()) + throw block_map_error_( + "Program simultaneous field solve has no explicit program-to-runtime block map"); + + const bool structure_changed = workspace.program_to_runtime != block_map || + workspace.runtime_stages.size() != runtime_blocks; + if (structure_changed) { + std::vector authenticated_map; + authenticated_map.reserve(block_map.size()); + std::vector authenticated_runtime(runtime_blocks, nullptr); + for (std::size_t program_block = 0; program_block < block_map.size(); ++program_block) { + const int runtime_block = sys_block(static_cast(program_block)); + const std::size_t runtime_slot = static_cast(runtime_block); + if (authenticated_runtime[runtime_slot] != nullptr) + throw block_map_error_("Program simultaneous field solve block map is not injective"); + authenticated_map.push_back(runtime_block); + authenticated_runtime[runtime_slot] = &provider_().program_execution_state_(runtime_block); + } + workspace.program_to_runtime = std::move(authenticated_map); + workspace.runtime_stages.assign(runtime_blocks, nullptr); + // The ordered Program pack is part of the compiled IR identity, not of the runtime block + // materialization. A map/rank/topology rebuild may replace the runtime slots, but it must + // never teach an existing value_id a different Program request. + } + + const bool learn_blocks = !workspace.expected_program_blocks_initialized; + if (learn_blocks) { + workspace.expected_program_blocks.clear(); + workspace.expected_program_blocks.reserve(overrides.size()); + } else if (workspace.expected_program_blocks.size() != overrides.size()) { + throw std::logic_error( + "generated simultaneous field solve IR identity changed its block pack"); + } + + std::fill(workspace.runtime_stages.begin(), workspace.runtime_stages.end(), nullptr); + std::size_t ordinal = 0; + for (const FieldStageOverride& override_value : overrides) { + if (override_value.program_block < 0 || + static_cast(override_value.program_block) >= + workspace.program_to_runtime.size()) + throw std::out_of_range("generated simultaneous field solve Program block is out of range"); + if (override_value.state == nullptr) + throw std::invalid_argument( + "generated simultaneous field solve stage override cannot be null"); + + const std::size_t program_slot = static_cast(override_value.program_block); + const std::size_t runtime_slot = + static_cast(workspace.program_to_runtime[program_slot]); + if (workspace.runtime_stages[runtime_slot] != nullptr) + throw std::invalid_argument( + "generated simultaneous field solve contains a duplicate Program block"); + if (learn_blocks) + workspace.expected_program_blocks.push_back(override_value.program_block); + else if (workspace.expected_program_blocks[ordinal] != override_value.program_block) + throw std::logic_error( + "generated simultaneous field solve IR identity changed its ordered block pack"); + + const MultiFab& live = + provider_().program_execution_state_(workspace.program_to_runtime[program_slot]); + const MultiFab& stage = *override_value.state; + if (!field_layout_matches_(stage, live, live.ncomp(), live.n_grow())) + throw std::invalid_argument( + "generated field-solve stage does not match its exact runtime-block layout"); + for (std::size_t other = 0; other < runtime_blocks; ++other) + if (other != runtime_slot && + &stage == &provider_().program_execution_state_(static_cast(other))) + throw std::invalid_argument( + "generated field-solve stage cannot alias another block's live state"); + + workspace.runtime_stages[runtime_slot] = override_value.state; + ++ordinal; + } + workspace.expected_program_blocks_initialized = true; + } + void prepare_coupling_workspace_(std::initializer_list candidates) const { const std::vector& block_map = program_runtime_state_().block_map(); const std::size_t runtime_blocks = static_cast(program_resource_topology().blocks); @@ -1751,9 +2016,12 @@ class ProgramExecutionServices { } mutable CouplingWorkspace coupling_workspace_; + mutable std::map generated_field_solve_workspaces_; + mutable std::shared_ptr scratch_registry_ = + std::make_shared(); mutable std::map history_bindings_; mutable std::uint64_t operator_snapshot_revision_ = 0; - mutable std::uint64_t active_operator_snapshot_revision_ = 0; // zero is never a minted revision + mutable std::optional active_operator_snapshot_; }; /// Compile-time association between a public runtime facade and its topology/storage provider. diff --git a/include/pops/runtime/program/program_runtime_state.hpp b/include/pops/runtime/program/program_runtime_state.hpp index 6f75feb53..eb772829e 100644 --- a/include/pops/runtime/program/program_runtime_state.hpp +++ b/include/pops/runtime/program/program_runtime_state.hpp @@ -20,10 +20,11 @@ // and the held-node scheduler cache through the checkpoint; the AMR runtime defers both (its // history / cache seams are not wired), so these stay EMPTY on AMR. Keeping the storage here (one // struct) means an AMR history/cache seam later plugs into the SAME fields, never a fork. -// WHO OWNS STEPPING: the cadence fields (step_ / substeps_ / stride_ / dt_bound_) are READ by the -// driver, but the cadence LOOP lives at the call site, not here -- SystemProgramDriver::run_program_cadence -// on the uniform side, AmrSystem::Impl::run_program_cadence_ on the AMR side. This struct only STORES -// the cadence; it never advances the clock (no Impl / grid dependency leaks in). +// WHO OWNS STEPPING: this state owns the one topology-independent cadence LOOP as well as its fields +// (step_ / substeps_ / stride_ / dt_bound_). Uniform and AMR lend it only their accepted +// `(physical_time, macro_step)` cursor by reference; no Impl, grid or hierarchy dependency crosses +// this boundary. ProgramExecutionServices remains the sole implementation of operations invoked by +// the installed step closure, while the two runtime drivers merely enter this shared dispatcher. // // GRID BOUNDARY. The self-contained logic (cadence guards, diagnostics, block params, history-ring // introspection + rotate, cache passthrough) lives HERE as methods with Program-subsystem-worded @@ -36,12 +37,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -141,6 +144,29 @@ struct HistoryManager { } }; +/// Attempt-local native balance evidence emitted by one exact runtime operator. +/// +/// The coordinate deliberately remains independent of a user-facing BalanceLedger route: native +/// operators know their qualified runtime block, hierarchy level and conservative component, while +/// the route-to-quantity selector is a separate planning authority. Keeping both identities +/// separate prevents a reflux correction from being silently relabelled as a complete balance. +struct AutomaticBalanceKey { + int runtime_block = -1; + int level = -1; + int component = -1; + std::string term; + + friend bool operator<(const AutomaticBalanceKey& left, const AutomaticBalanceKey& right) { + if (left.runtime_block != right.runtime_block) + return left.runtime_block < right.runtime_block; + if (left.level != right.level) + return left.level < right.level; + if (left.component != right.component) + return left.component < right.component; + return left.term < right.term; + } +}; + /// The compiled time-Program runtime state, extracted from the System / AmrSystem god-object (ADC-594). /// /// A plain aggregate: the owning Impl embeds ONE instance and routes every Program seam through it. The @@ -195,6 +221,9 @@ struct ProgramRuntimeState { /// essential at large physical times: reconstructing it as `accepted_time - accumulated_dt` loses /// low bits before the Program starts. Zero is the canonical inactive image. double cadence_window_start_time_ = 0.0; + /// Transient non-reentrancy lease for the one shared cadence dispatcher. It is neither checkpoint + /// state nor accepted scientific state and is always released by RAII on success or failure. + bool cadence_dispatch_active_ = false; /// A strict checkpoint restore stages, but does not yet install, one authenticated window. The /// subsequent set_clock must present the exact accepted (time, macro-step) pair that validated the /// staged image; only that call commits the window. A mismatch discards the staged transaction and @@ -207,7 +236,8 @@ struct ProgramRuntimeState { double cadence_clock_restore_accepted_time_ = 0.0; int cadence_clock_restore_macro_step_ = 0; /// LAST accepted numerical interval handed to step_ (ADC-626). Set by the driver right before each - /// program_.step_(h) call (run_program_cadence, shared by step() and step_cfl()), so the runtime's + /// program_.step_(h) call (dispatch_cadence_step, shared by both runtimes and their explicit/CFL + /// entry points), so the runtime's /// pre-commit store_history can tag its state sample with the outgoing interval that advances it /// toward the next accepted sample (HistoryManager::slot_dt). A plain data field only assigned by /// the template (never a new method it instantiates) -> the mock System. Default 0 -> no program @@ -265,6 +295,35 @@ struct ProgramRuntimeState { /// COMPILED-PROGRAM SCALAR DIAGNOSTICS (ADC-414): name -> last value recorded via P.record_scalar. /// Lives here (not the .so) so it outlives the step closure and Python can read it. Used by BOTH. std::map diagnostics_; + /// Reserved balance records for the current native attempt only. Unlike diagnostics_, this + /// mailbox is cleared before every public step and is never checkpointed. Accepted balance + /// consumers read it while the facade's outer transaction still retains U^n, so a missing term + /// cannot silently reuse the preceding step. + std::map step_balance_terms_; + /// Native operator contributions captured only for a due Balance attempt. These values are keyed + /// by their physical runtime coordinate instead of a user ledger route and are therefore not read + /// by accepted_balance_terms(). The owning facade snapshots this map with the rest of the attempt, + /// so rejection cannot leak automatic evidence into a retry. + std::map automatic_balance_terms_; + /// Monotone attempt-local decision emitted by generated code before any Program operator runs. + /// It is the OR of the exact ConsumerGraph-derived route decisions for this public step. Keeping + /// this separate from step_balance_terms_ lets projection operators execute before their later + /// Program.record_balance sinks without losing due automatic evidence. + bool automatic_balance_due_ = false; + /// Attempt-local outer accepted-step target used by ConsumerGraph-fused balance guards. Program + /// substeps temporarily publish their window-start macro step through the facade, so generated + /// balance code must not infer the public target from `macro_step()+1`. + bool balance_due_window_active_ = false; + int balance_due_target_step_ = 0; + /// Selective checkpoint reconstruction re-executes scientific Program code without accepting a + /// public step. Balance evidence is therefore compiled off for that replay: it must neither query + /// a nonexistent public-step due window nor populate the current accepted-attempt mailbox. + bool balance_replay_active_ = false; + /// A stride-held public step executes no Program work, so its exact discrete balance is the + /// additive identity for every route. These transient flags distinguish that valid zero from a + /// due Program that failed to publish all five terms; neither flag is checkpoint state. + bool balance_step_completed_ = false; + bool balance_program_was_due_ = false; /// Attempt-local identities of ProjectAndRecheck branches that actually executed. This report /// mailbox is cleared at attempt entry and consumed by the Python transaction coordinator before /// commit or rollback; it is deliberately not checkpoint or accepted scientific state. @@ -654,6 +713,66 @@ struct ProgramRuntimeState { } } + /// Execute one accepted facade step through the single Uniform/AMR cadence dispatcher. + /// + /// The owning runtime lends its exact accepted cursor by reference. The dispatcher publishes each + /// numerical substep's start coordinate while invoking the installed Program, restores the entry + /// cursor after every failure, commits the held/due cadence image once, then advances the public + /// cursor exactly once. Grid and hierarchy work remain inside the installed provider closure. + void dispatch_cadence_step(double& physical_time_cursor, int& macro_step_cursor, double dt, + const std::string& runtime) { + if (cadence_dispatch_active_) + throw std::logic_error(runtime + " Program cadence dispatch is non-reentrant"); + if (!step_) + throw std::logic_error( + runtime + " Program cadence dispatch requires an installed whole-system Program"); + + cadence_dispatch_active_ = true; + struct CadenceDispatchLease { + bool& active; + ~CadenceDispatchLease() { active = false; } + } dispatch_lease{cadence_dispatch_active_}; + + const double accepted_time = physical_time_cursor; + const int accepted_macro_step = macro_step_cursor; + const PreparedCadenceStep cadence = + prepare_cadence_step(accepted_time, accepted_macro_step, dt, runtime); + if (accepted_macro_step == std::numeric_limits::max()) + throw std::overflow_error(runtime + " Program cadence macro-step counter overflow"); + + try { + if (cadence.due) { + validate_cadence_partition(cadence, substeps_, runtime); + const int held_before_due = cadence.window_steps - 1; + if (accepted_macro_step < held_before_due) + throw std::logic_error(runtime + " Program cadence window starts before macro-step zero"); + const int window_start_macro_step = accepted_macro_step - held_before_due; + run_balance_due_window(accepted_macro_step, runtime, [&] { + for (int substep = 0; substep < substeps_; ++substep) { + const PreparedCadenceSubstep partition = + prepare_cadence_substep(cadence, substep, substeps_, runtime); + physical_time_cursor = partition.start; + macro_step_cursor = window_start_macro_step; + last_dt_ = static_cast(partition.dt); + step_(partition.dt); + physical_time_cursor = partition.end; + } + }); + physical_time_cursor = accepted_time; + macro_step_cursor = accepted_macro_step; + } + + commit_cadence_step(cadence, runtime); + physical_time_cursor = cadence.window_end; + complete_balance_step(cadence.due); + ++macro_step_cursor; + } catch (...) { + physical_time_cursor = accepted_time; + macro_step_cursor = accepted_macro_step; + throw; + } + } + /// Stage an authenticated checkpoint window for one exact set_clock transaction. The accepted /// window is not mutated until the matching clock pair is consumed, and no historical duration is /// guessed. @@ -735,9 +854,124 @@ struct ProgramRuntimeState { " set_clock cannot reuse an active stride window; restore its strict checkpoint image"); } - /// Record a compiled-Program scalar diagnostic (ADC-414): the installed Program writes named scalars - /// via P.record_scalar; Python reads them after the step. Idempotent (last write wins). - void record_diagnostic(const std::string& name, Real value) { diagnostics_[name] = value; } + static bool has_reserved_balance_namespace(const std::string& name) noexcept { + return name.rfind("pops.balance-term", 0) == 0; + } + + static void require_balance_route(const std::string& route, const std::string& runtime) { + static constexpr std::string_view kRoutePrefix = "pops.balance-ledger-route.v1:sha256:"; + if (route.size() != kRoutePrefix.size() + 64 || + route.compare(0, kRoutePrefix.size(), kRoutePrefix.data(), kRoutePrefix.size()) != 0 || + !std::all_of(route.begin() + static_cast(kRoutePrefix.size()), route.end(), + [](unsigned char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'); + })) + throw std::invalid_argument(runtime + " requires a canonical balance-ledger-route identity"); + } + + static void require_balance_due_contract(const std::string& contract, + const std::string& runtime) { + static constexpr std::string_view kContractPrefix = "pops.balance-due-contract.v1:sha256:"; + if (contract.size() != kContractPrefix.size() + 64 || + contract.compare(0, kContractPrefix.size(), kContractPrefix.data(), + kContractPrefix.size()) != 0 || + !std::all_of(contract.begin() + static_cast(kContractPrefix.size()), + contract.end(), [](unsigned char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'); + })) + throw std::invalid_argument(runtime + " requires a canonical balance-due-contract identity"); + } + + static void require_balance_term(const std::string& term, const std::string& runtime) { + static constexpr std::array kTerms{ + "storage_change", "outward_boundary_flux", "sources", "reflux", "projection"}; + if (std::find(kTerms.begin(), kTerms.end(), std::string_view(term)) == kTerms.end()) + throw std::invalid_argument(runtime + " requires one canonical five-term balance name"); + } + + static void require_automatic_balance_term(const std::string& term, const std::string& runtime) { + static constexpr std::array kTerms{"outward_boundary_flux", "sources", + "reflux", "projection"}; + if (std::find(kTerms.begin(), kTerms.end(), std::string_view(term)) == kTerms.end()) + throw std::invalid_argument(runtime + + " requires one native operator balance contribution name"); + } + + /// Record a compiled-Program scalar. Ordinary P.record_scalar names remain inspectable after the + /// step with last-write-wins semantics. The balance namespace has a separate typed sink. + void record_diagnostic(const std::string& name, Real value) { + if (has_reserved_balance_namespace(name)) + throw std::invalid_argument( + "ProgramRuntimeState::record_diagnostic: pops.balance-term is a reserved namespace"); + diagnostics_[name] = value; + } + + /// Record one validated Program.record_balance term. Not exposed through the Python runtime + /// facade: only generated ProgramContext code reaches this sink. + void record_balance_term(const std::string& route, const std::string& term, Real value, + const std::string& runtime) { + require_balance_route(route, runtime + "::record_balance_term"); + require_balance_term(term, runtime + "::record_balance_term"); + if (!std::isfinite(static_cast(value))) + throw std::invalid_argument(runtime + "::record_balance_term requires a finite value"); + const std::string name = "pops.balance-term.v1:" + route + ":" + term; + // A Program cadence may invoke the compiled body several times inside one public macro-step. + // Terms are signed, time-integrated increments and therefore accumulate across invocations. + auto [entry, inserted] = step_balance_terms_.try_emplace(name, value); + if (!inserted) + entry->second += value; + } + + /// Whether generated code proved that at least one Balance route is due in this attempt. + /// + /// The exact ConsumerGraph-derived decision is emitted before any Program operator, so both an + /// in-body projection and post-body reflux observe the same cadence without a second scheduler. + [[nodiscard]] bool automatic_balance_capture_due() const noexcept { + return !balance_replay_active_ && automatic_balance_due_; + } + + /// Publish one generated ConsumerGraph due decision before Program operators execute. + /// + /// Several compiled Program invocations may share one outer accepted-step window. The marker is + /// therefore monotone inside an attempt and is reset only at attempt entry. Static-false routes + /// emit no call, so a run without Balance consumers retains no generated hot-path branch. + void note_automatic_balance_capture_due(bool due, const std::string& runtime) { + if (balance_replay_active_) { + if (due) + throw std::logic_error(runtime + + "::note_automatic_balance_capture_due cannot enable replay capture"); + return; + } + if (!balance_due_window_active_) + throw std::logic_error( + runtime + "::note_automatic_balance_capture_due requires an active public-step window"); + automatic_balance_due_ = automatic_balance_due_ || due; + } + + /// Accumulate one signed, metric-integrated native operator contribution. + /// + /// This is intentionally not accepted_balance_terms(): automatic evidence remains qualified by + /// block/level/component until a resolved quantity selector proves which BalanceLedger route owns + /// it. The separation is fail-closed and lets boundary/source/projection producers join the same + /// mailbox later without fabricating missing terms. + void record_automatic_balance_term(int runtime_block, int level, int component, + const std::string& term, Real value, + const std::string& runtime) { + if (!automatic_balance_capture_due()) + throw std::logic_error(runtime + + "::record_automatic_balance_term requires a due authored balance"); + if (runtime_block < 0 || level < 0 || component < 0) + throw std::invalid_argument( + runtime + "::record_automatic_balance_term requires non-negative coordinates"); + require_automatic_balance_term(term, runtime + "::record_automatic_balance_term"); + if (!std::isfinite(static_cast(value))) + throw std::invalid_argument(runtime + + "::record_automatic_balance_term requires a finite value"); + auto [entry, inserted] = automatic_balance_terms_.try_emplace( + AutomaticBalanceKey{runtime_block, level, component, term}, value); + if (!inserted) + entry->second += value; + } /// Read the named diagnostic, FAIL-LOUD if the Program never recorded it. @p runtime names the /// Program subsystem setter in the message (not a generic getter). @throws std::out_of_range. @@ -753,7 +987,198 @@ struct ProgramRuntimeState { /// The whole name -> value diagnostics map (checkpoint / inspection). By value: inert copy. std::map diagnostics() const { return diagnostics_; } - void begin_step_projection_report() { step_projections_.clear(); } + void begin_step_projection_report() { + step_projections_.clear(); + step_balance_terms_.clear(); + automatic_balance_terms_.clear(); + automatic_balance_due_ = false; + balance_due_window_active_ = false; + balance_due_target_step_ = 0; + balance_step_completed_ = false; + balance_program_was_due_ = false; + } + + void complete_balance_step(bool program_was_due) noexcept { + balance_step_completed_ = true; + balance_program_was_due_ = program_was_due; + } + + /// Return exactly the five native Program scalars recorded for one typed balance route during the + /// current attempt. The facade separately proves that an external accepted-step transaction is + /// active. No zero, stale value, or array-derived Python fallback is permitted. + std::map accepted_balance_terms(const std::string& route, + const std::string& runtime) const { + static constexpr std::array kTerms{"storage_change", "outward_boundary_flux", + "sources", "reflux", "projection"}; + require_balance_route(route, runtime + "::_accepted_balance_terms"); + std::map result; + if (step_balance_terms_.empty() && balance_step_completed_ && !balance_program_was_due_) { + for (const char* term : kTerms) + result.emplace(term, Real(0)); + return result; + } + for (const char* term : kTerms) { + const std::string record = "pops.balance-term.v1:" + route + ":" + term; + const auto found = step_balance_terms_.find(record); + if (found == step_balance_terms_.end()) + throw std::runtime_error( + runtime + "::_accepted_balance_terms: current native attempt omitted term '" + term + + "'; Program.record_balance must publish all five terms"); + if (!std::isfinite(static_cast(found->second))) + throw std::runtime_error( + runtime + + "::_accepted_balance_terms: current native attempt produced non-finite term '" + term + + "'"); + result.emplace(term, found->second); + } + return result; + } + + /// Resolve one public Balance route against exact native operator coordinates. + /// + /// Explicit Program records remain authoritative for every term not listed in @p automatic_terms. + /// Reflux and projection may instead be selected from the attempt-local native mailbox. The + /// selector is complete and owner-qualified: one runtime block, one conservative component and + /// the full active contiguous hierarchy. A selected producer must have published every expected + /// coordinate; missing evidence and duplicate Program/native authority fail instead of becoming + /// zero or reusing a stale value. + std::map selected_accepted_balance_terms( + const std::string& route, int runtime_block, int component, const std::vector& levels, + const std::vector& automatic_terms, const std::string& runtime) const { + static constexpr std::array kTerms{"storage_change", "outward_boundary_flux", + "sources", "reflux", "projection"}; + require_balance_route(route, runtime + "::_selected_accepted_balance_terms"); + if (runtime_block < 0 || component < 0) + throw std::invalid_argument( + runtime + "::_selected_accepted_balance_terms requires non-negative coordinates"); + if (levels.empty() || levels.front() < 0 || + std::adjacent_find(levels.begin(), levels.end(), + [](int left, int right) { return right != left + 1; }) != levels.end()) + throw std::invalid_argument( + runtime + "::_selected_accepted_balance_terms requires a non-empty contiguous hierarchy"); + if (!std::is_sorted(automatic_terms.begin(), automatic_terms.end()) || + std::adjacent_find(automatic_terms.begin(), automatic_terms.end()) != automatic_terms.end()) + throw std::invalid_argument( + runtime + "::_selected_accepted_balance_terms requires sorted unique automatic terms"); + for (const std::string& term : automatic_terms) + if (term != "reflux" && term != "projection") + throw std::invalid_argument( + runtime + "::_selected_accepted_balance_terms has no native producer for '" + term + + "'"); + + std::map result; + if (step_balance_terms_.empty() && balance_step_completed_ && !balance_program_was_due_) { + for (const char* term : kTerms) + result.emplace(term, Real(0)); + return result; + } + for (const char* term_value : kTerms) { + const std::string term = term_value; + const bool automatic = + std::binary_search(automatic_terms.begin(), automatic_terms.end(), term); + const std::string record = "pops.balance-term.v1:" + route + ":" + term; + const auto authored = step_balance_terms_.find(record); + if (!automatic) { + if (authored == step_balance_terms_.end()) + throw std::runtime_error( + runtime + + "::_selected_accepted_balance_terms: current native attempt omitted term '" + term + + "'; Program.record_balance must publish every non-automatic term"); + if (!std::isfinite(static_cast(authored->second))) + throw std::runtime_error( + runtime + + "::_selected_accepted_balance_terms: current native attempt produced " + "non-finite term '" + + term + "'"); + result.emplace(term, authored->second); + continue; + } + if (authored != step_balance_terms_.end()) + throw std::runtime_error(runtime + "::_selected_accepted_balance_terms: term '" + term + + "' has both Program and native producer authority"); + + Real value = Real(0); + const std::size_t expected = term == "reflux" ? levels.size() - 1 : levels.size(); + for (std::size_t index = 0; index < expected; ++index) { + const AutomaticBalanceKey key{runtime_block, levels[index], component, term}; + const auto found = automatic_balance_terms_.find(key); + if (found == automatic_balance_terms_.end()) + throw std::runtime_error( + runtime + "::_selected_accepted_balance_terms: native producer omitted term '" + + term + "' at level " + std::to_string(levels[index])); + if (!std::isfinite(static_cast(found->second))) + throw std::runtime_error( + runtime + + "::_selected_accepted_balance_terms: native producer returned non-finite " + "term '" + + term + "'"); + value += found->second; + } + if (!std::isfinite(static_cast(value))) + throw std::runtime_error( + runtime + "::_selected_accepted_balance_terms: native term accumulation overflowed"); + result.emplace(term, value); + } + return result; + } + + void begin_balance_due_window(int accepted_macro_step, const std::string& runtime) { + if (balance_due_window_active_) + throw std::logic_error(runtime + " balance due window is already active"); + if (balance_replay_active_) + throw std::logic_error(runtime + " cannot enter a public-step window during balance replay"); + if (accepted_macro_step < 0 || accepted_macro_step == std::numeric_limits::max()) + throw std::overflow_error(runtime + " balance due target step is not representable"); + balance_due_target_step_ = accepted_macro_step + 1; + balance_due_window_active_ = true; + } + + void end_balance_due_window() noexcept { + balance_due_window_active_ = false; + balance_due_target_step_ = 0; + } + + template + void run_balance_due_window(int accepted_macro_step, const std::string& runtime, Body&& body) { + begin_balance_due_window(accepted_macro_step, runtime); + try { + std::forward(body)(); + } catch (...) { + end_balance_due_window(); + throw; + } + end_balance_due_window(); + } + + template + void run_balance_replay(const std::string& runtime, Body&& body) { + if (balance_replay_active_) + throw std::logic_error(runtime + " balance replay is already active"); + if (balance_due_window_active_) + throw std::logic_error(runtime + " cannot enter balance replay inside a public-step window"); + balance_replay_active_ = true; + try { + std::forward(body)(); + } catch (...) { + balance_replay_active_ = false; + throw; + } + balance_replay_active_ = false; + } + + bool balance_consumer_is_due(const std::string& contract, const std::string& route, int every_n, + const std::string& runtime) const { + require_balance_due_contract(contract, runtime + "::balance_consumer_is_due"); + require_balance_route(route, runtime + "::balance_consumer_is_due"); + if (every_n <= 0) + throw std::invalid_argument(runtime + "::balance_consumer_is_due requires a positive period"); + if (balance_replay_active_) + return false; + if (!balance_due_window_active_ || balance_due_target_step_ <= 0) + throw std::logic_error(runtime + + "::balance_consumer_is_due requires an active public-step window"); + return balance_due_target_step_ % every_n == 0; + } void note_step_projection(const std::string& name) { if (name.empty()) diff --git a/include/pops/runtime/program/same_level_cell_temporal_provider.hpp b/include/pops/runtime/program/same_level_cell_temporal_provider.hpp new file mode 100644 index 000000000..2570ffe44 --- /dev/null +++ b/include/pops/runtime/program/same_level_cell_temporal_provider.hpp @@ -0,0 +1,649 @@ +#pragma once + +/// @file +/// @brief Bounded production finite-volume provider for the cell-local temporal executor. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops::runtime::program { + +enum class SameLevelCellFace : std::uint8_t { XLow = 0, XHigh = 1, YLow = 2, YHigh = 3 }; + +/// Complete accepted image of one same-level integrated-flux publication. +/// +/// The static layout qualifiers are retained deliberately: a rollback may restore only the exact +/// ledger that produced the image. This prevents a stale local-time context from publishing fluxes +/// into a rematerialized hierarchy that happens to have the same number of cells. +struct SameLevelCellIntegratedFluxLedgerAcceptedState { + std::uint64_t topology_epoch = 0; + std::uint64_t materialization_generation = 0; + std::size_t block = 0; + int level = 0; + std::size_t cell_count = 0; + int component_count = 0; + std::vector> integrated_flux; + std::int64_t begin_tick = 0; + std::int64_t end_tick = 0; + std::int64_t tick_denominator = 1; + std::uint64_t publication_generation = 0; +}; + +/// Accepted, fixed-shape time-integrated face-flux publication. +/// +/// Each cell owns four face records, avoiding device races while retaining both copies of an +/// interior face for later conservation audits. The accepted vector is replaced only together with +/// the provider's live-state commit. Attempt-local values never enter this object. +class SameLevelCellIntegratedFluxLedger { + public: + SameLevelCellIntegratedFluxLedger(std::uint64_t topology_epoch, + std::uint64_t materialization_generation, std::size_t block, + int level, std::size_t cell_count, int component_count) + : topology_epoch_(topology_epoch), + materialization_generation_(materialization_generation), + block_(block), + level_(level), + cell_count_(cell_count), + component_count_(component_count), + accepted_(checked_value_count_(cell_count, component_count), Real(0)) { + if (component_count <= 0) + throw std::invalid_argument("same-level cell flux ledger requires components > 0"); + } + + [[nodiscard]] std::uint64_t topology_epoch() const noexcept { return topology_epoch_; } + [[nodiscard]] std::uint64_t materialization_generation() const noexcept { + return materialization_generation_; + } + [[nodiscard]] std::size_t block() const noexcept { return block_; } + [[nodiscard]] int level() const noexcept { return level_; } + [[nodiscard]] std::size_t cell_count() const noexcept { return cell_count_; } + [[nodiscard]] int component_count() const noexcept { return component_count_; } + [[nodiscard]] std::int64_t begin_tick() const noexcept { return begin_tick_; } + [[nodiscard]] std::int64_t end_tick() const noexcept { return end_tick_; } + [[nodiscard]] std::int64_t tick_denominator() const noexcept { return tick_denominator_; } + [[nodiscard]] std::uint64_t publication_generation() const noexcept { + return publication_generation_; + } + + /// Copy the accepted publication into caller-owned reusable storage. + /// + /// Program attempts keep one resident image and reuse its capacity across steps. Any allocation + /// therefore happens at the transaction boundary, never in the prepared rung loop. + void copy_accepted_state_into( + SameLevelCellIntegratedFluxLedgerAcceptedState& state) const { + state.topology_epoch = topology_epoch_; + state.materialization_generation = materialization_generation_; + state.block = block_; + state.level = level_; + state.cell_count = cell_count_; + state.component_count = component_count_; + state.integrated_flux.resize(accepted_.size()); + std::copy(accepted_.begin(), accepted_.end(), state.integrated_flux.begin()); + state.begin_tick = begin_tick_; + state.end_tick = end_tick_; + state.tick_denominator = tick_denominator_; + state.publication_generation = publication_generation_; + } + + [[nodiscard]] SameLevelCellIntegratedFluxLedgerAcceptedState accepted_state() const { + SameLevelCellIntegratedFluxLedgerAcceptedState state; + copy_accepted_state_into(state); + return state; + } + + /// Restore one previously captured accepted publication after the hierarchy state rolls back. + /// + /// Every qualifier is checked before mutation. A topology/materialization mismatch is never + /// interpreted as an empty ledger because that would hide a stale prepared temporal provider. + void restore_accepted_state( + const SameLevelCellIntegratedFluxLedgerAcceptedState& state) { + if (state.topology_epoch != topology_epoch_ || + state.materialization_generation != materialization_generation_ || + state.block != block_ || state.level != level_ || state.cell_count != cell_count_ || + state.component_count != component_count_ || state.integrated_flux.size() != accepted_.size()) + throw std::invalid_argument( + "same-level cell flux ledger rollback image targets another prepared layout"); + if (state.begin_tick < 0 || state.end_tick < state.begin_tick || state.tick_denominator <= 0 || + (state.publication_generation == 0 && state.begin_tick != state.end_tick) || + (state.publication_generation != 0 && state.end_tick == state.begin_tick)) + throw std::invalid_argument( + "same-level cell flux ledger rollback image has an invalid accepted clock"); + + std::copy(state.integrated_flux.begin(), state.integrated_flux.end(), accepted_.begin()); + begin_tick_ = state.begin_tick; + end_tick_ = state.end_tick; + tick_denominator_ = state.tick_denominator; + publication_generation_ = state.publication_generation; + } + + /// Mark that no accepted interval-flux publication belongs to a restored checkpoint boundary. + /// Local-time fluxes are diagnostics, not numerical continuation state; until the next accepted + /// step, returning a pre-rollback publication would be stale and is therefore made impossible. + void invalidate_accepted_publication(std::int64_t synchronization_tick, + std::int64_t denominator) { + if (synchronization_tick < 0 || denominator <= 0) + throw std::invalid_argument( + "same-level cell flux ledger invalidation requires a valid accepted clock"); + std::fill(accepted_.begin(), accepted_.end(), Real(0)); + begin_tick_ = synchronization_tick; + end_tick_ = synchronization_tick; + tick_denominator_ = denominator; + publication_generation_ = 0; + } + + [[nodiscard]] Real integrated_flux(std::size_t cell, SameLevelCellFace face, + int component) const { + if (cell >= cell_count_ || component < 0 || component >= component_count_) + throw std::out_of_range("same-level cell flux ledger index is out of range"); + return accepted_.at(storage_offset(cell, face, component, component_count_)); + } + + [[nodiscard]] POPS_HD static std::size_t storage_offset(std::size_t cell, SameLevelCellFace face, + int component, int components) noexcept { + return (cell * std::size_t{4} + static_cast(face)) * + static_cast(components) + + static_cast(component); + } + + private: + friend class PreparedSameLevelTransportEulerStageFluxProvider; + + static std::size_t checked_value_count_(std::size_t cells, int components) { + if (components <= 0) + return 0; + const std::size_t width = std::size_t{4} * static_cast(components); + if (cells > std::numeric_limits::max() / width) + throw std::overflow_error("same-level cell flux ledger size overflows size_t"); + return cells * width; + } + + void publish_(std::int64_t begin_tick, std::int64_t end_tick, std::int64_t denominator, + const Real* values, std::size_t count) noexcept { + if (count != accepted_.size()) + std::terminate(); + std::copy_n(values, count, accepted_.data()); + begin_tick_ = begin_tick; + end_tick_ = end_tick; + tick_denominator_ = denominator; + ++publication_generation_; + } + + std::uint64_t topology_epoch_ = 0; + std::uint64_t materialization_generation_ = 0; + std::size_t block_ = 0; + int level_ = 0; + std::size_t cell_count_ = 0; + int component_count_ = 0; + std::vector> accepted_; + std::int64_t begin_tick_ = 0; + std::int64_t end_tick_ = 0; + std::int64_t tick_denominator_ = 1; + std::uint64_t publication_generation_ = 0; +}; + +inline constexpr std::string_view kSameLevelTransportEulerStageFluxProvider = + "pops.amr.same-level-transport-euler-stage-flux@1"; + +/// Canonical all-cell partition accepted by the first scientific provider. +/// +/// This route is deliberately synchronous within its one level: every valid cell has the same rung. +/// Heterogeneous neighbouring rungs require temporal boundary interpolation and are refused by the +/// provider rather than evaluated from stale data. +inline CellTemporalPartitionAcceptedState prepare_same_level_transport_euler_partition( + AmrRuntime& runtime, std::int64_t synchronization_tick, std::int64_t tick_denominator, + int rung = 0) { + if (n_ranks() != 1 || runtime.n_blocks() != 1 || runtime.nlev() != 1) + throw std::invalid_argument( + "same-level transport Euler partition requires serial execution, one block and one level"); + if (rung < 0 || rung > 30 || synchronization_tick < 0 || tick_denominator <= 0 || + synchronization_tick % (std::int64_t{1} << rung) != 0) + throw std::invalid_argument("same-level transport Euler partition has invalid tick/rung data"); + const MultiFab& state = runtime.level_state(0, 0); + if (state.box_array().size() != 1 || state.local_size() != 1 || state.dmap()[0] != 0) + throw std::invalid_argument( + "same-level transport Euler partition requires one serial-owned level box"); + const Box2D box = state.box(0); + const std::int64_t count64 = box.num_cells(); + if (count64 <= 0 || static_cast(count64) > + static_cast(std::numeric_limits::max())) + throw std::overflow_error("same-level transport Euler partition cell count is invalid"); + + CellTemporalPartitionAcceptedState result; + result.kind = TemporalPartitionKind::CellLocal; + result.provider_identity = std::string(kSameLevelTransportEulerStageFluxProvider); + result.topology_epoch = runtime.topology_epoch(); + result.synchronization_tick = synchronization_tick; + result.tick_denominator = tick_denominator; + result.cells.reserve(static_cast(count64)); + for (std::uint64_t cell = 0; cell < static_cast(count64); ++cell) + result.cells.push_back({0, cell, rung, synchronization_tick}); + validate_cell_temporal_partition_state(result); + return result; +} + +namespace same_level_cell_temporal_detail { + +inline BoxArray face_boxes(const BoxArray& cells, bool x_faces) { + std::vector boxes; + boxes.reserve(static_cast(cells.size())); + for (const Box2D& box : cells.boxes()) + boxes.push_back(x_faces ? xface_box(box) : yface_box(box)); + return BoxArray(std::move(boxes)); +} + +POPS_HD inline bool finite_device_value(Real value) noexcept { + return value == value && value <= std::numeric_limits::max() && + value >= -std::numeric_limits::max(); +} + +struct SameLevelTransportEulerDeviceView { + ConstArray4 state; + ConstArray4 residual; + ConstArray4 flux_x; + ConstArray4 flux_y; + Array4 candidate; + Real* integrated_flux = nullptr; + Real seconds_per_tick = Real(0); + std::size_t cell_count = 0; + int component_count = 0; + int ilo = 0; + int jlo = 0; + int nx = 0; + int expected_rung = 0; + std::int64_t expected_begin_tick = 0; + std::int64_t expected_end_tick = 0; + std::int64_t expected_tick_denominator = 1; + + [[nodiscard]] POPS_HD CellTemporalStageOutcome + evaluate_local_stage_and_record_space_time_flux(CellTemporalStagePoint point) const noexcept { + if (point.level != 0 || point.rung != expected_rung || point.record_index >= cell_count || + point.cell != static_cast(point.record_index) || nx <= 0 || + component_count <= 0 || integrated_flux == nullptr || + point.begin_tick != expected_begin_tick || point.end_tick != expected_end_tick || + point.tick_denominator != expected_tick_denominator || point.end_tick <= point.begin_tick) + return CellTemporalStageOutcome::failed(0x756001u); + const std::size_t linear = point.record_index; + const int i = ilo + static_cast(linear % static_cast(nx)); + const int j = jlo + static_cast(linear / static_cast(nx)); + const Real dt = static_cast(point.end_tick - point.begin_tick) * seconds_per_tick; + if (!(dt > Real(0)) || !finite_device_value(dt)) + return CellTemporalStageOutcome::failed(0x756002u); + + for (int component = 0; component < component_count; ++component) { + const Real next = state(i, j, component) + dt * residual(i, j, component); + const Real xlo = dt * flux_x(i, j, component); + const Real xhi = dt * flux_x(i + 1, j, component); + const Real ylo = dt * flux_y(i, j, component); + const Real yhi = dt * flux_y(i, j + 1, component); + if (!finite_device_value(next) || !finite_device_value(xlo) || !finite_device_value(xhi) || + !finite_device_value(ylo) || !finite_device_value(yhi)) + return CellTemporalStageOutcome::rejected(0x756003u); + candidate(i, j, component) = next; + integrated_flux[SameLevelCellIntegratedFluxLedger::storage_offset( + linear, SameLevelCellFace::XLow, component, component_count)] += xlo; + integrated_flux[SameLevelCellIntegratedFluxLedger::storage_offset( + linear, SameLevelCellFace::XHigh, component, component_count)] += xhi; + integrated_flux[SameLevelCellIntegratedFluxLedger::storage_offset( + linear, SameLevelCellFace::YLow, component, component_count)] += ylo; + integrated_flux[SameLevelCellIntegratedFluxLedger::storage_offset( + linear, SameLevelCellFace::YHigh, component, component_count)] += yhi; + } + return CellTemporalStageOutcome::accepted(); + } +}; + +} // namespace same_level_cell_temporal_detail + +/// First production consumer of ``PreparedBatchedCellTemporalExecutor``. +/// +/// It reuses the selected AMR block's real flux-materialising transport closure, updates the real +/// live conservative state with forward Euler, and records the exact four face fluxes used by that +/// divergence. State and ledger remain in fixed attempt-local storage until one barrier commit. +/// The honest first envelope is host/serial, one block, one level, one box and one common rung. +class PreparedSameLevelTransportEulerStageFluxProvider { + public: + using DeviceView = same_level_cell_temporal_detail::SameLevelTransportEulerDeviceView; + + PreparedSameLevelTransportEulerStageFluxProvider( + AmrRuntime& runtime, const CellTemporalPartitionAcceptedState& partition, + std::shared_ptr ledger, std::string clock_identity) + : runtime_(&runtime), + ledger_(std::move(ledger)), + clock_identity_(std::move(clock_identity)), + topology_epoch_(runtime.topology_epoch()), + materialization_generation_(runtime.topology_materialization_generation()) { + validate_and_materialize_(partition); + } + + PreparedSameLevelTransportEulerStageFluxProvider( + const PreparedSameLevelTransportEulerStageFluxProvider&) = delete; + PreparedSameLevelTransportEulerStageFluxProvider& operator=( + const PreparedSameLevelTransportEulerStageFluxProvider&) = delete; + PreparedSameLevelTransportEulerStageFluxProvider( + PreparedSameLevelTransportEulerStageFluxProvider&&) noexcept = default; + PreparedSameLevelTransportEulerStageFluxProvider& operator=( + PreparedSameLevelTransportEulerStageFluxProvider&&) noexcept = default; + + [[nodiscard]] static constexpr PreparedProviderIdentity provider_identity() noexcept { + return {"pops.amr.same-level-transport-euler-stage-flux", 1}; + } + [[nodiscard]] static constexpr bool supports_default_execution_space() noexcept { + return host_execution_(); + } + [[nodiscard]] static constexpr PreparedCellTemporalStageFluxContractV1 + stage_flux_contract() noexcept { + return {}; + } + void serialize_exact_parameters(ExactContractBuilder& contract) const { + contract.bytes(exact_parameters_); + } + + [[nodiscard]] PreparedProviderSupport begin_attempt( + CellTemporalAttemptDescriptor attempt) noexcept { + if (active_) + return PreparedProviderSupport::reject(0x756101u, "provider attempt is already active"); + if (!host_execution_()) + return PreparedProviderSupport::reject(0x756102u, "provider has no GPU execution proof"); + if (n_ranks() != 1) + return PreparedProviderSupport::reject(0x756103u, "provider has no MPI execution proof"); + if (runtime_->topology_epoch() != topology_epoch_ || + runtime_->topology_materialization_generation() != materialization_generation_) + return PreparedProviderSupport::reject(0x756104u, + "provider storage is stale after topology change"); + if (attempt.topology_epoch != topology_epoch_ || attempt.begin_tick != synchronization_tick_ || + attempt.target_tick <= attempt.begin_tick || + attempt.tick_denominator != tick_denominator_ || attempt.cell_count != cell_count_) + return PreparedProviderSupport::reject(0x756105u, + "attempt differs from prepared temporal authority"); + device_fence(); + std::copy_n(live_->fab(0).data(), static_cast(live_->fab(0).size()), + state_a_.fab(0).data()); + std::fill(attempt_flux_.begin(), attempt_flux_.end(), Real(0)); + current_is_a_ = true; + attempt_begin_tick_ = attempt.begin_tick; + attempt_target_tick_ = attempt.target_tick; + current_tick_ = attempt.begin_tick; + active_ = true; + batch_active_ = false; + return PreparedProviderSupport::accept(); + } + + void begin_rung_batch(CellTemporalRungBatchDescriptor batch) { + if (!active_ || batch_active_ || batch.rung != common_rung_ || + batch.begin_tick != current_tick_ || + batch.end_tick - batch.begin_tick != (std::int64_t{1} << common_rung_) || + batch.end_tick > attempt_target_tick_ || batch.tick_denominator != tick_denominator_ || + batch.cell_count != cell_count_) + throw std::logic_error("same-level transport provider received an unprepared rung batch"); + const Real dt = static_cast(batch.end_tick - batch.begin_tick) * seconds_per_tick_; + ::pops::runtime::multiblock::BoundaryEvaluationPoint point; + point.clock = clock_identity_; + point.tick = batch.begin_tick; + point.level = 0; + point.substep = static_cast((batch.begin_tick - attempt_begin_tick_) >> common_rung_); + point.stage = 0; + point.stage_fraction = ::pops::amr::Rational(0, 1); + point.dt = static_cast(dt); + point.physical_time = static_cast(batch.begin_tick) * seconds_per_tick_; + runtime_->level_neg_div_flux_capture_into(0, 0, point, current_state_(), residual_, flux_x_, + flux_y_); + batch_end_tick_ = batch.end_tick; + batch_active_ = true; + } + + void complete_rung_batch(CellTemporalRungBatchDescriptor) noexcept { + current_is_a_ = !current_is_a_; + current_tick_ = batch_end_tick_; + batch_active_ = false; + } + + [[nodiscard]] DeviceView device_view() const noexcept { + if (!active_ || !batch_active_) + return {}; + return {current_state_().fab(0).const_array(), + residual_.fab(0).const_array(), + flux_x_.fab(0).const_array(), + flux_y_.fab(0).const_array(), + candidate_state_().fab(0).array(), + attempt_flux_.data(), + seconds_per_tick_, + cell_count_, + component_count_, + valid_box_.lo[0], + valid_box_.lo[1], + valid_box_.nx(), + common_rung_, + current_tick_, + batch_end_tick_, + tick_denominator_}; + } + + [[nodiscard]] PreparedProviderSupport prepare_commit_attempt() noexcept { + device_fence(); + if (!active_ || batch_active_ || current_tick_ != attempt_target_tick_) + return PreparedProviderSupport::reject( + 0x756106u, "provider did not reach its prepared synchronization barrier"); + if (runtime_->topology_epoch() != topology_epoch_ || + runtime_->topology_materialization_generation() != materialization_generation_) + return PreparedProviderSupport::reject( + 0x756107u, "provider storage changed before accepted publication"); + if (!ledger_ || ledger_->topology_epoch() != topology_epoch_ || + ledger_->materialization_generation() != materialization_generation_ || + ledger_->block() != 0 || ledger_->level() != 0 || + ledger_->cell_count() != cell_count_ || ledger_->component_count() != component_count_) + return PreparedProviderSupport::reject( + 0x756108u, "provider flux ledger changed before accepted publication"); + return PreparedProviderSupport::accept(); + } + + void commit_attempt() noexcept { + const PreparedProviderSupport support = prepare_commit_attempt(); + if (!support.well_formed() || !support.accepted()) + std::terminate(); + const ConstArray4 source = current_state_().fab(0).const_array(); + const Array4 destination = live_->fab(0).array(); + for (int j = valid_box_.lo[1]; j <= valid_box_.hi[1]; ++j) + for (int i = valid_box_.lo[0]; i <= valid_box_.hi[0]; ++i) + for (int component = 0; component < component_count_; ++component) + destination(i, j, component) = source(i, j, component); + ledger_->publish_(attempt_begin_tick_, attempt_target_tick_, tick_denominator_, + attempt_flux_.data(), attempt_flux_.size()); + synchronization_tick_ = attempt_target_tick_; + active_ = false; + batch_active_ = false; + } + + void rollback_attempt() noexcept { + if (!active_) + return; + device_fence(); + active_ = false; + batch_active_ = false; + current_is_a_ = true; + } + + /// Rebind only the accepted clock after the owning Program restored the matching native image. + /// The executor has already proved that topology, denominator, canonical cells and rungs are the + /// immutable prepared authority of this provider. + void restore_accepted_boundary( + const CellTemporalPartitionAcceptedState& accepted) noexcept { + synchronization_tick_ = accepted.synchronization_tick; + attempt_begin_tick_ = synchronization_tick_; + attempt_target_tick_ = synchronization_tick_; + current_tick_ = synchronization_tick_; + batch_end_tick_ = synchronization_tick_; + active_ = false; + batch_active_ = false; + current_is_a_ = true; + } + + private: + static constexpr bool host_execution_() noexcept { +#if defined(POPS_HAS_KOKKOS) + return std::is_same_v; +#else + return true; +#endif + } + + [[nodiscard]] MultiFab& current_state_() const noexcept { + return current_is_a_ ? state_a_ : state_b_; + } + + [[nodiscard]] MultiFab& candidate_state_() const noexcept { + return current_is_a_ ? state_b_ : state_a_; + } + + void validate_and_materialize_(const CellTemporalPartitionAcceptedState& partition) { + validate_cell_temporal_partition_state(partition); + if (partition.provider_identity != kSameLevelTransportEulerStageFluxProvider || + runtime_->n_blocks() != 1 || runtime_->nlev() != 1 || n_ranks() != 1) + throw std::invalid_argument( + "same-level transport provider requires its exact serial one-block/one-level partition"); + if (clock_identity_.empty()) + throw std::invalid_argument( + "same-level transport provider requires a non-empty clock identity"); + live_ = &runtime_->level_state(0, 0); + if (live_->box_array().size() != 1 || live_->local_size() != 1 || live_->dmap()[0] != 0) + throw std::invalid_argument("same-level transport provider requires one serial-owned box"); + if (runtime_->block_state_identity(0).empty() || + runtime_->block_transport_flux_provider_identity(0).empty() || + runtime_->block_transport_flux_parameter_contract(0).empty()) + throw std::invalid_argument( + "same-level transport provider requires an exact builder-owned state/spatial contract"); + if (runtime_->block_has_prepared_boundary_plan(0)) + throw std::invalid_argument( + "same-level transport provider has no exact prepared-boundary contract proof"); + valid_box_ = live_->box(0); + cell_count_ = static_cast(valid_box_.num_cells()); + component_count_ = live_->ncomp(); + if (partition.topology_epoch != topology_epoch_ || partition.cells.size() != cell_count_) + throw std::invalid_argument("same-level transport partition differs from the live topology"); + common_rung_ = partition.cells.front().rung; + for (std::size_t index = 0; index < partition.cells.size(); ++index) { + const CellTemporalPartitionRecord& cell = partition.cells[index]; + if (cell.level != 0 || cell.cell != static_cast(index) || + cell.rung != common_rung_) + throw std::invalid_argument( + "same-level transport provider requires canonical cells on one common rung"); + } + if (!ledger_ || ledger_->topology_epoch() != topology_epoch_ || + ledger_->materialization_generation() != materialization_generation_ || + ledger_->block() != 0 || ledger_->level() != 0 || ledger_->cell_count() != cell_count_ || + ledger_->component_count() != component_count_) + throw std::invalid_argument("same-level transport provider received the wrong flux ledger"); + + synchronization_tick_ = partition.synchronization_tick; + tick_denominator_ = partition.tick_denominator; + seconds_per_tick_ = Real(1) / static_cast(tick_denominator_); + state_a_ = MultiFab(live_->box_array(), live_->dmap(), live_->ncomp(), live_->n_grow()); + state_b_ = MultiFab(live_->box_array(), live_->dmap(), live_->ncomp(), live_->n_grow()); + residual_ = MultiFab(live_->box_array(), live_->dmap(), live_->ncomp(), 0); + flux_x_ = MultiFab(same_level_cell_temporal_detail::face_boxes(live_->box_array(), true), + live_->dmap(), live_->ncomp(), 0); + flux_y_ = MultiFab(same_level_cell_temporal_detail::face_boxes(live_->box_array(), false), + live_->dmap(), live_->ncomp(), 0); + attempt_flux_.assign(cell_count_ * std::size_t{4} * static_cast(component_count_), + Real(0)); + current_is_a_ = true; + + const Geometry geometry = runtime_->level_geom(0); + const Periodicity periodicity = runtime_->base_periodicity(); + ExactContractBuilder parameters; + parameters.text("pops.amr.same-level-transport-euler-stage-flux") + .scalar(std::uint32_t{1}) + .text(runtime_->block_state_identity(0)) + .text(runtime_->block_transport_flux_provider_identity(0)) + .bytes(runtime_->block_transport_flux_parameter_contract(0)) + .text("forward-euler") + .text("negative-flux-divergence") + .text("frozen-attempt-auxiliary-fields") + .text(clock_identity_) + .scalar(seconds_per_tick_) + .scalar(topology_epoch_) + .scalar(materialization_generation_) + .scalar(static_cast(common_rung_)) + .scalar(tick_denominator_) + .scalar(static_cast(component_count_)) + .scalar(static_cast(live_->n_grow())) + .scalar(static_cast(geometry.domain.lo[0])) + .scalar(static_cast(geometry.domain.lo[1])) + .scalar(static_cast(geometry.domain.hi[0])) + .scalar(static_cast(geometry.domain.hi[1])) + .scalar(geometry.xlo) + .scalar(geometry.xhi) + .scalar(geometry.ylo) + .scalar(geometry.yhi) + .scalar(periodicity.x) + .scalar(periodicity.y) + .sequence(live_->box_array().boxes(), + [](ExactContractBuilder& item, const Box2D& box) { + item.scalar(static_cast(box.lo[0])) + .scalar(static_cast(box.lo[1])) + .scalar(static_cast(box.hi[0])) + .scalar(static_cast(box.hi[1])); + }) + .sequence(live_->dmap().ranks()); + exact_parameters_ = std::move(parameters).release(); + } + + AmrRuntime* runtime_ = nullptr; + MultiFab* live_ = nullptr; + std::shared_ptr ledger_; + Real seconds_per_tick_ = Real(0); + std::string clock_identity_; + std::uint64_t topology_epoch_ = 0; + std::uint64_t materialization_generation_ = 0; + std::string exact_parameters_; + Box2D valid_box_{}; + std::size_t cell_count_ = 0; + int component_count_ = 0; + int common_rung_ = 0; + std::int64_t synchronization_tick_ = 0; + std::int64_t tick_denominator_ = 1; + mutable MultiFab state_a_; + mutable MultiFab state_b_; + MultiFab residual_; + MultiFab flux_x_; + MultiFab flux_y_; + mutable std::vector> attempt_flux_; + bool current_is_a_ = true; + std::int64_t attempt_begin_tick_ = 0; + std::int64_t attempt_target_tick_ = 0; + std::int64_t current_tick_ = 0; + std::int64_t batch_end_tick_ = 0; + bool active_ = false; + bool batch_active_ = false; +}; + +static_assert(CellTemporalStageFluxProvider); +static_assert(CellTemporalRungBatchLifecycle); +static_assert( + CellTemporalAcceptedBoundaryLifecycle); + +} // namespace pops::runtime::program diff --git a/include/pops/runtime/recovery/uniform_recovery_consumer.hpp b/include/pops/runtime/recovery/uniform_recovery_consumer.hpp new file mode 100644 index 000000000..6d4c9cd96 --- /dev/null +++ b/include/pops/runtime/recovery/uniform_recovery_consumer.hpp @@ -0,0 +1,200 @@ +#pragma once + +/// @file +/// @brief Generation-qualified primitive materialization for the host Uniform runtime. +/// +/// This is the first production consumer of RecoveryWarmStartSlot. One consumer instance belongs +/// to one runtime block and owns one slot per local Uniform cell. A slot is reusable only when the +/// cell identity, exact conservative state, topology generation and accepted batch generation all +/// agree. Candidate primitives and cache entries are staged through +/// RecoveryPublicationTransaction; a failed batch publishes no primitive array and explicitly +/// invalidates every slot touched by that consumer. +/// +/// The route is deliberately host/Uniform-only. AMR patch migration, regrid generations and +/// checkpoint/restart persistence require a hierarchy-owned cache and are not inferred here. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { + +inline constexpr std::size_t kNoRecoveryCell = std::numeric_limits::max(); + +/// Result of one all-or-nothing Uniform primitive-materialization batch. +struct UniformRecoveryBatchReport { + RecoveryReport recovery{}; + std::size_t cell_count = 0; + std::size_t recovered_cells = 0; + std::size_t cache_hits = 0; + std::size_t failed_cell = kNoRecoveryCell; + std::uint64_t topology_generation = 0; + std::uint64_t state_generation = 0; + bool published = false; + + bool publication_permitted() const { return published && failed_cell == kNoRecoveryCell; } +}; + +/// Type-erased host batch consumed by System::get_primitive_state. +using UniformCellRecovery = std::function& conserved, std::vector& primitive)>; + +namespace recovery_detail { + +inline std::uint64_t next_uniform_recovery_generation(std::uint64_t current, + const char* generation_name) { + if (current == std::numeric_limits::max()) + throw std::overflow_error(std::string("Uniform recovery ") + generation_name + + " generation exhausted"); + return current + 1; +} + +template +bool exact_uniform_recovery_state(const std::array& accepted, + const std::array& candidate) { + return std::memcmp(accepted.data(), candidate.data(), sizeof(double) * N) == 0; +} + +} // namespace recovery_detail + +/// Stateful host consumer around one immutable prepared recovery plan. +/// +/// Input and output use the System component-major layout: component * n_cells + cell. The output +/// vector is assigned only after every cell has recovered and every per-cell transaction committed. +/// On any refusal or exception, the caller's output stays byte-exact and all slots are invalidated. +template +class PreparedUniformRecoveryConsumer { + public: + static_assert(N > 0, "a Uniform recovery consumer needs at least one variable"); + + explicit PreparedUniformRecoveryConsumer(Plan plan) : plan_(std::move(plan)) {} + + UniformRecoveryBatchReport recover(const std::vector& conserved, + std::vector& primitive) { + if (conserved.size() % static_cast(N) != 0) + throw std::invalid_argument( + "Uniform recovery input size must be divisible by the prepared variable width"); + + const std::size_t cells = conserved.size() / static_cast(N); + prepare_topology(cells); + const std::uint64_t candidate_state_generation = + recovery_detail::next_uniform_recovery_generation(state_generation_, "state"); + + UniformRecoveryBatchReport batch; + batch.cell_count = cells; + batch.topology_generation = topology_generation_; + batch.state_generation = state_generation_; + + std::vector candidate(conserved.size()); + std::vector> next_identity(cells); + try { + for (std::size_t cell = 0; cell < cells; ++cell) { + Real cell_conserved[N] = {}; + Real initial_guess[N] = {}; + std::array cell_identity{}; + for (int component = 0; component < N; ++component) { + const double input = conserved[static_cast(component) * cells + cell]; + const Real value = static_cast(input); + cell_conserved[component] = initial_guess[component] = value; + cell_identity[static_cast(component)] = input; + next_identity[cell][static_cast(component)] = input; + } + + RecoveryWarmStartSlot& slot = slots_[cell]; + const bool exact_identity = + identity_valid_[cell] != 0 && recovery_detail::exact_uniform_recovery_state( + accepted_identity_[cell], cell_identity); + if (exact_identity && + slot.load_if_current(topology_generation_, state_generation_, initial_guess)) + ++batch.cache_hits; + + const RecoveryOutcome outcome = + recover_prepared_variable(plan_, cell_conserved, initial_guess); + batch.recovery = recovery_report(outcome); + if (!outcome.publication_permitted()) { + batch.failed_cell = cell; + rollback_cache(); + return batch; + } + + Real accepted_value[N] = {}; + RecoveryPublicationTransaction transaction(accepted_value, slot); + if (!transaction.publish_tentative(outcome, topology_generation_, + candidate_state_generation) || + !transaction.commit()) + throw std::logic_error( + "Uniform recovery publication transaction refused a recovered " + "candidate"); + + for (int component = 0; component < N; ++component) + candidate[static_cast(component) * cells + cell] = + static_cast(accepted_value[component]); + ++batch.recovered_cells; + } + } catch (...) { + rollback_cache(); + throw; + } + + accepted_identity_.swap(next_identity); + identity_valid_.assign(cells, std::uint8_t{1}); + state_generation_ = candidate_state_generation; + batch.state_generation = state_generation_; + batch.published = true; + primitive = std::move(candidate); + return batch; + } + + void invalidate() { rollback_cache(); } + + private: + void prepare_topology(std::size_t cells) { + if (topology_initialized_ && slots_.size() == cells) + return; + topology_generation_ = + recovery_detail::next_uniform_recovery_generation(topology_generation_, "topology"); + slots_.assign(cells, RecoveryWarmStartSlot{}); + accepted_identity_.assign(cells, std::array{}); + identity_valid_.assign(cells, std::uint8_t{0}); + topology_initialized_ = true; + } + + void rollback_cache() { + for (auto& slot : slots_) + slot.invalidate(); + identity_valid_.assign(identity_valid_.size(), std::uint8_t{0}); + } + + Plan plan_; + std::vector> slots_; + std::vector> accepted_identity_; + std::vector identity_valid_; + std::uint64_t topology_generation_ = 0; + std::uint64_t state_generation_ = 0; + bool topology_initialized_ = false; +}; + +/// Build one copyable type-erased consumer while retaining one shared authoritative cache. +template +UniformCellRecovery make_uniform_recovery_consumer(const Model& model) { + constexpr int N = Model::n_vars; + auto plan = prepare_model_variable_recovery(model); + using Consumer = PreparedUniformRecoveryConsumer; + auto consumer = std::make_shared(std::move(plan)); + return [consumer = std::move(consumer)](const std::vector& conserved, + std::vector& primitive) { + return consumer->recover(conserved, primitive); + }; +} + +} // namespace pops diff --git a/include/pops/runtime/runtime_environment.hpp b/include/pops/runtime/runtime_environment.hpp index a0f52b878..f3106ca87 100644 --- a/include/pops/runtime/runtime_environment.hpp +++ b/include/pops/runtime/runtime_environment.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -21,7 +22,6 @@ namespace pops { -inline constexpr int kNativeDimension = 2; inline constexpr int kNativeAmrRefinementRatio = kAmrRefRatio; struct RuntimeEnvironmentReport { diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 50bab6654..b712e6652 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include // POPS_EXPORT (methods resolved by the native loader through dlopen) #include // CoupledSourceProgram (facade POD, ADC-214) @@ -19,6 +20,7 @@ #include // RuntimeParams (compiled-Program runtime params, ADC-510) #include #include +#include #include #include @@ -47,7 +49,7 @@ namespace pops { -class WorldCommunicator; +class ObserverMpiLane; class PreparedSystemLayoutTransfer; namespace component { @@ -186,7 +188,8 @@ class System { /// Adds an equation block (one species). /// @param model composition of bricks (transport/source/elliptic + parameters) - /// @param limiter reconstruction: "none" | "minmod" | "vanleer" | "weno5" + /// @param limiter reconstruction: "none" | "minmod" | "vanleer" | "weno5" | "mc" | + /// "superbee" /// @param riemann numerical flux: "rusanov" (minimal generic) | "hll" (generic, requires /// model.wave_speeds) | "hllc" | "roe" (generic when the model supplies the /// HasHLLCStructure / HasRoeDissipation hooks; no layout inference or fallback) @@ -223,11 +226,10 @@ class System { /// rel_tol / abs_tol define the mandatory per-cell stopping criterion /// ||F||inf <= abs_tol + rel_tol*||F0||inf; fd_eps controls the finite-difference /// Jacobian and damping controls W -= damping*delta in (0, 1]. - /// @param newton_diagnostics IMEX only: enables the block's Newton report (max residual, - /// max iterations, failed cells -- non-finite / degenerate pivot / non-convergence), - /// aggregated over the substeps of each advance and available via newton_report(name). - /// OPT-IN: false (default) omits the retained diagnostic summary. Stays - /// flat (a separate bool, outside the homogeneous family of convergence options). + /// @param newton_diagnostics Reserved compatibility flag. The Program-only System runtime rejects + /// true until a typed implicit Program consumer actually publishes a Newton + /// report; accepting it would otherwise allocate a carrier that no execution + /// route writes. /// @param wave_speed_cache riemann='hll' + explicit ONLY: pre-computes model.wave_speeds once for /// every exact reconstructed face-trace pair, then reuses that interval from both /// adjacent residual cells. Net gain when wave_speeds is expensive (moment hierarchy). @@ -246,9 +248,9 @@ class System { double positivity_floor = 0.0, bool wave_speed_cache = false, double weno_epsilon = static_cast(kWenoEpsilon)); - /// Report of the implicit source Newton (IMEX) of a block, AGGREGATED over the substeps of the - /// LAST advance of the block. Only exists if the block was added with newton_diagnostics=true - /// (explicit error otherwise). Flat copy (no dependency on the numerics header). + /// Compatibility query for a report published by a typed implicit Program consumer. The current + /// Program-only System runtime rejects the opt-in request until such a consumer is installed. + /// Flat copy (no dependency on the numerics header). struct SourceNewtonReport { bool enabled; ///< a report was computed (at least one IMEX advance played) bool converged; ///< no failed cell on the last advance @@ -268,8 +270,9 @@ class System { /// real System context and installs a zero-copy native block. The complete canonical BindSchema /// vector crosses the fixed ABI once and is injected into the generated model before those closures /// are constructed. Package and module ABI keys must match. - /// @param limiter "none" | "minmod" | "vanleer" | "weno5" (weno5: add_compiled_model reallocates - /// the block state to block_n_ghost = 3 ghosts after install_block, like add_block) + /// @param limiter "none" | "minmod" | "vanleer" | "weno5" | "mc" | "superbee" + /// (weno5: add_compiled_model reallocates the block state to block_n_ghost = 3 + /// ghosts after install_block, like add_block) /// @param riemann "rusanov" | "hll" | "hllc" | "roe" /// @param recon "conservative" | "primitive" /// @param time "explicit" (SSPRK2) | "ssprk3" | "euler" | "imex" (the template marshals the explicit @@ -320,23 +323,33 @@ class System { POPS_EXPORT GridContext grid_context(const std::string& name); /// Index-qualified twin for an already authenticated Program block map. POPS_EXPORT GridContext grid_context(int block); - /// Install one executable built-in ghost plan. `face_types` is xlo,xhi,ylo,yhi using - /// periodic/foextrap/dirichlet; `face_values` is component-major (ncomp*4). + /// Install one executable built-in hyperbolic ghost plan. Face identities remain block/owner + /// qualified and component roles declare reflection behavior; no component index is interpreted. POPS_EXPORT void install_boundary_plan(const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces = {}, const std::string& state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}); - /// Exact-topology overload. The historical exported signature above remains available so - /// translation-only callers retain their ABI and execution path. + /// Exact-topology overload. Physical laws and component transforms remain model-aware; the + /// additional table only identifies periodic face pairs whose coordinate map is not the + /// axis-aligned translation represented by Periodicity. POPS_EXPORT void install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications); + std::vector periodic_identifications, + const std::vector& face_representations = {}, + const std::vector& face_converter_identities = {}, + const std::vector>& face_analytic_opcodes = {}, + const std::vector>& face_analytic_literals = {}, + const std::vector& face_analytic_clocks = {}); /// Register the exact state Handle owned by a materialized block. This registry is independent /// of boundary plans: a block with periodic-only or no physical boundary remains a legal N-ary /// dependency of another block's boundary component. @@ -353,6 +366,9 @@ class System { POPS_EXPORT void install_ghost_boundary_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); + POPS_EXPORT void install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component); POPS_EXPORT void install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component); @@ -382,7 +398,8 @@ class System { /// spatial stencil). WENO5 reads 3 ghosts, > the 2 allocated by install_block; called by add_compiled_model /// (header) with block_n_ghost(limiter) AFTER install_block, so the native compiled path /// (loader .so) accepts weno5 -- SAME mechanism as add_block. No-op if U already has enough ghosts - /// (none/minmod/vanleer, <= 2): allocation and data bit-identical to history. POPS_EXPORT: + /// (all catalogue routes with <= 2 ghosts): allocation and data bit-identical to history. + /// POPS_EXPORT: /// called by the header template add_compiled_model -> must be exported for the loader .so. POPS_EXPORT void set_block_ghosts(const std::string& name, int n_ghost); /// @} @@ -634,13 +651,29 @@ class System { /// Type-erasure of the POINTWISE (one cell) cons <-> prim conversion of a block: in/out are /// arrays of ncomp doubles. Installed by install_block / add_compiled_model / push_dynamic from - /// the block's model, consumed by set_primitive_state / get_primitive_state. + /// the block's model and consumed by publication and prepared-boundary validation. Primitive + /// field materialization exclusively consumes CellBatchRecovery below. using CellConvert = std::function; + /// Fallible conservative -> primitive conversion. A failed report forbids writing @p out. + using CellRecovery = std::function; + using CellBatchRecovery = UniformCellRecovery; + using CharacteristicNoInflowFill = PreparedBoundaryPlan::CharacteristicNoInflowFill; /// Installs the pointwise cons <-> prim conversions of a block (after install_block). Called by /// the header template add_compiled_model (compiled model); the native path add_block and the dynamic /// .so path set them directly. POPS_EXPORT: resolved by the native loader through dlopen. POPS_EXPORT void set_block_conversion(const std::string& name, CellConvert prim_to_cons, - CellConvert cons_to_prim); + CellRecovery cons_to_prim); + /// Finalize a requested characteristic no-inflow face with the exact compiled block model. + /// Plans that did not request the route and models without the prepared Jacobian both refuse it. + POPS_EXPORT void set_block_characteristic_no_inflow(const std::string& name, + CharacteristicNoInflowFill fill); + + /// Installs the generation-qualified host/Uniform batch consumer used by + /// get_primitive_state. The callback owns one warm-start slot per local cell and publishes the + /// materialized primitive array only after the complete batch succeeds. Every supported builder + /// must install it; a missing callback is an explicit incomplete-provider refusal. + POPS_EXPORT void set_block_batch_recovery(const std::string& name, + CellBatchRecovery batch_cons_to_prim); /// Installs the optional STEP BOUNDS of a block (after install_block): reduction of the /// max source frequency (HasSourceFrequency trait, bound dt <= cfl*substeps/(stride*mu)) and of the @@ -726,6 +759,13 @@ class System { POPS_EXPORT std::size_t apply_coupling_operators(Real dt, const std::vector& candidate_states); + /// Internal Program publication preflight. Validates one terminal candidate through the exact + /// block model's prepared conservative-to-primitive recovery before commit_many copies any block + /// into accepted storage. The operation is collective and read-only; refusal leaves every live + /// state unchanged. + POPS_EXPORT void validate_program_state_publication_candidate( + int block, const MultiFab& candidate) const; + /// Solve Poisson then derive aux = (phi, grad phi). The candidate potential and aux remain /// physically private until the returned one-shot outcome is consumed with Accept. [[nodiscard]] POPS_EXPORT SolveOutcome solve_fields(); @@ -1237,6 +1277,13 @@ class System { /// All recorded diagnostics (name -> last recorded value). Empty when the program records none. /// Exposed to Python as sim.program_diagnostics() (a dict); program_diagnostic(name) reads one. POPS_EXPORT std::map program_diagnostics() const; + /// Five current-attempt scalars for one typed balance route. RuntimeInstance calls this only + /// inside its active outer accepted-step transaction; missing/stale/non-finite evidence fails. + POPS_EXPORT std::map accepted_balance_terms(const std::string& route) const; + /// The same accepted route with selected attempt-local native reflux/projection producers. + POPS_EXPORT std::map selected_accepted_balance_terms( + const std::string& route, const std::string& block, int component, + const std::vector& levels, const std::vector& automatic_terms) const; POPS_EXPORT void begin_step_projection_report(); POPS_EXPORT void note_step_projection(const std::string& name); POPS_EXPORT std::vector consume_step_projections(); @@ -1333,9 +1380,9 @@ class System { std::vector output_field_local_pieces(const std::string& provider_slot, int level); /// Collective ROOT views. Local provider errors are agreed before native MPI_Gatherv; only rank /// zero receives complete pieces and every non-root rank receives an empty vector. - std::vector output_state_root_pieces(const WorldCommunicator& world, + std::vector output_state_root_pieces(const ObserverMpiLane& lane, const std::string& name, int level) const; - std::vector output_field_root_pieces(const WorldCommunicator& world, + std::vector output_field_root_pieces(const ObserverMpiLane& lane, const std::string& provider_slot, int level); /// @} @@ -1361,6 +1408,12 @@ class System { private: friend class runtime::program::ProgramContext; friend class PreparedSystemLayoutTransfer; + /// Dedicated generated-Program sink for one validated, attempt-local balance term. It remains + /// private to ProgramContext and is deliberately absent from Python bindings. + POPS_EXPORT void record_program_balance_term(const std::string& route, const std::string& term, + Real value); + POPS_EXPORT bool program_balance_consumer_is_due(const std::string& contract, + const std::string& route, int every_n) const; POPS_EXPORT runtime::program::ProgramRuntimeState& program_runtime_state_(); /// Immediate provider calls are an exported implementation seam for generated ProgramContext /// code, never a public publication route. Every public field solve and every Program solve wraps @@ -1376,6 +1429,9 @@ class System { const MultiFab& U_stage); POPS_EXPORT SolveReport solve_fields_from_blocks_in_place_( const std::string& field, const std::vector& U_stages); + POPS_EXPORT SolveReport solve_fields_from_blocks_at_in_place_( + const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& field, + const std::vector& U_stages); POPS_EXPORT void prepare_default_field_publication_storage_(); POPS_EXPORT void prepare_named_field_publication_storage_(const std::string& field); POPS_EXPORT void begin_field_publication_transaction(); diff --git a/include/pops/runtime/system/prepared_field_solver_component.hpp b/include/pops/runtime/system/prepared_field_solver_component.hpp index 7eecc7ad0..0e40398b7 100644 --- a/include/pops/runtime/system/prepared_field_solver_component.hpp +++ b/include/pops/runtime/system/prepared_field_solver_component.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,7 @@ struct PreparedFieldSolverSpec { double relative_tolerance = 0.0; double absolute_tolerance = 0.0; std::int32_t max_iterations = 0; + bool component_pair_declares_mpi = false; std::shared_ptr execution; }; @@ -63,8 +65,8 @@ struct FieldTopologyReportRow { /// materialized once from replicated patch metadata and reused for every solve. A solve sends every /// local patch view in one request and calls the component exactly once on every participating rank, /// including ranks with zero local patches. The currently proven System route is host-resident, -/// serial, Cartesian, cell-centered and full-material; unsupported execution/layout facts are -/// rejected before either component can mutate the solution. +/// serial or singleton-MPI, Cartesian, cell-centered and full-material; unsupported +/// execution/layout facts are rejected before either component can mutate the solution. class PreparedFieldSolverComponent final { public: PreparedFieldSolverComponent(PreparedFieldSolverSpec spec, @@ -159,7 +161,7 @@ class PreparedFieldSolverComponent final { void prepare_provider_contract_() { ExactContractBuilder contract; contract.text("pops.runtime.external-field-solver-provider") - .scalar(std::uint32_t{1}) + .scalar(std::uint32_t{2}) .text(spec_.provider_slot) .text(spec_.topology_component_id) .text(spec_.topology_manifest_identity) @@ -175,6 +177,7 @@ class PreparedFieldSolverComponent final { .scalar(spec_.relative_tolerance) .scalar(spec_.absolute_tolerance) .scalar(spec_.max_iterations) + .scalar(spec_.component_pair_declares_mpi) .text(spec_.execution->identity()); collective_contract_ = std::move(contract).release(); provider_identity_ = hashed_identity_("external-field-solver-provider", collective_contract_); @@ -438,7 +441,7 @@ class PreparedFieldSolverComponent final { geometry.ylo + static_cast(box.lo[1]) * geometry.dy() || patch.cell_spacing[0] != geometry.dx() || patch.cell_spacing[1] != geometry.dy() || patch.layout_identity == nullptr || patch.patch_identity == nullptr || - materialized_layout_identity_ != patch.layout_identity || + spec_.source_layout_identity != patch.layout_identity || patch_identities_[index] != patch.patch_identity) throw std::runtime_error( "prepared external field topology cannot be reused after a layout change"); @@ -566,10 +569,21 @@ class PreparedFieldSolverComponent final { throw std::invalid_argument("prepared external field solver specification is incomplete"); const auto execution = spec_.execution->view(); component::validate_execution_context(execution); + const std::string communicator_identity(execution.communicator_identity); + bool singleton_mpi = false; +#ifdef POPS_HAS_MPI + if (communicator_identity == "MPI_COMM_WORLD") { + const CommunicatorView communicator{ + MPI_Comm_f2c(static_cast(execution.communicator_f_handle))}; + singleton_mpi = communicator.active() && communicator.size() == 1; + } +#endif if (execution.memory_space != POPS_MEMORY_SPACE_HOST_V1 || - std::string(execution.communicator_identity) != "serial") + (communicator_identity != "serial" && + (!singleton_mpi || !spec_.component_pair_declares_mpi))) throw std::invalid_argument( - "external FieldSolver v2 System adapter currently proves host/serial execution only"); + "external FieldSolver v2 System adapter currently proves host serial or declared " + "singleton-MPI execution only"); const auto& topology_api = topology_component_->api(); const auto& solver_api = solver_component_->api(); if (topology_api.component_id == nullptr || topology_api.manifest_identity == nullptr || diff --git a/include/pops/runtime/system/system_block_store.hpp b/include/pops/runtime/system/system_block_store.hpp index ef8475925..d5eabcd8d 100644 --- a/include/pops/runtime/system/system_block_store.hpp +++ b/include/pops/runtime/system/system_block_store.hpp @@ -5,9 +5,11 @@ #include // VariableSet (role descriptor carried by each block) #include // Box2D #include // device_fence (marshaling synchronizes the device before reading the host) -#include // MultiFab, Array4, ConstArray4 +#include // MultiFab, Array4, ConstArray4 +#include #include // GeometryMode + point-qualified geometry residuals #include +#include #include #include @@ -56,6 +58,8 @@ class SystemBlockStore { /// arrays of ncomp doubles. SAME type as System::CellConvert (identical std::function): assignment /// from set_block_conversion / native_loader stays a trivial move. using CellConvert = std::function; + using CellRecovery = std::function; + using CellBatchRecovery = UniformCellRecovery; /// Compiled spatial closures frozen at block add time (composite model + spatial scheme). /// Type-erased ONLY at the block list level; the kernel stays compiled. @@ -97,7 +101,8 @@ class SystemBlockStore { // Set at add time (install_block / push_dynamic) from the real model; empty -> identity (the // model exposes no conversion, e.g. pure scalar or .so generated before this work). // Consumed by set_primitive_state / get_primitive_state (init/diagnostic in primitive). - CellConvert prim_to_cons, cons_to_prim; + CellConvert prim_to_cons; + CellRecovery cons_to_prim; // dt_hotspot DIAGNOSTIC (ADC-182): (U, w, i, j) -> GLOBAL cell dominating the transport CFL bound // of the block + its speed w = max(wx, wy). ON DEMAND only (System::dt_hotspot): // never queried by step/step_cfl (hot path bit-identical). Trailing + empty default. @@ -189,9 +194,11 @@ class SystemBlockStore { boundary_jvp_at_point_prepared; PointQualifiedResidualClosures staircase_residuals; PointQualifiedResidualClosures cutcell_residuals; - // Frozen numerical-provider capability. Kept at the aggregate tail so the positional head used - // by install_block remains ABI/source compatible. - std::uint8_t supported_geometry_modes = kCartesianGeometrySupport; + SpatialProviderGeometry base_spatial_geometry = SpatialProviderGeometry::Cartesian; + // Frozen numerical-provider matrix. Kept at the aggregate tail so the positional head used by + // install_block remains ABI/source compatible. + SpatialProviderCapabilities spatial_provider = + make_cartesian_spatial_provider(kNativeDimension); /// Sequential runtime session materialized once at bind, after block layouts and qualified /// storage routes are frozen. Prepared Krylov workspaces own distinct lane-private sessions. std::shared_ptr boundary_lane; @@ -199,6 +206,9 @@ class SystemBlockStore { /// Exact owner-qualified state Handle. Installed from the compiled block plan rather than /// inferred from the optional physical-boundary authority. std::string state_identity; + /// Host/Uniform primitive materializer with per-cell generation-qualified warm starts. Kept at + /// the aggregate tail so native/legacy positional construction remains source-compatible. + CellBatchRecovery batch_cons_to_prim; }; /// ORDERED registry of the blocks (UNIQUE source of truth). PUBLIC: Impl aliases it as `sp` for the @@ -495,18 +505,29 @@ class SystemBlockStore { private: static void require_geometry_provider(const BlockState& block, GeometryMode mode) { - if (!supports_geometry_mode(block.supported_geometry_modes, mode)) + const SpatialProviderGeometry geometry = + mode == GeometryMode::None ? block.base_spatial_geometry : spatial_provider_geometry(mode); + const auto supports = [&](SpatialProviderOperation operation) { + return block.spatial_provider.supports({kNativeDimension, geometry, operation}); + }; + if (!supports(SpatialProviderOperation::Residual)) throw std::runtime_error("SystemBlockStore block '" + block.name + "' has no numerical provider for geometry policy '" + geometry_token(mode) + "'"); - if (mode == GeometryMode::None || !block.boundary_session) + if (!block.boundary_session) return; const PreparedBoundaryPlan* plan = block.boundary_session->resolved_plan(); - if (plan != nullptr && plan->has_component_boundaries()) + if (plan != nullptr && plan->requires_characteristic_no_inflow() && + !supports(SpatialProviderOperation::CharacteristicNoInflow)) + throw std::runtime_error("SystemBlockStore block '" + block.name + + "' cannot execute characteristic no-inflow for geometry policy '" + + geometry_token(mode) + "': no qualified spatial provider"); + if (plan != nullptr && plan->has_component_boundaries() && + !supports(SpatialProviderOperation::BoundaryLinearization)) throw std::runtime_error( - "SystemBlockStore embedded-boundary block '" + block.name + - "' cannot execute a native boundary component without an active-cell or cut-cell " - "metric provider"); + "SystemBlockStore block '" + block.name + + "' cannot execute a native boundary component for geometry policy '" + + geometry_token(mode) + "' without a signed-mask or cut-cell metric contract"); } static PointQualifiedResidualClosures& embedded_residuals(BlockState& block, GeometryMode mode) { diff --git a/include/pops/runtime/system/system_diagnostics_registry.hpp b/include/pops/runtime/system/system_diagnostics_registry.hpp index e9e733d37..03df0dbcb 100644 --- a/include/pops/runtime/system/system_diagnostics_registry.hpp +++ b/include/pops/runtime/system/system_diagnostics_registry.hpp @@ -13,16 +13,15 @@ /// /// Extracted from three inline `std::map`s that lived on `System::Impl`. It groups the metadata a /// runtime report reads back: the effective numerical/physical block options captured at -/// configuration time and the OPT-IN Newton (IMEX) per-block reports. None of these are read by -/// SystemProgramDriver -> MockImpl-invisible. +/// configuration time and compatibility carriers for a future typed Program diagnostic consumer. +/// None of these are read by SystemProgramDriver -> MockImpl-invisible. /// /// OWNERSHIP CONTRACT /// - block_options: FROZEN AT BIND. Populated only by structural block installation, refused once /// bound, and read-only afterwards (effective_options_report). -/// - newton_reports: the map ENTRIES are frozen at bind (allocated by add_block for a block that -/// opted into diagnostics or a fail policy); the report CONTENTS are MUTABLE DURING RUN (the -/// block IMEX advance closures write into them by raw pointer each step). The shared_ptr gives a -/// STABLE address even when the map reallocates at a later add_block. +/// - newton_reports: compatibility storage for a typed Program consumer. The current Program-only +/// System runtime never allocates entries and rejects the public opt-in until such a consumer +/// owns publication. The shared_ptr preserves a stable address for that future seam. /// - NOT checkpointed: inspection metadata is re-derived by replaying the composition. /// /// KEY TYPING: keyed by the user-chosen BLOCK / STAGE NAME (no ADC-584 route id exists for a @@ -37,9 +36,8 @@ struct SystemDiagnosticsRegistry { /// Effective numerical/physical block options captured when the block/stage is added. The closures /// are opaque, so inspection stores the user-facing route decisions here. std::map block_options; - /// OPT-IN IMEX Newton report carrier reserved for the typed implicit Program primitive. Spatial - /// block closures never capture or write it. Absent (missing key) for a block without - /// newton_diagnostics -> newton_report raises a clear error. + /// Newton report carrier reserved for a typed implicit Program consumer. Spatial block closures + /// never capture or write it; the current runtime leaves the map empty and rejects the opt-in. std::map> newton_reports; /// Effective block options of @p name, or nullptr if the block was never registered. diff --git a/include/pops/runtime/system/system_domain.hpp b/include/pops/runtime/system/system_domain.hpp index 0a7ced845..1758539fe 100644 --- a/include/pops/runtime/system/system_domain.hpp +++ b/include/pops/runtime/system/system_domain.hpp @@ -126,9 +126,8 @@ struct SystemDomain { return b; } - /// The exact historical System::Impl init-list, verbatim in order: cfg, geom, polar_, pgeom_, ba, - /// dm (sizes from ba), bc_, dom, per_, aux (allocates on ba/dm). The remaining members - /// (eb_* / domain_mask_ / ws_cache_block_ / geometry_mode_) default-construct exactly as before. + /// System::Impl layout initialization in ownership order. Cartesian periodicity comes from the + /// configuration; a polar ring always publishes physical-radial/periodic-azimuthal topology. explicit SystemDomain(const SystemConfig& c) : cfg(c), geom{Box2D::from_extents(c.n, c.n), c.xlo, c.xlo + c.L, c.ylo, c.ylo + c.L}, @@ -138,7 +137,7 @@ struct SystemDomain { dm(ba.size(), n_ranks()), bc_(make_bc(c)), dom(index_domain(c)), - per_{!polar_ && c.periodicity.x, !polar_ && c.periodicity.y}, + per_{polar_ ? false : c.periodicity.x, polar_ ? true : c.periodicity.y}, aux(ba, dm, kAuxBaseComps, 1) {} /// Structured report (ADC-578 acceptance): the layout facts a runtime report enumerates. diff --git a/include/pops/runtime/system/system_field_solver.hpp b/include/pops/runtime/system/system_field_solver.hpp index 646f6c3ac..f13da0545 100644 --- a/include/pops/runtime/system/system_field_solver.hpp +++ b/include/pops/runtime/system/system_field_solver.hpp @@ -313,6 +313,8 @@ class SystemFieldSolver { RuntimeDiagnosticsReport diagnostics; std::map named_potentials; std::vector named_unbuilt; + std::map> + named_topology_reports; }; [[nodiscard]] static bool same_publication_layout(const MultiFab& lhs, @@ -333,6 +335,7 @@ class SystemFieldSolver { if (phi_src_polar_) out.polar_source = *phi_src_polar_; for (auto& item : named_fields_) { + out.named_topology_reports.emplace(item.first, item.second.published_topology_report); if (!item.second.backend) { out.named_unbuilt.push_back(item.first); continue; @@ -362,9 +365,13 @@ class SystemFieldSolver { } phi_src_polar_ = snapshot.polar_source; for (auto& item : named_fields_) { + const auto topology = snapshot.named_topology_reports.find(item.first); + if (topology == snapshot.named_topology_reports.end()) + throw std::logic_error("System field snapshot lost accepted topology evidence"); if (std::find(snapshot.named_unbuilt.begin(), snapshot.named_unbuilt.end(), item.first) != snapshot.named_unbuilt.end()) { invalidate_named_backend_(item.second); + item.second.published_topology_report = topology->second; continue; } const auto saved = snapshot.named_potentials.find(item.first); @@ -373,6 +380,7 @@ class SystemFieldSolver { ensure_named_backend(item.second, item.first); item.second.backend->restore(saved->second); } + item.second.published_topology_report = topology->second; } diagnostics_ = snapshot.diagnostics; } @@ -418,6 +426,8 @@ class SystemFieldSolver { if (snapshot.named_potentials.size() + snapshot.named_unbuilt.size() != named_fields_.size()) return false; for (const auto& [name, field] : named_fields_) { + if (snapshot.named_topology_reports.find(name) == snapshot.named_topology_reports.end()) + return false; const auto saved = snapshot.named_potentials.find(name); const bool was_unbuilt = std::find(snapshot.named_unbuilt.begin(), snapshot.named_unbuilt.end(), name) != @@ -448,6 +458,10 @@ class SystemFieldSolver { const auto saved = snapshot.named_potentials.find(name); if (saved != snapshot.named_potentials.end()) PureFieldAlgebra::copy_allocated(field.backend->phi(), saved->second); + const auto topology = snapshot.named_topology_reports.find(name); + if (topology == snapshot.named_topology_reports.end()) + std::terminate(); + field.published_topology_report.swap(topology->second); } std::swap(diagnostics_.schema_version, snapshot.diagnostics.schema_version); diagnostics_.source.swap(snapshot.diagnostics.source); @@ -816,7 +830,11 @@ class SystemFieldSolver { return FieldDistribution::Distributed; } [[nodiscard]] MultiFab snapshot() override { return MultiFab(phi_); } - void restore(const MultiFab& value) override { phi_ = value; } + void restore(const MultiFab& value) override { + // The prepared FieldSolver request borrows phi_'s stable storage. Rollback restores values + // without replacing that allocation, so the cached ABI views remain valid for an exact retry. + PureFieldAlgebra::copy_allocated(phi_, value); + } void configure_boundary(FieldSolveConfig& plan) override { if (plan.has_boundary_kernel) throw std::runtime_error( @@ -1525,6 +1543,7 @@ class SystemFieldSolver { FieldSolveConfig plan{}; std::vector prepared_providers; std::unique_ptr backend; + std::vector published_topology_report; std::optional contribution_scratch; std::optional published_phi_scratch; std::optional published_aux_scratch; @@ -1646,9 +1665,7 @@ class SystemFieldSolver { throw std::logic_error("System Program-install rollback lost a named field"); field.has_plan = saved->second.has_plan; field.plan = std::move(saved->second.plan); - field.backend.reset(); - field.nullspace_ready = false; - field.nullspace_workspace.reset(); + invalidate_named_backend_(field); } program_boundary_baselines_ = std::move(snapshot.boundary_baselines); candidate_program_boundary_slots_ = std::move(snapshot.candidate_boundary_slots); @@ -1658,6 +1675,7 @@ class SystemFieldSolver { static void invalidate_named_backend_(NamedField& field) { field.backend.reset(); + field.published_topology_report.clear(); field.nullspace_ready = false; field.nullspace_workspace.reset(); } @@ -1680,8 +1698,6 @@ class SystemFieldSolver { program_boundary_baselines_; std::set candidate_program_boundary_slots_; bool program_boundary_install_active_ = false; - std::map> - external_field_components_; EllipticBackendRegistry elliptic_registry_; std::shared_ptr nullspace_provider_registry_; @@ -1942,7 +1958,6 @@ class SystemFieldSolver { auto component = std::make_shared( std::move(spec), std::move(topology), std::move(solver)); register_elliptic_provider(slot, std::make_unique(component)); - external_field_components_[slot] = component; if (found == named_field_plans_.end()) return std::string(component->provider_identity()); found->second.backend_provider_identity = slot; @@ -1961,14 +1976,22 @@ class SystemFieldSolver { std::vector topology_report( const std::string& slot) const { auto field = named_fields_.find(slot); - if (field != named_fields_.end() && field->second.backend) - return field->second.backend->topology_report(); - auto external = external_field_components_.find(slot); - if (external != external_field_components_.end()) - return external->second->topology_report(); - if (named_field_plans_.find(slot) == named_field_plans_.end()) + if (field == named_fields_.end() && named_field_plans_.find(slot) == named_field_plans_.end()) throw std::runtime_error("unknown qualified field provider slot"); - return {}; + return field == named_fields_.end() ? std::vector{} + : field->second.published_topology_report; + } + + /// Stage live backend topology as candidate publication evidence. The surrounding + /// FieldPublicationSnapshot restores the previously accepted rows until SolveOutcome::accept(), + /// so a failed solve may retain a private warm cache without making it observable. + void stage_named_topology_reports() { + for (auto& [name, field] : named_fields_) { + (void)name; + field.published_topology_report = field.backend + ? field.backend->topology_report() + : std::vector{}; + } } template @@ -2957,15 +2980,21 @@ class SystemFieldSolver { template void require_collective_named_phase_(std::string_view phase, Phase&& action) const { - bool failed = false; + std::exception_ptr local_failure; try { std::forward(action)(); } catch (...) { - failed = true; - } - if (all_reduce_max(failed ? 1L : 0L) != 0) + local_failure = std::current_exception(); + } + if (all_reduce_max(local_failure ? 1L : 0L) != 0) { + // A singleton execution has no remote failure to hide. Preserve the provider's exact + // diagnostic instead of replacing it with a collective summary; multi-rank execution still + // reports one rank-independent error after every participant reaches the reduction. + if (n_ranks() == 1 && local_failure) + std::rethrow_exception(local_failure); throw std::runtime_error("System: named field " + std::string(phase) + " failed on at least one communicator rank"); + } } void require_collective_field_providers_(NamedField& field) { diff --git a/include/pops/runtime/system/system_program_driver.hpp b/include/pops/runtime/system/system_program_driver.hpp index 29ab321a1..fffa95d7d 100644 --- a/include/pops/runtime/system/system_program_driver.hpp +++ b/include/pops/runtime/system/system_program_driver.hpp @@ -167,50 +167,7 @@ class SystemProgramDriver { /// collapses the loop to one call with h == dt. void run_program_cadence(double dt) { Impl* P = owner_; - const double accepted_time = P->t; - const auto cadence = - P->program_.prepare_cadence_step(accepted_time, P->macro_step_, dt, "System"); - if (P->macro_step_ == std::numeric_limits::max()) - throw std::overflow_error("System Program cadence macro-step counter overflow"); - if (cadence.due) { - const int n = P->program_.substeps_; - P->program_.validate_cadence_partition(cadence, n, "System"); - const int accepted_macro_step = P->macro_step_; - const int held_before_due = cadence.window_steps - 1; - if (accepted_macro_step < held_before_due) - throw std::logic_error("System Program cadence window starts before macro-step zero"); - const int window_start_macro_step = accepted_macro_step - held_before_due; - try { - for (int sub = 0; sub < n; ++sub) { - const auto partition = P->program_.prepare_cadence_substep(cadence, sub, n, "System"); - // Publish the exact accepted start of this Program substep. ProgramContext derives every - // stage/boundary physical coordinate from System::time(); leaving the facade at the outer - // macro-step start would stamp every substep with the same time and would start a stride - // catch-up window one held step too late. - P->t = partition.start; - // A due stride is one logical public window, irrespective of the number of internal - // substeps. Publish its accepted start tick for every Program invocation; schedules and - // contexts must not mistake internal calls for additional public macro-steps. - P->macro_step_ = window_start_macro_step; - // Record the dt handed to the program BEFORE the call so the runtime's store_history can tag - // the slot it produces with the exact dt (ADC-626 variable-dt replay). Shared by step() and - // step_cfl() (both route here), so no call site is missed. A plain data assignment. - P->program_.last_dt_ = static_cast(partition.dt); - P->program_.step_(partition.dt); - P->t = partition.end; - } - } catch (...) { - P->t = accepted_time; - P->macro_step_ = accepted_macro_step; - throw; - } - P->macro_step_ = accepted_macro_step; - } - P->program_.commit_cadence_step(cadence, "System"); - // Use the endpoint prepared once from the accepted facade cursor. Recomputing either - // accepted_time + dt or window_start + effective_dt here would reintroduce a second authority. - P->t = cadence.window_end; // clock ticks EVERY macro-step (held steps included), like native - P->macro_step_++; + P->program_.dispatch_cadence_step(P->t, P->macro_step_, dt, "System"); } /// One macro-step of length @p dt through the installed whole-system Program. diff --git a/include/pops/validation/physics/advection_diffusion.hpp b/include/pops/validation/physics/advection_diffusion.hpp index f234d84c1..7508f69ec 100644 --- a/include/pops/validation/physics/advection_diffusion.hpp +++ b/include/pops/validation/physics/advection_diffusion.hpp @@ -42,11 +42,11 @@ struct AdvectionDiffusion { Real nu = 0.0; ///< diffusivity (0 = pure advection) /// Advection flux F = a u in direction dir. - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{(dir == 0 ? ax : ay) * u[0]}; // F = a u } /// Maximum wave speed: magnitude of the advection velocity in direction dir. - POPS_HD Real max_wave_speed(const State&, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const State&, const auto&, int dir) const { const Real v = (dir == 0) ? ax : ay; return v < 0 ? -v : v; } diff --git a/include/pops_headers.manifest b/include/pops_headers.manifest index 681943777..98a939106 100644 --- a/include/pops_headers.manifest +++ b/include/pops_headers.manifest @@ -10,15 +10,26 @@ # api, abi, sdk-root and sdk-support are all installed and authenticated by POPS_HEADER_SIG. api pops/amr/hierarchy/amr_hierarchy.hpp +api pops/amr/hierarchy/nd/berger_rigoutsos.hpp +api pops/amr/hierarchy/nd/cluster_provider.hpp +api pops/amr/hierarchy/nd/hierarchy_plan.hpp +api pops/amr/hierarchy/nd/level_layout.hpp +api pops/amr/hierarchy/nd/tag_mask.hpp api pops/amr/hierarchy/refinement_ratio.hpp +api pops/amr/nd/refinement_ratio.hpp api pops/amr/regridding/regrid.hpp +api pops/amr/reflux/nd/face_flux_ledger.hpp +api pops/amr/reflux/nd/metric_reflux.hpp api pops/amr/tagging/cluster.hpp api pops/amr/tagging/clustering_provider.hpp api pops/amr/tagging/tag_box.hpp api pops/amr/tagging/tagging_truth.hpp +api pops/amr/transfer/nd/refinement_ratio.hpp +api pops/amr/transfer/nd/transfer_provider.hpp api pops/core/foundation/allocator.hpp api pops/core/foundation/cold.hpp api pops/core/foundation/kokkos_env.hpp +api pops/core/foundation/native_dimension.hpp api pops/core/foundation/types.hpp api pops/core/foundation/validation.hpp api pops/core/identity/canonical_value.hpp @@ -48,24 +59,48 @@ api pops/diagnostics/runtime_diagnostics.hpp api pops/mesh/boundary/boundary_component_executor.hpp api pops/mesh/boundary/fill_boundary.hpp api pops/mesh/boundary/halo_schedule.hpp +api pops/mesh/boundary/nd_boundary_schedule.hpp api pops/mesh/boundary/periodicity.hpp api pops/mesh/boundary/physical_bc.hpp api pops/mesh/boundary/prepared_boundary_component.hpp api pops/mesh/boundary/prepared_boundary_plan.hpp +api pops/mesh/boundary/prepared_hyperbolic_boundary.hpp api pops/mesh/execution/for_each.hpp +api pops/mesh/geometry/coordinate_map.hpp api pops/mesh/geometry/geometry.hpp +api pops/mesh/geometry/prepared_metric_provider.hpp +test-only pops/mesh/nd_proof/box_array.hpp +test-only pops/mesh/nd_proof/box_hash.hpp +test-only pops/mesh/nd_proof/distribution.hpp +test-only pops/mesh/nd_proof/local_neighbors.hpp +test-only pops/mesh/nd_proof/multifab.hpp +test-only pops/mesh/nd_proof/periodicity.hpp +test-only pops/mesh/nd_proof/rank_space.hpp +test-only pops/mesh/nd_proof/translation_schedule.hpp +test-only pops/mesh/nd_proof/translation_exchange.hpp +api pops/mesh/index/box.hpp api pops/mesh/index/box2d.hpp api pops/mesh/index/box_hash.hpp +api pops/mesh/index/entity_index.hpp +api pops/mesh/index/extent.hpp +api pops/mesh/index/index.hpp +api pops/mesh/index/real_vector.hpp api pops/mesh/layout/box_array.hpp api pops/mesh/layout/copy_schedule.hpp api pops/mesh/layout/distribution_mapping.hpp api pops/mesh/layout/field_distribution.hpp +api pops/mesh/layout/nd/box_array.hpp +api pops/mesh/layout/nd/distribution.hpp +api pops/mesh/layout/nd/rank_space.hpp api pops/mesh/layout/patch_box.hpp api pops/mesh/layout/refinement.hpp +api pops/mesh/storage/fab.hpp api pops/mesh/storage/fab2d.hpp sdk-support pops/mesh/storage/field_replica_consensus.hpp +api pops/mesh/storage/field_view.hpp api pops/mesh/storage/mf_arith.hpp api pops/mesh/storage/multifab.hpp +api pops/mesh/topology/boundary_topology.hpp api pops/numerics/elliptic/eb/cut_fraction.hpp test-only pops/numerics/elliptic/interface/elliptic_interface.hpp api pops/numerics/elliptic/interface/elliptic_problem.hpp @@ -106,17 +141,25 @@ api pops/numerics/fv/reconstruction.hpp api pops/numerics/fv/spatial_discretisation.hpp api pops/numerics/linalg/block_inverse.hpp api pops/numerics/linalg/dense_eig.hpp +api pops/numerics/nonlinear/local_nonlinear_collective.hpp api pops/numerics/nonlinear/prepared_local_nonlinear.hpp +api pops/numerics/nonlinear/prepared_variable_recovery.hpp api pops/numerics/spatial/embedded_boundary/domain.hpp api pops/numerics/spatial/embedded_boundary/operator.hpp +api pops/numerics/spatial/nd/conservation_laws.hpp +api pops/numerics/spatial/nd/face_field.hpp +api pops/numerics/spatial/nd/finite_volume.hpp +api pops/numerics/spatial/nd/state_schema.hpp api pops/numerics/spatial/operators/cartesian_operator.hpp api pops/numerics/spatial/operators/masked_operator.hpp api pops/numerics/spatial/operators/polar_operator.hpp +api pops/numerics/spatial/operators/prepared_cartesian_nd.hpp api pops/numerics/spatial/primitives/face_flux.hpp api pops/numerics/spatial/primitives/finite.hpp api pops/numerics/spatial/primitives/positivity.hpp api pops/numerics/spatial/primitives/state_access.hpp api pops/numerics/spatial/primitives/wave_speed.hpp +api pops/numerics/spatial/provider_matrix.hpp api pops/numerics/spatial_operator.hpp api pops/numerics/time/amr/levels/amr_clock.hpp api pops/numerics/time/amr/levels/amr_patch_range.hpp @@ -146,6 +189,7 @@ api pops/physics/bricks/hyperbolic.hpp api pops/physics/bricks/source.hpp api pops/physics/composition/composite.hpp api pops/physics/fluids/euler.hpp +sdk-support pops/runtime/accelerator/prepared_stream_executor.hpp sdk-support pops/runtime/amr/amr_field_solve_transaction.hpp sdk-support pops/runtime/amr/amr_history.hpp sdk-support pops/runtime/amr/amr_program_reflux.hpp @@ -170,6 +214,7 @@ sdk-support pops/runtime/builders/block/amr_block_seam.hpp sdk-support pops/runtime/builders/block/block_builder.hpp sdk-support pops/runtime/builders/block/block_builder_polar.hpp sdk-support pops/runtime/builders/block/block_seam.hpp +sdk-support pops/runtime/builders/block/prepared_boundary_defaults.hpp sdk-root pops/runtime/builders/compiled/amr_dsl_block.hpp sdk-root pops/runtime/builders/compiled/dsl_block.hpp sdk-support pops/runtime/builders/compiled/flat_grid.hpp @@ -211,6 +256,8 @@ sdk-support pops/runtime/output_piece_collective.hpp sdk-support pops/runtime/program/amr_program_checkpoint.hpp sdk-root pops/runtime/program/amr_program_context.hpp sdk-support pops/runtime/program/cache_manager.hpp +sdk-support pops/runtime/program/cell_temporal_partition.hpp +sdk-support pops/runtime/program/cell_temporal_partition_executor.hpp sdk-support pops/runtime/program/clock_schedule.hpp sdk-root pops/runtime/program/coeff_elliptic_ops.hpp sdk-root pops/runtime/program/component_package.hpp @@ -219,12 +266,14 @@ sdk-root pops/runtime/program/external_riemann_brick.hpp sdk-support pops/runtime/program/module_metadata.hpp sdk-support pops/runtime/program/profiler.hpp sdk-root pops/runtime/program/program_context.hpp +sdk-support pops/runtime/program/same_level_cell_temporal_provider.hpp sdk-support pops/runtime/program/program_execution_services.hpp sdk-support pops/runtime/program/program_runtime_state.hpp sdk-support pops/runtime/program/residual_operator.hpp sdk-root pops/runtime/program/step_transaction.hpp abi pops/runtime/program/wire_ids.hpp api pops/runtime/runtime_environment.hpp +sdk-support pops/runtime/recovery/uniform_recovery_consumer.hpp api pops/runtime/system.hpp sdk-support pops/runtime/system/prepared_field_solver_component.hpp sdk-support pops/runtime/system/system_block_store.hpp diff --git a/python/bindings/core/init/boundary_component_install.hpp b/python/bindings/core/init/boundary_component_install.hpp index 1c12c511f..d30365ae4 100644 --- a/python/bindings/core/init/boundary_component_install.hpp +++ b/python/bindings/core/init/boundary_component_install.hpp @@ -58,6 +58,15 @@ inline runtime::field::PreparedFieldSolverSpec field_solver_spec_from_python( spec.relative_tolerance = relative_tolerance; spec.absolute_tolerance = absolute_tolerance; spec.max_iterations = max_iterations; + const auto declares_mpi = [](const py::dict& binding) { + if (!binding.contains("declared_execution")) + throw std::invalid_argument("field component binding has no execution declaration"); + const py::dict execution = py::cast(binding["declared_execution"]); + if (!execution.contains("host") || !execution.contains("mpi") || !execution.contains("gpu")) + throw std::invalid_argument("field component execution declaration is incomplete"); + return py::cast(execution["mpi"]); + }; + spec.component_pair_declares_mpi = declares_mpi(topology) && declares_mpi(solver); spec.execution = make_component_execution_context(execution_data); return spec; } diff --git a/python/bindings/core/init/generated_component_invokers.inc b/python/bindings/core/init/generated_component_invokers.inc index 5dc15e864..73d165343 100644 --- a/python/bindings/core/init/generated_component_invokers.inc +++ b/python/bindings/core/init/generated_component_invokers.inc @@ -1,4 +1,4 @@ -// Generated by scripts/generate_component_catalog.py from catalog 5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6; DO NOT EDIT. +// Generated by scripts/generate_component_catalog.py from catalog b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66; DO NOT EDIT. // This file is the sole Python/native request marshaller. init_component_loader.cpp only registers it. #include diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index 324affaa5..09f85ada9 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -1,5 +1,5 @@ #include "../bindings_detail.hpp" -#include +#include #include "boundary_component_install.hpp" #include "output_geometry_binding.hpp" @@ -182,6 +182,19 @@ pops::runtime::amr::PreparedClusteringSpec amr_clustering_spec_from_python( return spec; } +pops::runtime::amr::PreparedRefluxSpec amr_reflux_spec_from_python(const py::dict& row, + const py::dict& execution) { + pops::runtime::amr::PreparedRefluxSpec spec; + spec.provider_identity = py::cast(row["provider_identity"]); + spec.component_id = py::cast(row["component_id"]); + spec.manifest_identity = py::cast(row["component_manifest_identity"]); + spec.layout_identity = py::cast(row["layout_identity"]); + spec.clock_identity = py::cast(row["clock_identity"]); + spec.interface_version = py::cast(row["interface_version"]); + spec.execution = pops::python::detail::make_component_execution_context(execution); + return spec; +} + // Assembly seams: per-block composition, native block, and refinement tagging. void bind_amr_assembly(py::class_& cls) { cls.def(py::init()) @@ -234,19 +247,34 @@ void bind_amr_assembly(py::class_& cls) { "_install_boundary_plan", [](AmrSystem& system, const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, - const std::vector>& periodic_identifications) { + const std::vector>& periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { system.install_boundary_plan( - name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows(periodic_identifications)); + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows(periodic_identifications), face_representations, + face_converter_identities, face_analytic_opcodes, face_analytic_literals, + face_analytic_clocks); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), - py::arg("face_values"), py::arg("ncomp"), + py::arg("face_values"), py::arg("face_identities"), py::arg("component_roles"), py::arg("omitted_interface_faces") = std::vector{}, py::arg("state_identity") = std::string{}, py::arg("periodic_identifications") = std::vector>{}, + py::arg("face_representations") = std::vector{}, + py::arg("face_converter_identities") = std::vector{}, + py::arg("face_analytic_opcodes") = std::vector>{}, + py::arg("face_analytic_literals") = std::vector>{}, + py::arg("face_analytic_clocks") = std::vector{}, "Install one resolved per-block ghost-production plan before lazy AMR construction.") .def("_install_block_state_route", &AmrSystem::install_block_state_route, py::arg("name"), py::arg("state_identity"), @@ -272,6 +300,14 @@ void bind_amr_assembly(py::class_& cls) { amr_clustering_spec_from_python(binding, execution), std::move(component)); }, py::arg("component"), py::arg("binding"), py::arg("execution_context")) + .def( + "_install_amr_reflux_component", + [](AmrSystem& system, std::shared_ptr component, + const py::dict& binding, const py::dict& execution) { + system.install_amr_reflux_component(amr_reflux_spec_from_python(binding, execution), + std::move(component)); + }, + py::arg("component"), py::arg("binding"), py::arg("execution_context")) .def("_discard_amr_provider_components", &AmrSystem::discard_amr_provider_components, "Roll back one failed external AMR provider transaction.") .def( @@ -288,6 +324,20 @@ void bind_amr_assembly(py::class_& cls) { }, py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), py::arg("target_json"), py::arg("execution_context")) + .def( + "_install_boundary_flux_component", + [](AmrSystem& system, const std::string& name, + std::shared_ptr component, const py::dict& row, + const std::string& parameters_json, const std::string& target_json, + const py::dict& execution) { + system.install_boundary_flux_component( + name, + pops::python::detail::boundary_component_spec_from_python(row, parameters_json, + target_json, execution), + std::move(component)); + }, + py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), + py::arg("target_json"), py::arg("execution_context")) .def( "_install_field_boundary_residual_component", [](AmrSystem& system, const std::string& name, @@ -431,6 +481,30 @@ void bind_amr_assembly(py::class_& cls) { py::arg("provider_coefficients"), py::arg("solver"), py::arg("hierarchy_policy_id"), py::arg("hierarchy_policy_interface_version"), py::arg("hierarchy_policy_option_schema"), py::arg("hierarchy_policy_options"), py::arg("schema_identity"), py::arg("options")) + .def( + "register_field_solver_provider", + [](AmrSystem& system, const std::string& provider_slot, + std::shared_ptr topology, + std::shared_ptr solver, + const py::dict& topology_binding, const py::dict& solver_binding, + const std::string& topology_parameters_json, const std::string& solver_parameters_json, + const std::string& source_layout_identity, const std::string& topology_recipe_identity, + const std::string& boundary_contract_json, double relative_tolerance, + double absolute_tolerance, std::int32_t max_iterations, const py::dict& execution) { + auto spec = pops::python::detail::field_solver_spec_from_python( + provider_slot, topology_binding, solver_binding, topology_parameters_json, + solver_parameters_json, source_layout_identity, topology_recipe_identity, + boundary_contract_json, relative_tolerance, absolute_tolerance, max_iterations, + execution); + return system.register_field_solver_provider(provider_slot, std::move(spec), + std::move(topology), std::move(solver)); + }, + py::arg("provider_slot"), py::arg("topology_component"), py::arg("solver_component"), + py::arg("topology_binding"), py::arg("solver_binding"), + py::arg("topology_parameters_json"), py::arg("solver_parameters_json"), + py::arg("source_layout_identity"), py::arg("topology_recipe_identity"), + py::arg("boundary_contract_json"), py::arg("relative_tolerance"), + py::arg("absolute_tolerance"), py::arg("max_iterations"), py::arg("execution_context")) .def( "field_solver_configuration", [](const AmrSystem& system, const std::string& provider_slot) { @@ -793,6 +867,15 @@ void bind_amr_program(py::class_& cls) { // IR hash of the installed compiled Program (the .so's pops_program_hash), or "" if none. Parity // System::installed_program_hash (the checkpoint guard). .def("installed_program_hash", &AmrSystem::installed_program_hash) + // Exact Program-index -> AMR-block-index map established by name at install. Expose only + // immutable report metadata, never a structural mutation route. + .def("program_block_map", &AmrSystem::program_block_map) + .def( + "program_param_count", + [](const AmrSystem& system, int program_block) { + return system.program_params(program_block).count; + }, + py::arg("program_block")) .def("program_accepted_state", [](const AmrSystem& s) { const auto bytes = s.program_accepted_state(); @@ -824,6 +907,7 @@ void bind_amr_program(py::class_& cls) { py::arg("payload"), py::arg("names"), py::arg("depths"), py::arg("ncomps")) .def("program_accepted_state_manifest", &AmrSystem::program_accepted_state_manifest) .def("program_clock_manifest", &AmrSystem::program_clock_manifest) + .def("program_temporal_partition_manifest", &AmrSystem::program_temporal_partition_manifest) .def("program_flux_ledger_manifest", &AmrSystem::program_flux_ledger_manifest) .def("program_interface_flux_ledger_manifest", &AmrSystem::program_interface_flux_ledger_manifest) @@ -837,6 +921,10 @@ void bind_amr_program(py::class_& cls) { // driver records a measured scalar into each cadence tick. .def("program_diagnostic", &AmrSystem::program_diagnostic, py::arg("name")) .def("program_diagnostics", &AmrSystem::program_diagnostics) + .def("_accepted_balance_terms", &AmrSystem::accepted_balance_terms, py::arg("route")) + .def("_selected_accepted_balance_terms", &AmrSystem::selected_accepted_balance_terms, + py::arg("route"), py::arg("block"), py::arg("component"), py::arg("levels"), + py::arg("automatic_terms")) .def("_consume_step_projections", &AmrSystem::consume_step_projections) .def("record_program_diagnostic", &AmrSystem::record_program_diagnostic, py::arg("name"), py::arg("value")) @@ -970,16 +1058,16 @@ void bind_amr_data(py::class_& cls) { "Exact compact valid-cell pieces of one qualified field owned by this rank.") .def( "output_field_root_pieces", - [](AmrSystem& s, const WorldCommunicator& world, const std::string& provider_slot, + [](AmrSystem& s, const ObserverMpiLane& lane, const std::string& provider_slot, int level) { std::vector pieces; { py::gil_scoped_release release; - pieces = s.output_field_root_pieces(world, provider_slot, level); + pieces = s.output_field_root_pieces(lane, provider_slot, level); } return output_pieces_to_python(pieces); }, - py::arg("world"), py::arg("provider_slot"), py::arg("level"), + py::arg("lane"), py::arg("provider_slot"), py::arg("level"), "Collectively gather compact field pieces in C++; complete only on MPI rank zero.") .def( "_output_geometry_snapshot", @@ -1022,15 +1110,15 @@ void bind_amr_data(py::class_& cls) { "Exact compact valid-cell pieces of one qualified state owned by this rank.") .def( "output_state_root_pieces", - [](AmrSystem& s, const WorldCommunicator& world, const std::string& name, int level) { + [](AmrSystem& s, const ObserverMpiLane& lane, const std::string& name, int level) { std::vector pieces; { py::gil_scoped_release release; - pieces = s.output_state_root_pieces(world, name, level); + pieces = s.output_state_root_pieces(lane, name, level); } return output_pieces_to_python(pieces); }, - py::arg("world"), py::arg("block"), py::arg("level"), + py::arg("lane"), py::arg("block"), py::arg("level"), "Collectively gather compact state pieces in C++; complete only on MPI rank zero.") .def( "set_block_level_state", diff --git a/python/bindings/core/init/init_parallel_hdf5.cpp b/python/bindings/core/init/init_parallel_hdf5.cpp index d2427257c..5e8cf2bde 100644 --- a/python/bindings/core/init/init_parallel_hdf5.cpp +++ b/python/bindings/core/init/init_parallel_hdf5.cpp @@ -1,7 +1,6 @@ #include "../bindings_detail.hpp" #include -#include #include #include @@ -97,21 +96,12 @@ void init_parallel_hdf5(py::module_& m) { [](const py::object& communicator_value, const py::object& path_value, const py::object& manifest_value, const py::object& root_arrays_value, const py::object& field_rows_value) { - pops::CommunicatorView communicator; - if (py::isinstance(communicator_value)) { - auto& world = communicator_value.cast(); - if (&world != &pops::WorldCommunicator::world()) - throw py::value_error("native HDF5 requires the exact process-world authority"); - communicator = world.communicator(); - } else if (py::isinstance(communicator_value)) { - auto& lane = communicator_value.cast(); - if (!lane.active()) - throw py::value_error("native HDF5 observer lane is closed"); - communicator = lane.communicator(); - } else { - throw py::type_error( - "native HDF5 requires a PoPS world communicator or observer MPI lane"); - } + if (!py::isinstance(communicator_value)) + throw py::type_error("native HDF5 requires an exact duplicated observer MPI lane"); + auto& lane = communicator_value.cast(); + if (!lane.active()) + throw py::value_error("native HDF5 observer lane is closed"); + const pops::CommunicatorView communicator = lane.communicator(); std::vector owners; std::vector arrays; std::vector fields; diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index 40153d799..725004ba4 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -1,5 +1,5 @@ #include "../bindings_detail.hpp" -#include +#include #include "boundary_component_install.hpp" #include "output_geometry_binding.hpp" @@ -146,8 +146,8 @@ void bind_system_assembly(py::class_& cls) { // bit-identical. Resolved on the C++ side against the block's names/roles (error on a missing name/role). py::arg("implicit_vars") = std::vector{}, py::arg("implicit_roles") = std::vector{}, - // Options of the implicit IMEX source Newton. newton_diagnostics=True enables the report - // (newton_report(name)). + // Options of the implicit IMEX source Newton. The Program-only System runtime rejects + // newton_diagnostics=True until a typed consumer actually publishes that report. py::arg("newton_max_iters") = kNewtonDefaultMaxIters, py::arg("newton_rel_tol") = static_cast(kNewtonDefaultRelTol), py::arg("newton_abs_tol") = static_cast(kNewtonDefaultAbsTol), @@ -169,19 +169,34 @@ void bind_system_assembly(py::class_& cls) { "_install_boundary_plan", [](System& system, const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, - const std::vector>& periodic_identifications) { + const std::vector>& periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { system.install_boundary_plan( - name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows(periodic_identifications)); + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows(periodic_identifications), face_representations, + face_converter_identities, face_analytic_opcodes, face_analytic_literals, + face_analytic_clocks); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), - py::arg("face_values"), py::arg("ncomp"), + py::arg("face_values"), py::arg("face_identities"), py::arg("component_roles"), py::arg("omitted_interface_faces") = std::vector{}, py::arg("state_identity") = std::string{}, py::arg("periodic_identifications") = std::vector>{}, + py::arg("face_representations") = std::vector{}, + py::arg("face_converter_identities") = std::vector{}, + py::arg("face_analytic_opcodes") = std::vector>{}, + py::arg("face_analytic_literals") = std::vector>{}, + py::arg("face_analytic_clocks") = std::vector{}, "Install one resolved per-block ghost-production plan before block construction.") .def("_install_block_state_route", &System::install_block_state_route, py::arg("name"), py::arg("state_identity"), @@ -205,6 +220,20 @@ void bind_system_assembly(py::class_& cls) { }, py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), py::arg("target_json"), py::arg("execution_context")) + .def( + "_install_boundary_flux_component", + [](System& system, const std::string& name, + std::shared_ptr component, const py::dict& row, + const std::string& parameters_json, const std::string& target_json, + const py::dict& execution) { + system.install_boundary_flux_component( + name, + pops::python::detail::boundary_component_spec_from_python(row, parameters_json, + target_json, execution), + std::move(component)); + }, + py::arg("name"), py::arg("component"), py::arg("binding"), py::arg("parameters_json"), + py::arg("target_json"), py::arg("execution_context")) .def( "_install_field_boundary_residual_component", [](System& system, const std::string& name, @@ -253,9 +282,7 @@ void bind_system_assembly(py::class_& cls) { py::arg("level") = 0) .def("_discard_interface_flux_components", &System::discard_interface_flux_components, "Roll back one failed post-block interface authority transaction.") - // Newton report (IMEX diagnostics OPT-IN): dict {enabled, converged, max_residual, - // max_iters_used, n_failed, failed_cell, failed_component}, aggregated over the substeps of the - // LAST advance of the block. failed_cell = (i, j) of ONE faulty cell or None. + // Compatibility query for a Newton report published by a typed implicit Program consumer. .def( "newton_report", [](const System& s, const std::string& name) { @@ -339,6 +366,18 @@ void bind_system_program(py::class_& cls) { // ADC-406b: IR hash of the installed compiled Program (the .so's pops_program_hash), or "" if // none. sim.checkpoint records it; sim.restart rejects a restart against a DIFFERENT Program. .def("installed_program_hash", &System::installed_program_hash) + // Exact Program-index -> System-index map established by name during install_program. The + // structured runtime report consumes this owned native fact; an empty Python-side fallback + // must never be mistaken for an identity map in a sliced multi-layout Program. + .def("program_block_map", &System::program_block_map) + // Metadata-only parameter occupancy for ProgramRuntimeReport. Keep the fixed-size values + // private while exposing the native count that proves every compiled carrier was installed. + .def( + "program_param_count", + [](const System& system, int program_block) { + return system.program_params(program_block).count; + }, + py::arg("program_block")) // ADC-592: runtime freeze lifecycle. mark_bound() (called LAST by the Python bind flow) freezes // the composition -> every structural setter then rejects; lifecycle_state() reports // assembling / bound / running (running derived from macro_step()). @@ -354,6 +393,10 @@ void bind_system_program(py::class_& cls) { // program_diagnostics() returns the whole name -> value dict. .def("program_diagnostic", &System::program_diagnostic, py::arg("name")) .def("program_diagnostics", &System::program_diagnostics) + .def("_accepted_balance_terms", &System::accepted_balance_terms, py::arg("route")) + .def("_selected_accepted_balance_terms", &System::selected_accepted_balance_terms, + py::arg("route"), py::arg("block"), py::arg("component"), py::arg("levels"), + py::arg("automatic_terms")) .def("_consume_step_projections", &System::consume_step_projections) // ADC-542: the native collective reduction over a named block the diagnostics driver drives to // fire a declared typed measure (Norm / Integral / MinMax) each cadence tick, and the sink the @@ -921,28 +964,27 @@ void bind_system_data(py::class_& cls) { "Exact compact valid-cell field pieces owned by this rank.") .def( "output_state_root_pieces", - [](const System& s, const WorldCommunicator& world, const std::string& block, int level) { + [](const System& s, const ObserverMpiLane& lane, const std::string& block, int level) { std::vector pieces; { py::gil_scoped_release release; - pieces = s.output_state_root_pieces(world, block, level); + pieces = s.output_state_root_pieces(lane, block, level); } return output_pieces_to_python(pieces); }, - py::arg("world"), py::arg("block"), py::arg("level"), + py::arg("lane"), py::arg("block"), py::arg("level"), "Collectively gather compact state pieces in C++; complete only on MPI rank zero.") .def( "output_field_root_pieces", - [](System& s, const WorldCommunicator& world, const std::string& provider_slot, - int level) { + [](System& s, const ObserverMpiLane& lane, const std::string& provider_slot, int level) { std::vector pieces; { py::gil_scoped_release release; - pieces = s.output_field_root_pieces(world, provider_slot, level); + pieces = s.output_field_root_pieces(lane, provider_slot, level); } return output_pieces_to_python(pieces); }, - py::arg("world"), py::arg("provider_slot"), py::arg("level"), + py::arg("lane"), py::arg("provider_slot"), py::arg("level"), "Collectively gather compact field pieces in C++; complete only on MPI rank zero.") .def( "_output_geometry_snapshot", diff --git a/python/pops/_balance_contract.py b/python/pops/_balance_contract.py new file mode 100644 index 000000000..14499db5a --- /dev/null +++ b/python/pops/_balance_contract.py @@ -0,0 +1,139 @@ +"""Core typed identity shared by Program balance evidence and output consumers. + +This module deliberately lives outside :mod:`pops.diagnostics`: Program/codegen imports must not +make every PoPS test transitively depend on the public diagnostics package initializer. The public +``pops.diagnostics.BalanceLedger`` name is an alias of this exact class. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from pops.identity import Identity, make_identity + + +BALANCE_TERM_NAMES = ( + "storage_change", + "outward_boundary_flux", + "sources", + "reflux", + "projection", +) + + +def _canonical_name(value: Any, *, where: str) -> str: + if not isinstance(value, str) or not value or value.strip() != value: + raise TypeError("%s must be non-empty canonical text" % where) + return value + + +@dataclass(frozen=True, slots=True) +class BalanceLedger: + """Identity joining one Program-authored discrete balance to one consumer. + + The ledger does not contain values. :meth:`Program.record_balance` writes the explicitly + authored reduced scalars into the current native step-attempt mailbox. A ledger may delegate + ``reflux`` and/or ``projection`` to exact native operators for one typed component role, while + :class:`pops.diagnostics.Balance` selects the same identity after that attempt has advanced + successfully. + """ + + name: str + role: Any = None + component: int | None = None + automatic_terms: tuple[str, ...] = () + identity: Identity = field(init=False) + __pops_ir_immutable__ = True + + def __post_init__(self) -> None: + name = _canonical_name(self.name, where="BalanceLedger.name") + role = None + if self.role is not None: + from pops.physics.roles import native_role_token + + try: + role = native_role_token(self.role) + except TypeError as exc: + raise TypeError( + "BalanceLedger.role must be a typed pops.physics.roles.ComponentRole" + ) from exc + if not isinstance(self.automatic_terms, tuple): + raise TypeError("BalanceLedger.automatic_terms must be a tuple") + automatic_terms = tuple(sorted(set(self.automatic_terms))) + if len(automatic_terms) != len(self.automatic_terms): + raise ValueError("BalanceLedger.automatic_terms must be unique") + unsupported = set(automatic_terms).difference({"reflux", "projection"}) + if unsupported: + raise ValueError( + "BalanceLedger automatic native producers currently support only " + "reflux and projection; got %s" % sorted(unsupported) + ) + component = self.component + if automatic_terms and component is None: + component = 0 + if component is not None and (type(component) is not int or component < 0): + raise TypeError("BalanceLedger.component must be a non-negative int or None") + object.__setattr__(self, "name", name) + object.__setattr__(self, "component", component) + object.__setattr__(self, "automatic_terms", automatic_terms) + payload: dict[str, Any] = {"schema_version": 1, "name": name} + if role is not None: + payload["role"] = role + if component is not None: + payload["component"] = component + if automatic_terms: + payload["automatic_terms"] = list(automatic_terms) + object.__setattr__( + self, + "identity", + make_identity("balance-ledger", payload), + ) + + def to_data(self) -> dict[str, Any]: + data = { + "schema_version": 1, + "name": self.name, + "identity": self.identity.to_data(), + } + if self.role is not None: + from pops.physics.roles import native_role_token + + data["role"] = native_role_token(self.role) + if self.component is not None: + data["component"] = self.component + if self.automatic_terms: + data["automatic_terms"] = list(self.automatic_terms) + return data + + def route_identity(self, block: Any) -> Identity: + from pops.problem.handles import BlockHandle + + if not isinstance(block, BlockHandle): + raise TypeError("balance ledger block must be a BlockHandle") + return make_identity( + "balance-ledger-route", + { + "schema_version": 1, + "ledger": self.identity.to_data(), + # Program records this route before Case resolution. Runtime block names are unique + # inside one Case/Program; the consumer separately carries the canonical block and + # state identity. + "runtime_block": block.local_id, + }, + ) + + +def balance_record_name(route: Any, term: Any) -> str: + """Return the reserved native Program diagnostic key for one exact term.""" + if ( + type(route) is not Identity + or route.domain != "balance-ledger-route" + or route.schema_version != 1 + ): + raise TypeError("balance route must be an exact balance-ledger-route Identity") + if term not in BALANCE_TERM_NAMES: + raise ValueError("unknown balance term %r" % (term,)) + return "pops.balance-term.v1:%s:%s" % (route.token, term) + + +__all__ = ["BALANCE_TERM_NAMES", "BalanceLedger", "balance_record_name"] diff --git a/python/pops/_balance_due_contract.py b/python/pops/_balance_due_contract.py new file mode 100644 index 000000000..f2bf15186 --- /dev/null +++ b/python/pops/_balance_due_contract.py @@ -0,0 +1,229 @@ +"""Core typed bridge from one resolved ConsumerGraph to Balance producers.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from pops.identity import Identity, make_identity +from pops.time._schedule.api import Always, Every, Schedule, When +from pops.time._schedule.domains import AcceptedStep + + +_MAX_NATIVE_ACCEPTED_STEP = (1 << 31) - 1 + + +def _identity(value: Any, domain: str, *, where: str) -> Identity: + if type(value) is not Identity or value.domain != domain or value.schema_version != 1: + raise TypeError("%s must be an exact version-1 %s Identity" % (where, domain)) + return Identity.from_data(value.to_data()) + + +@dataclass(frozen=True, slots=True) +class BalanceDueConsumer: + """One exact ConsumerGraph node whose schedule requests a balance route.""" + + consumer: Identity + schedule: Schedule + + def __post_init__(self) -> None: + object.__setattr__( + self, + "consumer", + _identity( + self.consumer, + "consumer-manifest", + where="BalanceDueConsumer.consumer", + ), + ) + if type(self.schedule) is not Schedule: + raise TypeError("BalanceDueConsumer.schedule must be an exact Schedule") + + def to_data(self) -> dict[str, Any]: + return { + "consumer": self.consumer.to_data(), + "schedule": self.schedule.to_data(), + } + + +@dataclass(frozen=True, slots=True) +class BalanceDueRoute: + """All consumer schedules that request one exact native balance route.""" + + route: Identity + consumers: tuple[BalanceDueConsumer, ...] + automatic_terms: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__( + self, + "route", + _identity( + self.route, + "balance-ledger-route", + where="BalanceDueRoute.route", + ), + ) + if not isinstance(self.consumers, tuple) or any( + type(value) is not BalanceDueConsumer for value in self.consumers + ): + raise TypeError( + "BalanceDueRoute.consumers must contain exact BalanceDueConsumer values" + ) + consumers = tuple( + sorted(self.consumers, key=lambda value: value.consumer.token) + ) + identities = [value.consumer.token for value in consumers] + if len(identities) != len(set(identities)): + raise ValueError("BalanceDueRoute contains a duplicate consumer") + object.__setattr__(self, "consumers", consumers) + if not isinstance(self.automatic_terms, tuple): + raise TypeError("BalanceDueRoute.automatic_terms must be a tuple") + if self.automatic_terms != tuple(sorted(set(self.automatic_terms))): + raise ValueError("BalanceDueRoute.automatic_terms must be sorted and unique") + if set(self.automatic_terms).difference({"reflux", "projection"}): + raise ValueError("BalanceDueRoute names an unavailable automatic balance producer") + + def to_data(self) -> dict[str, Any]: + data = { + "route": self.route.to_data(), + "consumers": [value.to_data() for value in self.consumers], + } + if self.automatic_terms: + data["automatic_terms"] = list(self.automatic_terms) + return data + + def accepted_step_periods(self) -> tuple[int, ...]: + """Return exact native periods, conservatively using period one when unprovable. + + ``Every(n)`` on the accepted-step domain is the first optimized cutover. ``Always`` and a + statically true ``When`` are exactly period one; a statically false ``When`` contributes no + occurrence. Any other domain/trigger remains active every step so this optimization can + never suppress evidence required by a ConsumerGraph extension or physical-time cadence. + """ + periods = [] + for row in self.consumers: + schedule = row.schedule + if type(schedule.domain) is not AcceptedStep: + return (1,) + trigger = schedule.trigger + if type(trigger) is Every: + # The native facade's public macro-step is a signed 32-bit ``int`` and rejects + # overflow before increment. A larger positive period can therefore never fire in + # any representable run; omit it instead of emitting an implementation-defined C++ + # narrowing conversion. + if trigger.n <= _MAX_NATIVE_ACCEPTED_STEP: + periods.append(trigger.n) + elif type(trigger) is Always: + periods.append(1) + elif type(trigger) is When and type(trigger.condition) is bool: + if trigger.condition: + periods.append(1) + else: + return (1,) + if 1 in periods: + return (1,) + return tuple(sorted(set(periods))) + + +@dataclass(frozen=True, slots=True) +class BalanceDueContract: + """Immutable ConsumerGraph-derived cadence authority consumed by native codegen.""" + + consumer_graph: Identity | None + routes: tuple[BalanceDueRoute, ...] + identity: Identity = field(init=False) + + def __post_init__(self) -> None: + if self.consumer_graph is not None: + object.__setattr__( + self, + "consumer_graph", + _identity( + self.consumer_graph, + "consumer-graph", + where="BalanceDueContract.consumer_graph", + ), + ) + if not isinstance(self.routes, tuple) or any( + type(value) is not BalanceDueRoute for value in self.routes + ): + raise TypeError( + "BalanceDueContract.routes must contain exact BalanceDueRoute values" + ) + routes = tuple(sorted(self.routes, key=lambda value: value.route.token)) + tokens = [value.route.token for value in routes] + if len(tokens) != len(set(tokens)): + raise ValueError("BalanceDueContract contains a duplicate route") + object.__setattr__(self, "routes", routes) + object.__setattr__( + self, + "identity", + make_identity("balance-due-contract", self._payload()), + ) + + @classmethod + def from_consumer_graph(cls, graph: Any) -> BalanceDueContract: + from pops.output._consumer_contracts import ConsumerGraph + + if graph is None: + return cls(None, ()) + if type(graph) is not ConsumerGraph or not graph.is_resolved: + raise TypeError( + "BalanceDueContract requires an exact resolved ConsumerGraph or None" + ) + by_route: dict[ + str, tuple[Identity, list[BalanceDueConsumer], tuple[str, ...]] + ] = {} + for manifest in graph.nodes: + for quantity in manifest.diagnostic_quantities: + for operation in quantity.execution["operations"]: + if operation["reduction"] != "accepted_balance": + continue + route = Identity.from_token(operation["balance_route"]) + _identity( + route, + "balance-ledger-route", + where="accepted balance operation route", + ) + automatic_terms = tuple(operation.get("automatic_terms", ())) + existing = by_route.setdefault( + route.token, (route, [], automatic_terms) + ) + if existing[2] != automatic_terms: + raise ValueError( + "one balance route cannot select different automatic producers" + ) + existing[1].append( + BalanceDueConsumer(manifest.identity, manifest.schedule) + ) + return cls( + graph.identity, + tuple( + BalanceDueRoute(route, tuple(consumers), automatic_terms) + for route, consumers, automatic_terms in by_route.values() + ), + ) + + def _payload(self) -> dict[str, Any]: + return { + "schema_version": 1, + "consumer_graph": ( + None if self.consumer_graph is None else self.consumer_graph.to_data() + ), + "routes": [value.to_data() for value in self.routes], + } + + def to_data(self) -> dict[str, Any]: + return {**self._payload(), "identity": self.identity.to_data()} + + def route(self, route: str) -> BalanceDueRoute | None: + if not isinstance(route, str) or not route: + raise TypeError("balance due route lookup requires non-empty text") + return next((value for value in self.routes if value.route.token == route), None) + + +__all__ = [ + "BalanceDueConsumer", + "BalanceDueContract", + "BalanceDueRoute", +] diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index eb558ec31..22fa36925 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -374,6 +374,275 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: mpi = bool(_flag_value(flags, "supports_mpi")) gpu = bool(_flag_value(flags, "supports_gpu")) return [ + _row( + "boundary:prepared_transport", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "one prepared 2D model-aware plan serves Uniform/AMR native and compiled " + "transport boundaries; executable built-ins are periodic, extrapolation, " + "constant/RuntimeParam fixed state, conservative device-side analytic " + "(x,y,t,params) fixed state, model primitive-to-conservative fixed-state conversion, " + "typed-role slip wall, and typed no-flux faces that extrapolate ghosts then zero " + "the evaluated numerical flux before divergence/reflux; dynamic AMR regrid keeps internal " + "coarse-fine ghosts under the prepared transfer authority on MPI ranks, with " + "double-physical corners explicitly not required by dimension-split FV stencils; " + "numerical resolution rejects every descriptor outside this executable envelope" + ), + source=source, + ), + _row( + "boundary:characteristic_no_inflow", + layout="uniform|amr", + backend="production", + platform="host", + mpi=False, + gpu=False, + status="partial", + limitation=( + "2D Cartesian conservative constant/RuntimeParam fixed-reference no-inflow uses " + "the exact compiled " + "flux-Jacobian provider emitted by m.roe_from_jacobian() (1..16 components); " + "the Kokkos kernel projects only outward-normal incoming modes, treats the " + "scale-relative sonic subspace as neutral, preflights the real spectrum " + "collectively, and rolls back ghosts on refusal; primitive/analytic references, " + "runtime/field-dependent eigenstructure, sonic-error policy, 3D, polar/embedded " + "geometry, and qualified MPI/GPU execution remain unavailable" + ), + requested="characteristic no-inflow/outflow transport boundary", + available_route=( + "Inflow(state=U, value=U_ref, " + "characteristic=model_characteristic_no_inflow(U))" + ), + alternative=( + "use fixed-state inflow/extrapolated outflow outside the qualified envelope" + ), + source=source, + ), + _row( + "boundary:representation_conversion", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "2D fixed-state primitive inflow may use the exact compiled block-model " + "to_conservative provider; conservative-to-primitive recovery and arbitrary " + "representation converters remain unavailable, and conversion does not invent " + "a boundary admissibility projection" + ), + source=source, + ), + _row( + "boundary:analytic_xtp", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "2D conservative fixed-state inflow accepts data-only analytic ScalarExpr " + "programs over typed coordinates, one exact logical Clock, and bound parameters; " + "primitive per-point conversion and discrete state/field/input reads remain " + "unavailable, analytic ghost depth may not exceed the normal domain extent, and " + "axis-permuted periodic coordinates require a prepared coordinate map" + ), + source=source, + ), + _row( + "boundary:post_riemann_flux", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=False, + status="partial", + limitation=( + "one typed BoundaryFlux component transforms the already evaluated outward-normal " + "face flux between the Riemann solve and divergence/reflux through the same " + "prepared Uniform/AMR boundary plan; execution is currently a 2D Cartesian " + "host-batch route, the ordinary Uniform route materializes face fields when this " + "stage is selected, and no device-native or embedded/cut-cell metric ABI or " + "high-level TransportBoundarySet convenience exists yet" + ), + requested="post-Riemann transport-boundary flux provider", + available_route="PostRiemannFlux plus one qualified BoundaryFlux component", + source=source, + ), + _row( + "riemann:typed_failure_outcome", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "Rusanov, HLL, HLLC, and Roe return one device-copyable FluxEvaluation with " + "typed status, stability bound, reason code, requested/used/last solver identity, " + "and attempt metadata; single-solver routes remain explicit and face failures are " + "reduced into the owning transaction, while fallback counters and restart " + "publication metadata are not yet wired" + ), + source=source, + ), + _row( + "riemann:prepared_recovery_policy", + layout="uniform|amr", + backend="production", + platform="host", + mpi=False, + gpu=False, + status="partial", + limitation=( + "the typed public riemann.Recovery descriptor lowers one catalog-authenticated " + "Roe -> HLL -> Rusanov -> reject PreparedRiemannRecoveryPolicy into Uniform and " + "AMR Cartesian face kernels and records requested, used, last-attempted, " + "first-cause, and attempt-count provenance; only typed candidate rejection " + "advances, while polar geometry is refused and block/team counters, MPI fallback " + "reduction, GPU qualification, restart metadata, and a benchmark gate remain" + ), + requested=( + "prepared Riemann recovery chain with requested/used solver diagnostics" + ), + available_route=( + "pops.numerics.riemann.Recovery(primary=Roe(), " + "fallbacks=(HLL(), Rusanov()))" + ), + alternative=( + "select one supported Riemann route explicitly and consume rejection through " + "the step retry/failure policy" + ), + source=source, + ), + _row( + "recovery:prepared_variable", + layout="uniform|amr", + backend="production", + platform="host", + mpi=mpi, + gpu=gpu, + status="partial", + limitation=( + "one block-prepared closed-form method returns a device-copyable " + "RecoveryOutcome/RecoveryReport retaining selected and last-attempted method " + "kinds across type erasure; System conservative-to-primitive and transactional " + "analytic initial-state materialization plus Cartesian, polar, masked, and " + "embedded-boundary face " + "reconstruction consume publication permission before copying or flux " + "evaluation; primitive-to-conservative setup conversion publishes only a finite " + "candidate accepted by that same prepared inverse authority; accepted AMR " + "regrid prolongation and restriction candidates pass the block-prepared inverse " + "authority collectively before replacing live hierarchy state; AMR bootstrap " + "commits, rematerialized history slots, and physical boundary traces use that " + "same publication gate and roll back exactly on refusal; generated Program " + "terminal commits validate every Uniform or AMR live-state candidate before the " + "first multi-block copy, including endpoints assembled from model-local and " + "coupled sources, with no implicit repair or fallback; the host Uniform " + "get_primitive_state materializer additionally owns one exact-state and " + "generation-qualified warm-start slot per local cell, publishes only complete " + "batches, and invalidates every slot after a refused batch" + ), + source=source, + ), + _row( + "recovery:complete_consumer_cutover", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "manual in-place Program writes, persistent warm starts outside the host Uniform " + "diagnostic materializer (spatial kernels and AMR), cache restart, and the " + "backend/performance matrix do not yet share one prepared recovery authority" + ), + requested="complete prepared variable-recovery consumer cutover", + available_route=( + "prepared closed-form recovery for System conservative-to-primitive and " + "transactional analytic initial-state materialization plus spatial face " + "reconstruction, fallible primitive-to-conservative setup conversion, and " + "transactional AMR regrid prolongation/restriction, bootstrap/history, and " + "physical boundary-trace publication, plus generated Program terminal commit " + "validation for model-local and coupled-source endpoints, and exact-state " + "generation-qualified warm starts for host Uniform primitive materialization" + ), + alternative=( + "use generated Program candidate commits and the delivered recovery consumers, or " + "implement the missing in-place-write, AMR/spatial warm-start, and cache/restart " + "contracts" + ), + source=source, + ), + _row( + "amr:cell_local_temporal_transport", + layout="amr", + backend="production", + platform="host", + mpi=False, + gpu=False, + status="partial", + limitation=( + "Program.cell_local_time and its generated AmrProgramContext route cover one " + "serial host rank, one 2D block, one level, one owned box, one common cell rung, " + "transport-only forward Euler and frozen attempt auxiliary fields with built-in " + "periodic/Foextrap boundaries; the provider reuses the exact compiled AMR " + "residual/face-flux closure " + "and commits real conservative state plus four time-integrated face records per " + "cell as one accepted transaction at the synchronization barrier; its exact " + "contract includes model-owned transport parameters and the limiter/Riemann route; " + "same-topology restart restores numerical state and exact clocks but intentionally " + "invalidates the last-interval diagnostic flux ledger until another accepted step; " + "prepared physical-boundary plans, heterogeneous rungs, multi-box/multilevel and " + "coarse/fine ledgers, sources, MPI, GPU, regrid/rank-change rematerialization, " + "checkpoint persistence of the diagnostic ledger and performance proof remain " + "unavailable" + ), + requested="prepared cell-local scientific stage and space-time flux transaction", + available_route=( + "Program.cell_local_time plus the generated AmrProgramContext and native " + "PreparedSameLevelTransportEulerStageFluxProvider in their exact bounded " + "host/serial same-rung envelope" + ), + alternative=( + "use the synchronous AMR Program route outside that envelope, or implement the " + "missing prepared local-time provider family" + ), + source=source, + ), + _row( + "amr:external_field_solver_v2", + layout="amr", + backend="component", + platform="host", + mpi=mpi, + gpu=False, + status="available", + limitation=( + "host float64 and ratio-2 AMR only; MPI requires both components to declare " + "MPI_COMM_WORLD and " + "a distributed coarse level; executable MPI qualification currently covers " + "exactly two ranks with distributed L0/L1 and regrid rematerialization; " + "embedded/cut-cell topology, dynamic boundaries, reaction terms, nonlinear/JVP " + "solves and GPU execution remain explicit refusals" + ), + requested="external FieldSolver@2 on an AMR hierarchy", + available_route=( + "authenticated FieldTopology@2 + FieldSolver@2 composite hierarchy batch with " + "metadata.level, binary coarse/fine coverage, one collective solve, exact " + "materialization/report consensus and transactional candidate publication" + ), + alternative="", + source=source, + ), _row( "amr:field_coupled_rhs_jacvec", layout="amr", @@ -393,21 +662,55 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: ), source=source, ), + _row( + "amr:shared_interface_implicit_jacvec_pair", + layout="amr", + backend="production", + platform="host", + mpi=False, + gpu=False, + status="partial", + limitation=( + "one generated Program compiles, binds and runs GMRES with the paired " + "level_rhs_jacvec_pair matvec on every level of an exactly two-level frozen 2D " + "AMR hierarchy in host/serial execution; the two interface participants may use " + "one independent packed-vector carrier block, but dynamic hierarchy mutation, " + "additional interfaces, mixed apply operators, MPI and GPU remain unavailable" + ), + available_route=( + "generated host/serial GMRES solve with an authenticated two-sided shared-interface " + "JVP on a frozen two-level 2D AMR hierarchy" + ), + alternative=( + "use the proved frozen two-level host/serial route, or add explicit execution " + "proof for dynamic hierarchies, additional interfaces, MPI or GPU" + ), + source=source, + ), _row( "amr:source_implicit_program", layout="amr", - backend="none", + backend="production", platform="host", mpi=mpi, - gpu=gpu, - status="unavailable", + gpu=False, + status="partial", limitation=( - "AMR has no typed local implicit-source/Newton Program primitive; block IMEX " - "descriptors are metadata and the spatial runtime has no temporal fallback" + "a generated IMEX Program executes one prepared LocalNewton solve over every " + "active cell on a synchronous, dynamically regridded two-level 2D hierarchy; " + "SolveOutcome/FailRun rollback is exact across covered and uncovered coarse " + "cells, fine cells, clocks, topology and MPI ranks, but GPU qualification, " + "subcycled local solves, field/global implicit coupling and performance evidence " + "remain outside the proved envelope" + ), + available_route=( + "generated Program local implicit source solve with LocalNewton and a consumed " + "SolveOutcome on synchronous two-level 2D AMR" + ), + alternative=( + "use the proved synchronous local-source route, or add an explicit capability " + "and execution proof for subcycled, GPU, field-coupled or global implicit solves" ), - requested="local implicit source solve on AMR", - available_route="explicit AMR Program primitives", - alternative="implement and install the typed AMR implicit-source Program primitive", source=source, ), ] @@ -592,7 +895,7 @@ def _inventory_rows(flags: Any, source: Any) -> list: platform="host", mpi=mpi, gpu=gpu, - limitation="requires exact HLLC model capability; polar metric provider unavailable", + limitation="requires exact HLLC model capability on the selected geometry", source=source, ), _row( @@ -602,7 +905,7 @@ def _inventory_rows(flags: Any, source: Any) -> list: platform="host", mpi=mpi, gpu=gpu, - limitation="requires exact Roe dissipation capability; polar metric provider unavailable", + limitation="requires exact Roe dissipation capability on the selected geometry", source=source, ), # ADC-552: the typed wave-speed provider families a model can bind HLL to. Descriptor-level @@ -679,7 +982,7 @@ def _inventory_rows(flags: Any, source: Any) -> list: "reconstruction:muscl", layout="uniform|amr", backend="production", - limitation="ghost_depth=2; native limiters minmod/vanleer", + limitation="ghost_depth=2; native limiters minmod/vanleer/mc/superbee", source=source, ), _row( @@ -695,23 +998,15 @@ def _inventory_rows(flags: Any, source: Any) -> list: _row( "limiter:mc", layout="uniform|amr", - backend="none", - status="unavailable", - limitation="catalogued but no native C++ limiter symbol exists", - requested="limiter=MC()", - available_route="Minmod() or VanLeer()", - alternative="use pops.numerics.reconstruction.limiters.Minmod()", + backend="production", + limitation="native POPS_HD MC slope policy; formal_order=2; ghost_depth=2", source=source, ), _row( "limiter:superbee", layout="uniform|amr", - backend="none", - status="unavailable", - limitation="catalogued but no native C++ limiter symbol exists", - requested="limiter=Superbee()", - available_route="Minmod() or VanLeer()", - alternative="use pops.numerics.reconstruction.limiters.VanLeer()", + backend="production", + limitation="native POPS_HD Superbee slope policy; formal_order=2; ghost_depth=2", source=source, ), _row( diff --git a/python/pops/_generated_component_interfaces.py b/python/pops/_generated_component_interfaces.py index 3bb1c5fc6..fc74b68f5 100644 --- a/python/pops/_generated_component_interfaces.py +++ b/python/pops/_generated_component_interfaces.py @@ -3,8 +3,8 @@ NATIVE_COMPONENT_ABI_VERSION = 1 NATIVE_COMPONENT_COMMON_ABI_VERSION = 1 -NATIVE_COMPONENT_CATALOG_SHA256 = '5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6' -NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = 'adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +NATIVE_COMPONENT_CATALOG_SHA256 = 'b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66' +NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = 'b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3' NATIVE_TAGGING_PROGRAM_ABI = {'version': 1, 'execution_modes': {'native_backend': 1, 'host': 2}, 'collective_scopes': {'none': 0}, @@ -72,6 +72,14 @@ 'hot_path': True, 'facets': ('stencil', 'lowering'), 'operations': ('apply',)}, + {'id': 6, + 'name': 'reflux', + 'uri': 'pops://interfaces/reflux', + 'version': 1, + 'cpp_table': 'PopsRefluxApiV1', + 'hot_path': True, + 'facets': ('stencil', 'lowering', 'effects'), + 'operations': ('apply_interface_batch',)}, {'id': 7, 'name': 'field_solver', 'uri': 'pops://interfaces/field-solver', @@ -95,13 +103,22 @@ 'cpp_table': 'PopsFieldTopologyApiV2', 'hot_path': False, 'facets': ('provider', 'report'), - 'operations': ('prepare_topology',)}) + 'operations': ('prepare_topology',)}, + {'id': 10, + 'name': 'boundary_flux', + 'uri': 'pops://interfaces/boundary-flux', + 'version': 1, + 'cpp_table': 'PopsBoundaryFluxApiV1', + 'hot_path': True, + 'facets': ('provider', 'lowering', 'fallible_evaluation'), + 'operations': ('transform_faces',)}) NATIVE_COMPONENT_INTERFACE_BY_NAME = {row['name']: row for row in NATIVE_COMPONENT_INTERFACES} NATIVE_COMPONENT_INTERFACE_BY_URI = {row['uri']: row for row in NATIVE_COMPONENT_INTERFACES} NATIVE_COMPONENT_BOUNDARY_HANDLE_ROUTES = {'boundary_provider': ('ghost_boundary', 'apply_region_batch'), 'corner_resolver': ('ghost_boundary', 'apply_region_batch'), 'numerical_closure': ('ghost_boundary', 'apply_region_batch'), 'conservative_flux': ('numerical_flux', 'evaluate_faces'), + 'boundary_flux_provider': ('boundary_flux', 'transform_faces'), 'residual_operator': ('field_boundary_closure', 'residual'), 'linearization_operator': ('field_boundary_closure', 'jvp')} diff --git a/python/pops/_generated_release_contract.py b/python/pops/_generated_release_contract.py index b88fd9327..6b7ee03d8 100644 --- a/python/pops/_generated_release_contract.py +++ b/python/pops/_generated_release_contract.py @@ -5,20 +5,22 @@ from typing import Any PACKAGE_VERSION = '1.0.0' -RELEASE_CONTRACT_SCHEMA_VERSION = 1 +RELEASE_CONTRACT_SCHEMA_VERSION = 2 PUBLIC_API_VERSION = 1 SEMANTIC_IR_VERSION = 1 NORMALIZATION_VERSION = 1 COMPONENT_CATALOG_SCHEMA_VERSION = 1 COMPONENT_MANIFEST_SCHEMA_VERSION = 2 -COMPONENT_REGISTRY_VERSION = 2 +COMPONENT_REGISTRY_VERSION = 3 CAPABILITY_VOCABULARY_VERSION = 4 COMPONENT_INTERFACE_ABI_VERSION = 1 NATIVE_ABI_VERSION = 3 CHECKPOINT_ENVELOPE_SCHEMA_VERSION = 1 UNIFORM_CHECKPOINT_PAYLOAD_VERSION = 5 AMR_CHECKPOINT_PAYLOAD_VERSION = 7 -RELEASE_CONTRACT_SHA256 = '677cc4279df230eeedcf0d657b558a1c42479bc41d0e7e1cc8cbdf0e7560a3da' +COMPONENT_CATALOG_SHA256 = 'b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66' +COMPONENT_CATALOG_SEMANTIC_SHA256 = 'b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3' +RELEASE_CONTRACT_SHA256 = 'c3f532c08e06c5fdeceeff5f5ee92ac0f737bd345d9f0fc4f06ae0c9600643a2' _SUPPORTED_MATRIX_DATA = {'distributed': {'execution_spaces': ['Serial'], 'mpi_implementation': 'OpenMPI'}, 'kokkos': {'execution_spaces': ['Serial', 'OpenMP'], 'version': '4.4.01'}, 'language': {'compiler_families': ['GNU', 'AppleClang'], diff --git a/python/pops/_platform_contracts.py b/python/pops/_platform_contracts.py index 9539fa6a4..de62bd8c6 100644 --- a/python/pops/_platform_contracts.py +++ b/python/pops/_platform_contracts.py @@ -27,6 +27,26 @@ _CENTERINGS = frozenset({"cell", "node", "face_x", "face_y", "face_z"}) _LAYOUTS = frozenset({"right", "left", "strided"}) _OWNERSHIP = frozenset({"borrowed", "owned", "shared"}) +_FIELD_CAPABILITIES = ( + "supported_dimensions", + "centerings", + "scalars", + "layouts", + "ownership", + "generic_field_view", +) +_EXACT_FIELD_ATTRIBUTES = ( + "dimension", + "extents", + "strides", + "centering", + "ghosts", + "scalar", + "memory_space", + "patch", + "layout", + "ownership", +) _STD_YEARS = {"11": "201103", "14": "201402", "17": "201703", "20": "202002", "23": "202302"} @@ -324,6 +344,10 @@ def __post_init__(self) -> None: len(pair) != 2 or any(isinstance(item, bool) or not isinstance(item, int) or item < 0 for item in pair) for pair in ghosts): raise ValueError("FieldViewDescriptor.ghosts must contain one non-negative pair per axis") + if any(lower >= extent or upper >= extent - lower + for extent, (lower, upper) in zip(self.extents, ghosts, strict=True)): + raise ValueError( + "FieldViewDescriptor.ghosts must leave a positive interior extent on every axis") object.__setattr__(self, "ghosts", ghosts) if self.centering not in _CENTERINGS: raise ValueError("unsupported field centering %r" % self.centering) @@ -411,35 +435,71 @@ def _validate_launch_facts(platform: PlatformManifest, context: ExecutionContext for name in ("storage", "compute", "accumulation", "reduction"): _require_same("precision.%s" % name, getattr(platform.precision, name), getattr(backend.precision, name)) - supported_dimensions = tuple(backend.capabilities["dimensions"].require( - "runtime.capabilities.dimensions")) - supported_centerings = tuple(backend.capabilities["centerings"].require( + for name in _FIELD_CAPABILITIES: + _require_same( + "capabilities.%s" % name, + _field_capability(platform, name, owner="artifact"), + _field_capability(backend, name, owner="runtime"), + ) + generic_field_view = _field_capability( + backend, "generic_field_view", owner="runtime").require( + "runtime.capabilities.generic_field_view") + if type(generic_field_view) is not bool or not generic_field_view: + raise PlatformContractError( + "runtime does not prove the generic field-view launch contract", + field="generic_field_view", expected=True, actual=generic_field_view) + supported_dimensions = tuple(_field_capability( + backend, "supported_dimensions", owner="runtime").require( + "runtime.capabilities.supported_dimensions")) + supported_centerings = tuple(_field_capability( + backend, "centerings", owner="runtime").require( "runtime.capabilities.centerings")) - supported_scalars = tuple(backend.capabilities["scalars"].require( + supported_scalars = tuple(_field_capability( + backend, "scalars", owner="runtime").require( "runtime.capabilities.scalars")) + supported_layouts = tuple(_field_capability( + backend, "layouts", owner="runtime").require( + "runtime.capabilities.layouts")) + supported_ownership = tuple(_field_capability( + backend, "ownership", owner="runtime").require( + "runtime.capabilities.ownership")) supported_memory = tuple(backend.memory_spaces.require("runtime.memory_spaces")) actual = tuple(fields) - expected = {item.name: item for item in expected_fields} - if len(expected) != len(tuple(expected_fields)): - raise ValueError("expected field names must be unique") + required = tuple(expected_fields) + _require_unique_field_names(actual, owner="launch") + _require_unique_field_names(required, owner="expected") + expected = {item.name: item for item in required} for view in actual: - if type(view) is not FieldViewDescriptor: - raise TypeError("fields must contain exact FieldViewDescriptor values") - _require_field_capability(view, "dimension", view.dimension, supported_dimensions) - _require_field_capability(view, "centering", view.centering, supported_centerings) - _require_field_capability(view, "scalar", view.scalar, supported_scalars) - _require_field_capability(view, "memory_space", view.memory_space, supported_memory) + _validate_field_capabilities( + view, + dimensions=supported_dimensions, + centerings=supported_centerings, + scalars=supported_scalars, + memory_spaces=supported_memory, + layouts=supported_layouts, + ownership=supported_ownership, + ) if view.scalar != context.datatype.identity: raise PlatformContractError( "field scalar does not match ExecutionContext datatype", field="datatype", expected=view.scalar, actual=context.datatype.identity) requirement = expected.get(view.name) if requirement is not None: - for name in ("dimension", "extents", "centering", "scalar", "memory_space"): + for name in _EXACT_FIELD_ATTRIBUTES: if getattr(view, name) != getattr(requirement, name): raise PlatformContractError( "field %r %s mismatch" % (view.name, name), field=name, expected=getattr(requirement, name), actual=getattr(view, name)) + for view in required: + _validate_field_capabilities( + view, + dimensions=supported_dimensions, + centerings=supported_centerings, + scalars=supported_scalars, + memory_spaces=supported_memory, + layouts=supported_layouts, + ownership=supported_ownership, + ) missing = sorted(set(expected) - {item.name for item in actual}) if missing: raise PlatformContractError("required field view(s) are missing: %s" % missing, @@ -502,6 +562,12 @@ def validate_component_runtime(platform: PlatformManifest, _require_same( "capabilities.%s" % name, platform.capabilities[name], runtime.capabilities[name]) + for name in _FIELD_CAPABILITIES: + _require_same( + "capabilities.%s" % name, + _field_capability(platform, name, owner="component"), + _field_capability(runtime, name, owner="runtime"), + ) expected_abi = platform.abi.require("component.abi") actual_abi = runtime.abi.require("runtime.abi") if expected_abi != actual_abi: @@ -522,6 +588,46 @@ def _require_field_capability(view: FieldViewDescriptor, field_name: str, expected=supported, actual=value) +def _field_capability(manifest: PlatformManifest | RuntimeBackendManifest, name: str, + *, owner: str) -> CapabilityProof: + proof = manifest.capabilities.get(name) + if proof is None: + raise PlatformContractError( + "%s omitted required field-view capability %r" % (owner, name), + field="capabilities.%s" % name, expected="explicit proof", actual=None) + return proof + + +def _require_unique_field_names(fields: tuple[FieldViewDescriptor, ...], *, owner: str) -> None: + names: set[str] = set() + for view in fields: + if type(view) is not FieldViewDescriptor: + raise TypeError("%s fields must contain exact FieldViewDescriptor values" % owner) + if view.name in names: + raise PlatformContractError( + "%s field descriptors contain duplicate name %r" % (owner, view.name), + field="fields.%s" % view.name, expected="unique name", actual=view.name) + names.add(view.name) + + +def _validate_field_capabilities( + view: FieldViewDescriptor, + *, + dimensions: tuple[Any, ...], + centerings: tuple[Any, ...], + scalars: tuple[Any, ...], + memory_spaces: tuple[Any, ...], + layouts: tuple[Any, ...], + ownership: tuple[Any, ...], +) -> None: + _require_field_capability(view, "dimension", view.dimension, dimensions) + _require_field_capability(view, "centering", view.centering, centerings) + _require_field_capability(view, "scalar", view.scalar, scalars) + _require_field_capability(view, "memory_space", view.memory_space, memory_spaces) + _require_field_capability(view, "layout", view.layout, layouts) + _require_field_capability(view, "ownership", view.ownership, ownership) + + def launch_checked(platform: PlatformManifest, context: ExecutionContext, fields: Sequence[FieldViewDescriptor], kernel: Callable[..., Any], *, expected_fields: Sequence[FieldViewDescriptor] = ()) -> Any: @@ -558,7 +664,7 @@ def proven_serial_manifest(*, backend: str, target: str, abi: str, precision=PrecisionPolicy(*(proof("float64") for _ in range(4))), device=proof("host"), memory_spaces=proof(("host",)), communicator=proof("serial"), capabilities={ - "dimensions": proof((2,)), "centerings": proof(("cell",)), + "supported_dimensions": proof((2,)), "centerings": proof(("cell",)), "scalars": proof(("float64",)), "layouts": proof(("right", "left", "strided")), "ownership": proof(("borrowed", "owned", "shared")), @@ -624,7 +730,7 @@ def artifact_platform_manifest( device_proof = proof(device_value) if device_value else unknown() memory_proof = proof(tuple(spaces)) if spaces else unknown() capabilities = { - "dimensions": proof((2,)), "centerings": proof(("cell",)), + "supported_dimensions": proof((2,)), "centerings": proof(("cell",)), "scalars": proof(("float64",)), "layouts": proof(("right", "left", "strided")), "ownership": proof(("borrowed", "owned", "shared")), diff --git a/python/pops/_pops.pyi b/python/pops/_pops.pyi index 559803ef5..72678bb60 100644 --- a/python/pops/_pops.pyi +++ b/python/pops/_pops.pyi @@ -276,6 +276,15 @@ class System: def __init__(self, config: SystemConfig) -> None: ... def solve_fields(self) -> _SolveReport: ... def _consume_step_projections(self) -> list[str]: ... + def _accepted_balance_terms(self, route: str) -> dict[str, float]: ... + def _selected_accepted_balance_terms( + self, + route: str, + block: str, + component: int, + levels: list[int], + automatic_terms: list[str], + ) -> dict[str, float]: ... def output_state_local_pieces( self, block: str, level: int ) -> tuple[dict[str, object], ...]: ... @@ -283,10 +292,10 @@ class System: self, provider_slot: str, level: int ) -> tuple[dict[str, object], ...]: ... def output_state_root_pieces( - self, world: _NativeWorldCommunicator, block: str, level: int + self, lane: _NativeObserverMpiLane, block: str, level: int ) -> tuple[dict[str, object], ...]: ... def output_field_root_pieces( - self, world: _NativeWorldCommunicator, provider_slot: str, level: int + self, lane: _NativeObserverMpiLane, provider_slot: str, level: int ) -> tuple[dict[str, object], ...]: ... @@ -295,6 +304,15 @@ class AmrSystem: def n_levels(self) -> int: ... def configured_n_levels(self) -> int: ... def _consume_step_projections(self) -> list[str]: ... + def _accepted_balance_terms(self, route: str) -> dict[str, float]: ... + def _selected_accepted_balance_terms( + self, + route: str, + block: str, + component: int, + levels: list[int], + automatic_terms: list[str], + ) -> dict[str, float]: ... def materialize_program_restart_histories( self, payload: bytes, @@ -309,10 +327,10 @@ class AmrSystem: self, provider_slot: str, level: int ) -> tuple[dict[str, object], ...]: ... def output_state_root_pieces( - self, world: _NativeWorldCommunicator, block: str, level: int + self, lane: _NativeObserverMpiLane, block: str, level: int ) -> tuple[dict[str, object], ...]: ... def output_field_root_pieces( - self, world: _NativeWorldCommunicator, provider_slot: str, level: int + self, lane: _NativeObserverMpiLane, provider_slot: str, level: int ) -> tuple[dict[str, object], ...]: ... diff --git a/python/pops/amr/__init__.py b/python/pops/amr/__init__.py index 1db610322..5cea94341 100644 --- a/python/pops/amr/__init__.py +++ b/python/pops/amr/__init__.py @@ -36,6 +36,7 @@ AMRProviderLoweringContext, amr_provider_binding_identity, ClusteringProvider, + RefluxProvider, ResolvedAMRProviderBinding, TaggerProvider, ) @@ -66,6 +67,7 @@ "PatchLayout", "PreparedHierarchyNativeLowering", "PreparedHierarchyNativeProvider", + "RefluxProvider", "ResolvedAMRProviderBinding", "Tag", "TaggerProvider", diff --git a/python/pops/amr/_resolution.py b/python/pops/amr/_resolution.py index 64256d8cd..b0c741b3c 100644 --- a/python/pops/amr/_resolution.py +++ b/python/pops/amr/_resolution.py @@ -630,6 +630,7 @@ def resolve_amr_authorities( load_balance: Any, tagger: Any, clustering: Any, + reflux: Any, context: AMRResolutionContext, ) -> ResolvedAMRAuthorities: """Resolve every adaptive-layout concern exactly once from its owning declaration.""" @@ -650,7 +651,7 @@ def resolve_amr_authorities( raise TypeError("AMR %s authority must implement %s()" % (slot, method)) if type(context) is not AMRResolutionContext: raise TypeError("AMR resolution requires an AMRResolutionContext") - providers = (tagger, clustering) + providers = (tagger, clustering, reflux) for value in providers: for method in ("inspect", "resolve_references", "lower_amr_provider"): if not callable(getattr(value, method, None)): @@ -691,10 +692,11 @@ def resolve_amr_authorities( if lowered.role in provider_bindings: raise ValueError("AMR provider roles must be unique") provider_bindings[lowered.role] = lowered.data - if set(provider_bindings) != {"clustering", "tagger"}: - raise ValueError("AMR resolution requires exact clustering and tagger provider roles") + if set(provider_bindings) != {"clustering", "tagger", "reflux"}: + raise ValueError( + "AMR resolution requires exact clustering, tagger and reflux provider roles") provider_bindings = { - role: provider_bindings[role] for role in ("clustering", "tagger") + role: provider_bindings[role] for role in ("clustering", "tagger", "reflux") } resolved_hierarchy = _hierarchy( hierarchy, diff --git a/python/pops/amr/providers.py b/python/pops/amr/providers.py index ed395e976..28a9a936d 100644 --- a/python/pops/amr/providers.py +++ b/python/pops/amr/providers.py @@ -419,6 +419,64 @@ def runtime_binding_data(self) -> dict[str, Any]: canonical_identity = runtime_binding_data +@dataclass(frozen=True, slots=True) +class RefluxProvider: + """Bind one external local Reflux table to the conservative AMR transition.""" + + component: Any + __pops_ir_immutable__ = True + + def __post_init__(self) -> None: + from pops import interfaces + + _external_component( + self.component, + interface=interfaces.Reflux, + where="RefluxProvider.component", + ) + + def resolve_references(self, resolver: Any) -> RefluxProvider: + if not callable(resolver): + raise TypeError("RefluxProvider.resolve_references requires a callable resolver") + return self + + def require_component_inputs(self, components: Any) -> None: + _require_component(self.component, components, where="RefluxProvider") + + def lower_amr_provider( + self, context: AMRProviderLoweringContext, + ) -> ResolvedAMRProviderBinding: + """Authenticate the component, hierarchy layout and Program clock.""" + if type(context) is not AMRProviderLoweringContext: + raise TypeError("RefluxProvider requires an AMRProviderLoweringContext") + self.require_component_inputs(context.components) + data = { + **self.runtime_binding_data(), + "layout_identity": context.layout_identity, + "clock_identity": context.clock_identity, + } + data["provider_identity"] = amr_provider_binding_identity("reflux", data) + return ResolvedAMRProviderBinding("reflux", data) + + def runtime_binding_data(self) -> dict[str, Any]: + from pops import interfaces + + data = { + "schema_version": 1, + "provider_type": "external_amr_reflux", + "runtime_installation": { + "schema_version": 1, + "protocol": "external_component", + }, + **_component_binding(self.component, interfaces.Reflux), + } + data["provider_identity"] = make_identity("amr-reflux-provider", data).token + return data + + inspect = runtime_binding_data + canonical_identity = runtime_binding_data + + @dataclass(frozen=True, slots=True) class _AMRRuntimeInterfaceProtocol: """Native-interface-owned validation and installation route.""" @@ -593,6 +651,26 @@ def validate_installed_capability( "external AMR Tagger lacks its exact graph/capability/clock contract") +@dataclass(frozen=True, slots=True) +class _RefluxRuntimeInterfaceProtocol(_AMRRuntimeInterfaceProtocol): + """The local Reflux callback is qualified by the accepted Program clock.""" + + def validate_resolved_capability( + self, binding: Mapping[str, Any], resolved_tagging_identity: str | None, + ) -> None: + del resolved_tagging_identity + if not isinstance(binding.get("clock_identity"), str) \ + or not binding["clock_identity"]: + raise ValueError("AMR Reflux lacks its exact Program clock authority") + + def validate_installed_capability( + self, binding: Mapping[str, Any], installed: Any, + resolved_tagging_identity: str | None, + ) -> None: + del installed + self.validate_resolved_capability(binding, resolved_tagging_identity) + + def _runtime_interface_key(value: Any) -> tuple[Any, ...]: if not isinstance(value, Mapping): raise TypeError("AMR provider binding has no native-interface protocol") @@ -627,6 +705,12 @@ def _runtime_interface_protocols() -> dict[tuple[Any, ...], _AMRRuntimeInterface builtin_provider_id="pops.lib.amr::symbolic_tagger", component_installer="_install_amr_tagger_component", ), + _RefluxRuntimeInterfaceProtocol( + role="reflux", + native_interface=interfaces.Reflux.to_data(), + builtin_provider_id="pops.lib.amr::flux_register_reflux", + component_installer="_install_amr_reflux_component", + ), ) return {_runtime_interface_key(row.native_interface): row for row in protocols} @@ -928,6 +1012,7 @@ def prepare_amr_provider_installation( "amr_provider_binding_identity", "ClusteringProvider", "PreparedAMRProviderNativeConfig", + "RefluxProvider", "ResolvedAMRProviderBinding", "TaggerProvider", "validate_amr_provider_binding", diff --git a/python/pops/analytic/__init__.py b/python/pops/analytic/__init__.py index 06f9fbe0b..cca94efe8 100644 --- a/python/pops/analytic/__init__.py +++ b/python/pops/analytic/__init__.py @@ -25,6 +25,7 @@ radius, sin, sqrt, + time, where, x, y, @@ -69,6 +70,7 @@ "radius", "sin", "sqrt", + "time", "where", "x", "y", diff --git a/python/pops/analytic/_functions.py b/python/pops/analytic/_functions.py index bbb1a5269..d830106b5 100644 --- a/python/pops/analytic/_functions.py +++ b/python/pops/analytic/_functions.py @@ -15,6 +15,7 @@ constant, parameter, _program_input, + _time, ) @@ -36,6 +37,12 @@ def input(value_id: Any, component: Any) -> ScalarExpr: return _program_input(value_id, component) +def time(clock: Any) -> ScalarExpr: + """Read physical time from one exact logical ``Clock`` at native evaluation.""" + + return _time(clock) + + def x(frame: Any) -> ScalarExpr: """Return the typed x coordinate of ``frame``.""" @@ -210,6 +217,7 @@ def where(predicate: Any, when_true: Any, when_false: Any) -> ScalarExpr: "radius", "sin", "sqrt", + "time", "where", "x", "y", diff --git a/python/pops/analytic/_model.py b/python/pops/analytic/_model.py index 9ba6acd7f..953bd1f1f 100644 --- a/python/pops/analytic/_model.py +++ b/python/pops/analytic/_model.py @@ -26,7 +26,8 @@ _SCALAR_BINARY_OPS = frozenset({ "add", "sub", "mul", "div", "pow", "atan2", "hypot", "minimum", "maximum", }) -_SCALAR_OPS = frozenset({"constant", "coordinate", "parameter", "input", "where"}) | _SCALAR_UNARY_OPS \ +_SCALAR_OPS = frozenset({"constant", "coordinate", "parameter", "input", "time", "where"}) \ + | _SCALAR_UNARY_OPS \ | _SCALAR_BINARY_OPS _COMPARISON_OPS = frozenset({"eq", "ne", "lt", "le", "gt", "ge"}) _LOGICAL_BINARY_OPS = frozenset({"and", "or"}) @@ -84,6 +85,19 @@ def __post_init__(self) -> None: raise TypeError("analytic input component must be canonical non-empty text") +@dataclass(frozen=True, slots=True) +class _TimeRef: + """Exact logical clock read consumed as physical time by a prepared runtime.""" + + clock: Any + + def __post_init__(self) -> None: + from pops.time import Clock + + if type(self.clock) is not Clock or self.clock.owner is None: + raise TypeError("analytic time requires one owner-qualified exact Clock") + + @dataclass(frozen=True, slots=True, eq=False, init=False) class ScalarExpr: """One immutable scalar analytic expression. @@ -99,6 +113,7 @@ class ScalarExpr: _coordinate: _CoordinateRef | None _parameter: Any _input: _InputRef | None + _time: _TimeRef | None _frame_id: str | None __hash__: ClassVar[None] = None __pops_ir_immutable__: ClassVar[bool] = True @@ -118,6 +133,7 @@ def _new( coordinate: _CoordinateRef | None = None, parameter: Any = None, input_ref: _InputRef | None = None, + time_ref: _TimeRef | None = None, ) -> ScalarExpr: result = object.__new__(cls) object.__setattr__(result, "_op", op) @@ -126,6 +142,7 @@ def _new( object.__setattr__(result, "_coordinate", coordinate) object.__setattr__(result, "_parameter", parameter) object.__setattr__(result, "_input", input_ref) + object.__setattr__(result, "_time", time_ref) object.__setattr__(result, "_frame_id", _merged_frame_id(arguments, coordinate)) _validate_scalar_local(result) return result @@ -277,6 +294,11 @@ def input_references(self) -> tuple[tuple[int, str], ...]: return _input_references(self) + def time_clocks(self) -> tuple[Any, ...]: + """Return exact logical clocks in deterministic first-occurrence order.""" + + return _time_clocks(self) + @dataclass(frozen=True, slots=True, eq=False, init=False) class PredicateExpr: @@ -458,6 +480,12 @@ def _program_input(value_id: Any, component: Any) -> ScalarExpr: return ScalarExpr._new("input", input_ref=_InputRef(value_id, component)) +def _time(clock: Any) -> ScalarExpr: + """Internal exact-clock constructor exposed through :func:`pops.analytic.time`.""" + + return ScalarExpr._new("time", time_ref=_TimeRef(clock)) + + def _as_scalar(value: Any, *, where: str = "analytic scalar") -> ScalarExpr: if isinstance(value, ScalarExpr): return value @@ -542,30 +570,42 @@ def _validate_scalar_local(value: ScalarExpr) -> None: raise TypeError("analytic node arguments must be an immutable tuple") if value._op == "constant": if value._arguments or value._coordinate is not None or value._parameter is not None \ - or value._input is not None \ + or value._input is not None or value._time is not None \ or type(value._literal) is not float: raise TypeError("analytic constant node has an invalid shape") _finite_literal(value._literal) elif value._op == "coordinate": if value._arguments or value._literal is not None \ or value._parameter is not None \ - or value._input is not None \ + or value._input is not None or value._time is not None \ or not isinstance(value._coordinate, _CoordinateRef): raise TypeError("analytic coordinate node has an invalid shape") elif value._op == "parameter": from pops.model import ParamHandle if value._arguments or value._literal is not None or value._coordinate is not None \ - or value._input is not None \ + or value._input is not None or value._time is not None \ or type(value._parameter) is not ParamHandle: raise TypeError("analytic parameter node has an invalid shape") elif value._op == "input": if value._arguments or value._literal is not None or value._coordinate is not None \ - or value._parameter is not None or not isinstance(value._input, _InputRef): + or value._parameter is not None or value._time is not None \ + or not isinstance(value._input, _InputRef): raise TypeError("analytic input node has an invalid shape") + elif value._op == "time": + if ( + value._arguments + or value._literal is not None + or value._coordinate is not None + or value._parameter is not None + or value._input is not None + or not isinstance(value._time, _TimeRef) + ): + raise TypeError("analytic time node has an invalid shape") else: if value._literal is not None or value._coordinate is not None \ - or value._parameter is not None or value._input is not None: + or value._parameter is not None or value._input is not None \ + or value._time is not None: raise TypeError( "analytic operator node cannot carry literal, coordinate or parameter metadata") expected = 1 if value._op in _SCALAR_UNARY_OPS else 2 @@ -727,6 +767,15 @@ def _node_to_data(value: Expression) -> dict[str, Any]: "value_id": value._input.value_id, "component": value._input.component, } + if value._op == "time": + if value._time is None: + raise TypeError("analytic time node is missing its exact Clock") + return { + "kind": "scalar", + "op": "time", + "clock": value._time.clock.to_data(), + "clock_id": value._time.clock.qualified_id, + } return { "kind": "scalar", "op": value._op, @@ -841,12 +890,21 @@ def _node_from_data( raise TypeError("analytic input data has an unsupported shape") return ScalarExpr._new( "input", input_ref=_InputRef(data["value_id"], data["component"])) + if expected == "scalar" and op == "time": + if set(data) != {"kind", "op", "clock", "clock_id"}: + raise TypeError("analytic time data has an unsupported shape") + from pops.time import Clock + + clock = Clock.from_data(data["clock"]) + if data["clock_id"] != clock.qualified_id: + raise ValueError("analytic time data changed its exact Clock identity") + return ScalarExpr._new("time", time_ref=_TimeRef(clock)) if set(data) != {"kind", "op", "arguments"} \ or not isinstance(data["arguments"], list): raise TypeError("analytic operator data has an unsupported shape") raw_arguments = data["arguments"] if expected == "scalar": - if op not in _SCALAR_OPS - {"constant", "coordinate", "parameter", "input"}: + if op not in _SCALAR_OPS - {"constant", "coordinate", "parameter", "input", "time"}: raise ValueError("unsupported analytic scalar operation %r" % op) child_kinds = (["predicate", "scalar", "scalar"] if op == "where" else ["scalar"] * len(raw_arguments)) @@ -886,7 +944,7 @@ def _resolve_references(value: Expression, resolver: Any) -> Expression: if resolved.param_kind != value._parameter.param_kind: raise ValueError("analytic parameter resolver changed the declared parameter kind") return ScalarExpr._new("parameter", parameter=resolved) - if value._op in {"constant", "coordinate", "input"}: + if value._op in {"constant", "coordinate", "input", "time"}: return value return ScalarExpr._new( value._op, @@ -949,6 +1007,26 @@ def _input_references(value: Expression) -> tuple[tuple[int, str], ...]: return tuple(ordered) +def _time_clocks(value: Expression) -> tuple[Any, ...]: + """Collect exact Clock values without assigning a native runtime slot.""" + + ordered: list[Any] = [] + seen: set[str] = set() + stack = [value] + while stack: + node = stack.pop() + if isinstance(node, ScalarExpr) and node._op == "time": + reference = node._time + if not isinstance(reference, _TimeRef): + raise TypeError("analytic time leaf does not carry an exact _TimeRef") + identity = reference.clock.qualified_id + if identity not in seen: + seen.add(identity) + ordered.append(reference.clock) + stack.extend(reversed(node._arguments)) + return tuple(ordered) + + __all__ = [ "AnalyticTruthValueError", "DEFAULT_MAX_DEPTH", @@ -959,5 +1037,6 @@ def _input_references(value: Expression) -> tuple[tuple[int, str], ...]: "SCHEMA_VERSION", "ScalarExpr", "_program_input", + "_time", "parameter", ] diff --git a/python/pops/boundary/__init__.py b/python/pops/boundary/__init__.py index cadd68d55..d0bf88794 100644 --- a/python/pops/boundary/__init__.py +++ b/python/pops/boundary/__init__.py @@ -7,6 +7,10 @@ from .transport import ( BoundaryStencilRequirement, + model_characteristic_no_inflow, + model_primitive_to_conservative, + NoFlux, + SlipWall, TransportBoundarySet, ) from .embedded import EmbeddedBoundaryFlux, ZeroFlux @@ -14,6 +18,10 @@ __all__ = [ "BoundaryStencilRequirement", "EmbeddedBoundaryFlux", + "model_characteristic_no_inflow", + "model_primitive_to_conservative", + "NoFlux", + "SlipWall", "TransportBoundarySet", "ZeroFlux", ] diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index f466f720f..9b0d31684 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -5,8 +5,9 @@ from dataclasses import dataclass import hashlib import json -from typing import Any, ClassVar +from typing import Any, ClassVar, cast +from pops.analytic import ScalarExpr from pops.domain import DomainBoundary from pops._ir import Expr from pops._ir.expr import Const @@ -31,8 +32,13 @@ def _expression(value: Any, *, where: str) -> Expr: raise TypeError("%s must be a PoPS Expr or an exact scalar" % where) from exc -def _expression_data(value: Expr, *, qualified: bool = False) -> Any: +def _expression_data(value: Expr | ScalarExpr, *, qualified: bool = False) -> Any: """Return the same stable structural protocol used by derived parameter expressions.""" + if isinstance(value, ScalarExpr): + return { + "protocol": "pops.analytic.scalar.v1", + "value": value.to_data(), + } if qualified: from pops.model._bind_expression import qualified_expression_key @@ -75,6 +81,48 @@ def _converter(value: Any) -> Handle | None: return value +def model_primitive_to_conservative(state: Any) -> Handle: + """Return the exact block-model primitive-to-conservative boundary provider. + + The returned Handle names the already compiled ``Model.to_conservative`` kernel; it is not a + Python callback and it cannot select an unrelated conversion implementation by string. The + corresponding ``Inflow.value`` tuple follows the model's declared primitive-variable order. + """ + checked = _state(state, where="model_primitive_to_conservative.state") + representation = getattr(getattr(checked, "space", None), "representation", None) + if representation != "conservative": + raise ValueError( + "model_primitive_to_conservative requires a conservative target state" + ) + digest = hashlib.sha256(checked.qualified_id.encode("utf-8")).hexdigest()[:24] + return Handle( + "model-primitive-to-conservative-%s" % digest, + kind="representation_conversion", + owner=checked.owner_path, + ) + + +def model_characteristic_no_inflow(state: Any) -> Handle: + """Return the exact block-model flux-Jacobian characteristic provider. + + The provider is generated only for models compiled with + ``m.roe_from_jacobian()``. It projects the authored conservative reference state onto the + incoming eigenspace of the outward-normal flux Jacobian. The returned Handle is data-only and + block-qualified; it never names a Python callback or an Euler-specific implementation. + """ + checked = _state(state, where="model_characteristic_no_inflow.state") + if getattr(getattr(checked, "space", None), "representation", None) != "conservative": + raise ValueError( + "model_characteristic_no_inflow requires a conservative target state" + ) + digest = hashlib.sha256(checked.qualified_id.encode("utf-8")).hexdigest()[:24] + return Handle( + "model-characteristic-no-inflow-%s" % digest, + kind="boundary_eigenstructure", + owner=checked.owner_path, + ) + + def _condition_protocol(value: Any, *, where: str) -> Any: _state(getattr(value, "state", None), where="%s.state" % where) for method in ("inspect", "resolve_references", "resolve_condition"): @@ -119,13 +167,37 @@ def _provider_handle(state: Handle, boundary: Any, condition_type: str) -> Handl ) +def _analytic_time_handle(clock: Any) -> Handle: + from pops.time import Clock + + if type(clock) is not Clock or clock.owner is None: + raise TypeError("analytic boundary time requires one owner-qualified exact Clock") + digest = hashlib.sha256(clock.qualified_id.encode("utf-8")).hexdigest()[:24] + return Handle( + "clock-%s" % digest, + kind="time", + owner=clock.owner, + ) + + def _dependency_handles( - values: tuple[Expr, ...], *, include_state: Handle | None = None + values: tuple[Expr | ScalarExpr, ...], *, include_state: Handle | None = None ) -> tuple[tuple[Handle, ...], tuple[Handle, ...], tuple[Handle, ...], tuple[ParamHandle, ...]]: - references = _unique_references(*(value.declaration_references() for value in values)) + references = _unique_references( + *( + value.declaration_references() if isinstance(value, Expr) else value.parameter_handles() + for value in values + ) + ) states = [reference for reference in references if reference.kind == "state"] fields = [reference for reference in references if reference.kind == "field"] time = [reference for reference in references if reference.kind == "time"] + for value in values: + if isinstance(value, ScalarExpr): + for clock in value.time_clocks(): + handle = _analytic_time_handle(clock) + if handle not in time: + time.append(handle) params = [ reference for reference in references if isinstance(reference, ParamHandle) and reference.param_kind == "runtime" @@ -142,7 +214,7 @@ def _dependency_handles( return tuple(states), tuple(fields), tuple(time), tuple(params) -def _closure() -> Any: +def _closure(characteristic: Handle | None = None) -> Any: from pops.mesh.boundaries import ( CharacteristicClosure, ClosureMode, @@ -151,12 +223,20 @@ def _closure() -> Any: SonicPolicy, ) + if characteristic is None: + return CharacteristicClosure( + mode=ClosureMode.NONE, + sign_dependence=SignDependence.FIXED, + sonic=SonicPolicy.NEUTRAL, + incoming=IncomingMultiplicity.SINGLE, + characteristics=(), + ) return CharacteristicClosure( - mode=ClosureMode.NONE, - sign_dependence=SignDependence.FIXED, + mode=ClosureMode.DIRECTIONAL, + sign_dependence=SignDependence.SPATIAL, sonic=SonicPolicy.NEUTRAL, - incoming=IncomingMultiplicity.SINGLE, - characteristics=(), + incoming=IncomingMultiplicity.MULTIPLE, + characteristics=(characteristic,), ) @@ -202,26 +282,44 @@ class ResolvedTransportCondition: geometry: DomainBoundary condition_type: str state: Handle - values: tuple[Expr, ...] + values: tuple[Expr | ScalarExpr, ...] requirement: BoundaryStencilRequirement provider: Any def __post_init__(self) -> None: - from pops.mesh.boundaries import BoundaryProvider + from pops.mesh.boundaries import BoundaryProvider, BoundaryProviderKind if not isinstance(self.geometry, DomainBoundary): raise TypeError("ResolvedTransportCondition.geometry must be a DomainBoundary") - if self.condition_type not in {"inflow", "outflow"}: + if self.condition_type not in {"inflow", "outflow", "no_flux", "slip_wall"}: raise ValueError("unsupported built-in transport condition type") _state(self.state, where="ResolvedTransportCondition.state") if not self.state.is_resolved: raise TypeError("ResolvedTransportCondition.state must be canonical") - if not isinstance(self.values, tuple) or any(not isinstance(row, Expr) for row in self.values): - raise TypeError("ResolvedTransportCondition.values must contain Expr values") + if not isinstance(self.values, tuple) \ + or any(not isinstance(row, (Expr, ScalarExpr)) for row in self.values): + raise TypeError("ResolvedTransportCondition.values must contain Expr or ScalarExpr values") if self.requirement.state != self.state: raise ValueError("transport condition and stencil requirement refer to different states") if not isinstance(self.provider, BoundaryProvider): raise TypeError("ResolvedTransportCondition.provider must be a BoundaryProvider") + allowed_kinds = { + "inflow": frozenset(( + BoundaryProviderKind.INFLOW, + BoundaryProviderKind.DIRECTIONAL_TRANSPORT, + )), + "outflow": frozenset(( + BoundaryProviderKind.OUTFLOW, + BoundaryProviderKind.DIRECTIONAL_TRANSPORT, + )), + "no_flux": frozenset((BoundaryProviderKind.NO_FLUX,)), + "slip_wall": frozenset((BoundaryProviderKind.GHOST_FORMULA,)), + }[self.condition_type] + if self.provider.kind not in allowed_kinds: + raise ValueError( + "transport condition %r cannot use boundary provider law %r" + % (self.condition_type, self.provider.kind.value) + ) def canonical_identity(self) -> dict[str, Any]: return { @@ -248,8 +346,11 @@ def _resolved_condition( ) -> ResolvedTransportCondition: from pops.mesh.boundaries import ( BoundaryDependencies, + GhostFormula, GhostState, Inflow as LowLevelInflow, + NoFlux as LowLevelNoFlux, + NumericalFlux, Outflow as LowLevelOutflow, RepresentationFlow, ) @@ -266,21 +367,46 @@ def _resolved_condition( condition.values, include_state=state if include_state_dependency else None, ) + characteristic = getattr(condition, "characteristic", None) + if characteristic is not None and state not in states: + states = (*states, state) dependencies = BoundaryDependencies( states=states, fields=fields, time=time, runtime_params=params, representation=flow, - characteristic=_closure(), + characteristic=_closure(characteristic), ) - output = GhostState(boundary=boundary, subject=state, representation=target) - factory = LowLevelInflow if condition_type == "inflow" else LowLevelOutflow - provider = factory( - handle=_provider_handle(state, geometry, condition_type), - outputs=(output,), - dependencies=dependencies, + output = ( + NumericalFlux(boundary=boundary, subject=state, representation=target) + if condition_type == "no_flux" + else GhostState(boundary=boundary, subject=state, representation=target) ) + factory = { + "inflow": LowLevelInflow, + "no_flux": LowLevelNoFlux, + "outflow": LowLevelOutflow, + "slip_wall": GhostFormula, + }[condition_type] + if characteristic is not None: + if condition_type != "inflow": + raise ValueError("characteristic no-inflow is defined only for Inflow") + from pops.mesh.boundaries import DirectionalTransport + + factory = DirectionalTransport + if condition_type == "no_flux": + provider = factory( + handle=_provider_handle(state, geometry, condition_type), + output=output, + dependencies=dependencies, + ) + else: + provider = factory( + handle=_provider_handle(state, geometry, condition_type), + outputs=(output,), + dependencies=dependencies, + ) return ResolvedTransportCondition( geometry=geometry, condition_type=condition_type, @@ -297,9 +423,10 @@ class Inflow: condition_type: ClassVar[str] = "inflow" state: Handle - values: tuple[Expr, ...] + values: tuple[Expr | ScalarExpr, ...] representation: Representation | None converter: Handle | None + characteristic: Handle | None def __init__( self, @@ -308,6 +435,7 @@ def __init__( value: Any, representation: Representation | None = None, converter: Any = None, + characteristic: Any = None, ) -> None: checked_state = _state(state, where="Inflow.state") if representation is not None and not isinstance(representation, Representation): @@ -315,31 +443,91 @@ def __init__( raw_values = value if isinstance(value, tuple) else (value,) if not raw_values: raise ValueError("Inflow.value must prescribe at least one state component") + analytic = any(isinstance(row, ScalarExpr) for row in raw_values) + if analytic: + from pops.analytic import constant as analytic_constant + + checked_values = [] + for index, row in enumerate(raw_values): + if isinstance(row, ScalarExpr): + checked_values.append(row) + elif isinstance(row, Expr): + raise TypeError( + "Inflow.value cannot mix PoPS Expr and analytic ScalarExpr values; " + "use pops.analytic.param(...) for parameters" + ) + else: + try: + checked_values.append(analytic_constant(row)) + except (TypeError, ValueError) as exc: + raise TypeError( + "Inflow.value[%d] must be an analytic ScalarExpr or exact scalar" + % index + ) from exc + else: + checked_values = [ + _expression(row, where="Inflow.value[%d]" % index) + for index, row in enumerate(raw_values) + ] object.__setattr__(self, "state", checked_state) - object.__setattr__(self, "values", tuple( - _expression(row, where="Inflow.value[%d]" % index) - for index, row in enumerate(raw_values) - )) + object.__setattr__(self, "values", tuple(checked_values)) object.__setattr__(self, "representation", representation) object.__setattr__(self, "converter", _converter(converter)) + if characteristic is not None: + expected = model_characteristic_no_inflow(checked_state) + if not isinstance(characteristic, Handle) or characteristic != expected: + raise ValueError( + "Inflow.characteristic must be the exact " + "model_characteristic_no_inflow(state) provider" + ) + if representation is not None or converter is not None: + raise NotImplementedError( + "characteristic no-inflow currently requires a conservative reference state" + ) + if analytic: + raise NotImplementedError( + "characteristic no-inflow requires one finite fixed conservative reference" + ) + object.__setattr__(self, "characteristic", characteristic) def declaration_references(self) -> tuple[Handle, ...]: converter = () if self.converter is None else (self.converter,) + characteristic = () if self.characteristic is None else (self.characteristic,) return _unique_references( (self.state,), - *(value.declaration_references() for value in self.values), + *( + value.declaration_references() + if isinstance(value, Expr) + else value.parameter_handles() + for value in self.values + ), converter, + characteristic, ) def resolve_references(self, resolver: Any) -> Inflow: if not callable(resolver): raise TypeError("Inflow.resolve_references requires a callable resolver") - converter = None if self.converter is None else resolver(self.converter) + resolved_state = resolver(self.state) + if self.converter is None: + converter = None + elif self.converter == model_primitive_to_conservative(self.state): + # This provider is derived from the authenticated state, not an independently + # registered declaration. Re-derive its canonical identity after resolving the state. + converter = model_primitive_to_conservative(resolved_state) + else: + converter = resolver(self.converter) + characteristic = None + if self.characteristic is not None: + if self.characteristic != model_characteristic_no_inflow(self.state): + raise ValueError("Inflow retained a forged characteristic provider") + characteristic = model_characteristic_no_inflow(resolved_state) return type(self)( - state=resolver(self.state), + state=resolved_state, value=tuple(value.resolve_references(resolver) for value in self.values), representation=self.representation, converter=converter, + characteristic=characteristic, ) def inspect(self) -> dict[str, Any]: @@ -351,6 +539,8 @@ def inspect(self) -> dict[str, Any]: "representation": ( None if self.representation is None else self.representation.canonical_identity()), "converter": None if self.converter is None else self.converter.inspect(), + "characteristic": ( + None if self.characteristic is None else self.characteristic.inspect()), } def resolve_condition( @@ -424,9 +614,143 @@ def resolve_condition( ) +@dataclass(frozen=True, slots=True, eq=False, init=False) +class NoFlux: + """Close one physical face after the Riemann solve. + + Ghost values use the prepared extrapolation law so reconstruction remains defined; the same + immutable face row then zeroes the already evaluated numerical flux before divergence/reflux. + """ + + condition_type: ClassVar[str] = "no_flux" + state: Handle + values: tuple[Expr, ...] + representation: Representation | None + converter: Handle | None + + def __init__(self, *, state: Any) -> None: + object.__setattr__(self, "state", _state(state, where="NoFlux.state")) + object.__setattr__(self, "values", ()) + object.__setattr__(self, "representation", None) + object.__setattr__(self, "converter", None) + + def declaration_references(self) -> tuple[Handle, ...]: + return (self.state,) + + def resolve_references(self, resolver: Any) -> NoFlux: + if not callable(resolver): + raise TypeError("NoFlux.resolve_references requires a callable resolver") + return type(self)(state=resolver(self.state)) + + def inspect(self) -> dict[str, Any]: + return { + "schema_version": _SCHEMA_VERSION, + "condition_type": self.condition_type, + "state": self.state.inspect(), + } + + def resolve_condition( + self, + *, + geometry: DomainBoundary, + boundary: Any, + requirement: BoundaryStencilRequirement, + ) -> ResolvedTransportCondition: + return _resolved_condition( + self, + condition_type=self.condition_type, + geometry=geometry, + boundary=boundary, + requirement=requirement, + include_state_dependency=True, + ) + + +@dataclass(frozen=True, slots=True, eq=False, init=False) +class SlipWall: + """Model-aware reflective wall: reverse the normal polar-vector component only.""" + + condition_type: ClassVar[str] = "slip_wall" + state: Handle + values: tuple[Expr, ...] + representation: Representation | None + converter: Handle | None + + def __init__(self, *, state: Any) -> None: + object.__setattr__(self, "state", _state(state, where="SlipWall.state")) + object.__setattr__(self, "values", ()) + object.__setattr__(self, "representation", None) + object.__setattr__(self, "converter", None) + + def declaration_references(self) -> tuple[Handle, ...]: + return (self.state,) + + def resolve_references(self, resolver: Any) -> SlipWall: + if not callable(resolver): + raise TypeError("SlipWall.resolve_references requires a callable resolver") + return type(self)(state=resolver(self.state)) + + def inspect(self) -> dict[str, Any]: + return { + "schema_version": _SCHEMA_VERSION, + "condition_type": self.condition_type, + "state": self.state.inspect(), + } + + def resolve_condition( + self, + *, + geometry: DomainBoundary, + boundary: Any, + requirement: BoundaryStencilRequirement, + ) -> ResolvedTransportCondition: + from pops.physics.roles import ComponentRole, native_role_token + + components = _state_components(self.state, where="SlipWall") + space = getattr(self.state, "space", None) + roles = getattr(space, "roles", None) + if not isinstance(roles, Mapping) or set(roles) != set(components): + raise ValueError( + "SlipWall requires one explicit typed physical role for every state component") + tokens = { + component: ( + native_role_token(role) if isinstance(role, ComponentRole) else role) + for component, role in roles.items() + } + supported = { + "AxialX", "AxialY", "AxialZ", "Density", "MomentumX", "MomentumY", + "MomentumZ", "Energy", "VelocityX", "VelocityY", "VelocityZ", "Pressure", + "Temperature", "Scalar", + } + if any(not isinstance(token, str) or token not in supported for token in tokens.values()): + raise ValueError( + "SlipWall requires one explicit typed physical role for every state component") + normal_token = ("MomentumX", "MomentumY", "MomentumZ")[geometry.axis.index] + normal_velocity = ("VelocityX", "VelocityY", "VelocityZ")[geometry.axis.index] + normal = [ + component + for component, token in tokens.items() + if token in {normal_token, normal_velocity} + ] + if not normal: + raise ValueError( + "SlipWall on %s requires a declared normal polar-vector component" + % geometry.name + ) + return _resolved_condition( + self, + condition_type=self.condition_type, + geometry=geometry, + boundary=boundary, + requirement=requirement, + include_state_dependency=True, + ) + + @dataclass(frozen=True, slots=True, eq=False) class ResolvedTransportBoundarySet: domain_geometry_id: str + frame_id: str conditions: tuple[ResolvedTransportCondition, ...] plan: Any @@ -435,18 +759,27 @@ def __post_init__(self) -> None: if not isinstance(self.domain_geometry_id, str) or not self.domain_geometry_id: raise TypeError("resolved transport domain identity must be non-empty text") + if not isinstance(self.frame_id, str) or not self.frame_id: + raise TypeError("resolved transport frame identity must be non-empty text") if not isinstance(self.conditions, tuple) or not self.conditions \ or any(not isinstance(row, ResolvedTransportCondition) for row in self.conditions): raise TypeError("resolved transport conditions must be a non-empty tuple") if not isinstance(self.plan, ResolvedBoundaryPlan): raise TypeError("resolved transport plan must be a ResolvedBoundaryPlan") + # Resolution is the public acceptance boundary for a transport descriptor. Reusing the + # executable contract here prevents a characteristic, representation, analytic, or + # multi-state descriptor from surviving as inert metadata and failing only later during + # compile/bind. compile_boundary_data() and runtime_boundary_data() intentionally call the + # same pure validator again so detached/tampered resolved values remain fail-closed. + self._native_contract() def canonical_identity(self) -> dict[str, Any]: return { "schema_version": _SCHEMA_VERSION, "authority_type": "transport_boundary_set", "domain_geometry_id": self.domain_geometry_id, + "frame_id": self.frame_id, "conditions": [row.canonical_identity() for row in self.conditions], "plan": self.plan.canonical_identity(), } @@ -473,7 +806,17 @@ def compose_ghost_plan(self, context: Any) -> Any: return compose_transport_boundary(self, context=context) def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportCondition, ...], int]: - """Validate the complete compile-time shape of the built-in native provider.""" + """Validate the complete executable shape of the built-in native provider. + + This is the sole acceptance contract used at numerical resolution, compile, and bind. + """ + from pops.mesh.boundaries import ( + ClosureMode, + IncomingMultiplicity, + SignDependence, + SonicPolicy, + ) + states = {row.state for row in self.conditions} if len(states) != 1: raise NotImplementedError( @@ -485,6 +828,7 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio raise TypeError("resolved transport boundary state has no component manifest") ncomp = len(components) face_rows: list[ResolvedTransportCondition | None] = [None, None, None, None] + analytic_plan_clocks: set[str] = set() depth = 0 for condition in self.conditions: geometry = condition.geometry @@ -498,41 +842,134 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio face_rows[face] = condition depth = max(depth, condition.requirement.ghost_depth) dependencies = condition.provider.dependencies - flow = dependencies.representation - if flow.converter is not None or flow.source != flow.target: - raise NotImplementedError( - "native transport boundary lowering requires an authored compiled " - "representation converter" + characteristic = dependencies.characteristic + if characteristic.mode is not ClosureMode.NONE: + expected = model_characteristic_no_inflow(state) + exact_no_inflow = ( + condition.condition_type == "inflow" + and characteristic.mode is ClosureMode.DIRECTIONAL + and characteristic.sign_dependence is SignDependence.SPATIAL + and characteristic.sonic is SonicPolicy.NEUTRAL + and characteristic.incoming is IncomingMultiplicity.MULTIPLE + and characteristic.characteristics == (expected,) + and dependencies.states == (state,) + and not dependencies.fields + and not dependencies.time ) - if condition.condition_type == "inflow": - if dependencies.states or dependencies.fields or dependencies.time: + if not exact_no_inflow: raise NotImplementedError( - "state/field/time-dependent inflow requires a compiled boundary kernel; " - "the built-in native provider accepts only constants and RuntimeParams" + "native characteristic boundary requires prepared model eigenstructure " + "through the exact " + "model_characteristic_no_inflow(state) contract; directional modes " + "cannot fall back to component-wise ghost filling" ) + representation, _ = self._native_representation_contract(condition, state) + if condition.condition_type == "inflow": if len(condition.values) != ncomp: raise ValueError( "native inflow must prescribe exactly %d state components" % ncomp ) - for expression in condition.values: - if _expression_data( - expression, qualified=True).get("protocol") != "pops.expr.key.v1": - raise NotImplementedError("unsupported boundary expression protocol") + analytic = all(isinstance(row, ScalarExpr) for row in condition.values) + if analytic: + analytic_expressions = tuple( + cast(ScalarExpr, expression) for expression in condition.values + ) + if representation != "conservative": + raise NotImplementedError( + "analytic primitive inflow is unavailable because model conversion " + "must execute per boundary point; author conservative values instead" + ) + if dependencies.states or dependencies.fields: + raise NotImplementedError( + "analytic inflow cannot read discrete state or field storage" + ) + clocks = { + clock.qualified_id + for expression in analytic_expressions + for clock in expression.time_clocks() + } + if len(clocks) > 1: + raise ValueError( + "one analytic inflow face cannot mix several logical Clocks" + ) + analytic_plan_clocks.update(clocks) + for expression in analytic_expressions: + if expression.frame_id not in (None, self.frame_id): + raise ValueError("analytic inflow coordinate belongs to another frame") + if expression.input_references(): + raise NotImplementedError( + "analytic inflow cannot read setup-program discrete inputs" + ) + else: + if any(isinstance(row, ScalarExpr) for row in condition.values): + raise TypeError( + "native inflow values must use one expression protocol per face" + ) + if ( + dependencies.characteristic.mode is ClosureMode.NONE + and (dependencies.states or dependencies.fields or dependencies.time) + ): + raise NotImplementedError( + "state/field/time-dependent PoPS Expr inflow requires a compiled " + "boundary component" + ) + for expression in condition.values: + if ( + _expression_data(expression, qualified=True).get("protocol") + != "pops.expr.key.v1" + ): + raise NotImplementedError("unsupported boundary expression protocol") if any(row is None for row in face_rows): raise ValueError("native transport boundary has incomplete physical-face coverage") + if len(analytic_plan_clocks) > 1: + raise ValueError("one prepared analytic boundary plan cannot mix several logical Clocks") return state, ncomp, tuple(row for row in face_rows if row is not None), depth + @staticmethod + def _native_representation_contract( + condition: ResolvedTransportCondition, + state: Handle, + ) -> tuple[str, str | None]: + flow = condition.provider.dependencies.representation + target_name = getattr(getattr(state, "space", None), "representation", None) + if target_name != "conservative": + raise NotImplementedError( + "native transport boundaries require a conservative target state") + target = _representation_handle(state, target_name) + if flow.source == target and flow.target == target and flow.converter is None: + return "conservative", None + primitive = _representation_handle(state, "primitive") + expected_converter = model_primitive_to_conservative(state) + if ( + flow.source == primitive + and flow.target == target + and flow.converter == expected_converter + ): + if condition.condition_type != "inflow": + raise NotImplementedError( + "model primitive-to-conservative boundary conversion is defined only for " + "fixed-state inflow data" + ) + return "primitive", expected_converter.qualified_id + raise NotImplementedError( + "native transport boundary representation conversion requires the exact " + "model_primitive_to_conservative(state) provider" + ) + def compile_boundary_data(self) -> dict[str, Any]: """Return deterministic evidence that the authority has a total native lowering. RuntimeParam values intentionally remain unbound here. Their expression protocol and dependency set are authenticated now; numeric evaluation happens exactly once at bind. """ + from pops.mesh.boundaries import ClosureMode + state, ncomp, conditions, depth = self._native_contract() return { "schema_version": 1, "authority_type": "prepared_boundary_plan_compile", "source_plan": self.plan.canonical_id, + "frame_id": self.frame_id, "state": state.canonical_identity(), "ncomp": ncomp, "required_depth": depth, @@ -543,12 +980,32 @@ def compile_boundary_data(self) -> dict[str, Any]: "condition_type": row.condition_type, "producer": row.provider.qualified_id, "geometry": row.geometry.canonical_identity(), - "type": ("foextrap" if row.condition_type == "outflow" - else "dirichlet"), + "type": ( + "characteristic_no_inflow" + if row.provider.dependencies.characteristic.mode + is not ClosureMode.NONE + else { + "outflow": "foextrap", + "inflow": "dirichlet", + "no_flux": "no_flux", + "slip_wall": "slip_wall", + }[row.condition_type] + ), + "representation": self._native_representation_contract( + row, state)[0], + "converter": self._native_representation_contract( + row, state)[1], "values": ( - [] if row.condition_type == "outflow" else - [_expression_data(expression, qualified=True)["value"] - for expression in row.values] + [] + if row.condition_type in {"no_flux", "outflow"} + else [ + ( + _expression_data(expression, qualified=True) + if isinstance(expression, ScalarExpr) + else _expression_data(expression, qualified=True)["value"] + ) + for expression in row.values + ] ), } for row in conditions @@ -559,11 +1016,14 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: """Lower this resolved authority to the executable native v1 transport contract. The built-in provider intentionally supports only data that can be executed without a - Python callback: outflow and scalar expressions closed over BindSchema parameters. A - state/field/time-dependent inflow needs a compiled boundary kernel and therefore fails here - instead of being retained as ignored metadata. + Python callback: outflow, scalar expressions closed over BindSchema parameters, and + conservative analytic ``(x, y, t, params)`` inflow programs. Discrete state/field reads + need a compiled boundary component and therefore fail here instead of being retained as + ignored metadata. """ from pops.model._bind_expression import eval_expression_key + from pops.mesh.boundaries import ClosureMode + from pops.runtime._analytic_expression_lowering import lower_analytic_components if not isinstance(params, Mapping): raise TypeError("runtime boundary lowering requires resolved BindSchema values") @@ -578,29 +1038,75 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: for condition in conditions: geometry = condition.geometry face = 2 * geometry.axis.index + (0 if geometry.side.value == "lower" else 1) - if condition.condition_type == "outflow": + if condition.condition_type in {"no_flux", "outflow", "slip_wall"}: values = [0.0] * ncomp - face_type = "foextrap" + face_type = { + "no_flux": "no_flux", + "outflow": "foextrap", + "slip_wall": "slip_wall", + }[condition.condition_type] else: - values = [] - for index, expression in enumerate(condition.values): - data = _expression_data(expression, qualified=True) - value = eval_expression_key( - data["value"], env, - where="transport boundary %s component %d" % (geometry.name, index), + analytic_values = all( + isinstance(expression, ScalarExpr) for expression in condition.values + ) + if analytic_values: + analytic_expressions = tuple( + cast(ScalarExpr, expression) for expression in condition.values ) - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError( - "transport boundary values must lower to real scalars, got %r" % value + clocks = { + clock.qualified_id + for expression in analytic_expressions + for clock in expression.time_clocks() + } + clock_id = next(iter(clocks), None) + lowered = lower_analytic_components( + [expression.to_data() for expression in analytic_expressions], + frame_id=self.frame_id, + bindings=params, + time_clock_id=clock_id, + ) + analytic_programs = [ + {"opcodes": list(opcodes), "literals": list(literals)} + for opcodes, literals in lowered + ] + values = [0.0] * ncomp + else: + clock_id = None + analytic_programs = [] + values = [] + for index, expression in enumerate(condition.values): + data = _expression_data(expression, qualified=True) + value = eval_expression_key( + data["value"], env, + where="transport boundary %s component %d" % (geometry.name, index), ) - values.append(float(value)) - face_type = "dirichlet" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + "transport boundary values must lower to real scalars, got %r" + % value + ) + values.append(float(value)) + face_type = ( + "characteristic_no_inflow" + if condition.provider.dependencies.characteristic.mode + is not ClosureMode.NONE + else "dirichlet" + ) + if condition.condition_type in {"no_flux", "outflow", "slip_wall"}: + analytic_programs = [] + clock_id = None face_rows[face] = { "ordinal": face, "geometry": geometry.canonical_identity(), "producer": condition.provider.qualified_id, "type": face_type, + "representation": self._native_representation_contract( + condition, state)[0], + "converter": self._native_representation_contract( + condition, state)[1], "values": values, + "analytic_programs": analytic_programs, + "analytic_clock": clock_id, } rows = tuple(row for row in face_rows if row is not None) evidence = { @@ -819,6 +1325,7 @@ def labels(rows: Any) -> list[str]: plan = BoundaryProviderRegistry(*providers).resolve(topology, needs) return ResolvedTransportBoundarySet( domain_geometry_id=expected[0].domain_geometry_id, + frame_id=context.frame.canonical_id, conditions=tuple(resolved_conditions), plan=plan, ) @@ -827,8 +1334,12 @@ def labels(rows: Any) -> list[str]: __all__ = [ "BoundaryStencilRequirement", "Inflow", + "model_characteristic_no_inflow", + "model_primitive_to_conservative", + "NoFlux", "Outflow", "ResolvedTransportBoundarySet", "ResolvedTransportCondition", + "SlipWall", "TransportBoundarySet", ] diff --git a/python/pops/codegen/_amr_lowering_coverage.py b/python/pops/codegen/_amr_lowering_coverage.py new file mode 100644 index 000000000..4b0227bfa --- /dev/null +++ b/python/pops/codegen/_amr_lowering_coverage.py @@ -0,0 +1,139 @@ +"""Exact AMR authoring-to-runtime rows for the global lowering coverage report.""" +from __future__ import annotations + +from typing import Any + +from pops.codegen.lowering_coverage import LoweringCoverageReport, LoweringCoverageRow +from pops.identity import make_identity + + +def amr_lowering_coverage( + *, + resolved_hierarchy: Any, + transfer: Any, + bootstrap: Any, + execution: Any, +) -> LoweringCoverageReport: + """Project the resolved AMR authorities onto their executable runtime routes.""" + + from pops.amr.authoring import AMRExecution + from pops.mesh._amr._bootstrap_contracts import BootstrapPlan + from pops.mesh._amr._transfer_contracts import ResolvedAMRTransfer + from pops.mesh._amr.hierarchy_resolution import ResolvedHierarchy + + if type(resolved_hierarchy) is not ResolvedHierarchy: + raise TypeError("AMR lowering coverage requires an exact ResolvedHierarchy") + if type(transfer) is not ResolvedAMRTransfer: + raise TypeError("AMR lowering coverage requires an exact ResolvedAMRTransfer") + if type(bootstrap) is not BootstrapPlan: + raise TypeError("AMR lowering coverage requires an exact BootstrapPlan") + if type(execution) is not AMRExecution: + raise TypeError("AMR lowering coverage requires an exact AMRExecution") + + hierarchy_identity = resolved_hierarchy.identity.token + transfer_identity = transfer.identity.token + bootstrap_identity = bootstrap.identity.token + execution_identity = make_identity("amr-execution", execution.to_data()).token + tagging = bootstrap.tagging + tagging_target = "amr-runtime-tagging:%s" % tagging.qualified_id + hysteresis_targets = ["%s:hysteresis" % tagging_target] + if tagging.graph.hysteresis.min_cycles > 0: + hysteresis_targets.append( + "amr-runtime-program-accepted-state:tagging_hysteresis_state") + + rows = [ + LoweringCoverageRow( + source="amr-hierarchy:%s" % hierarchy_identity, + disposition="lowered", + targets=("amr-runtime-hierarchy:%s" % hierarchy_identity,), + ), + LoweringCoverageRow( + source="amr-regrid:%s" % resolved_hierarchy.plan.regrid.identity.token, + disposition="lowered", + targets=("amr-runtime-regrid:%s" % hierarchy_identity,), + ), + LoweringCoverageRow( + source="amr-tagging-graph:%s" % tagging.qualified_id, + disposition="lowered", + targets=(tagging_target,), + ), + LoweringCoverageRow( + source="amr-tagging-hysteresis:%s" % tagging.qualified_id, + disposition="lowered", + targets=tuple(hysteresis_targets), + ), + LoweringCoverageRow( + source="amr-tagging-conflict-policy:%s" % tagging.qualified_id, + disposition="lowered", + targets=("%s:conflict-policy" % tagging_target,), + ), + LoweringCoverageRow( + source="amr-transfer-plan:%s" % transfer_identity, + disposition="lowered", + targets=("amr-runtime-transfer:%s" % transfer_identity,), + ), + LoweringCoverageRow( + source="amr-execution:%s" % execution_identity, + disposition="lowered", + targets=("amr-runtime-execution:%s" % execution.mode,), + ), + LoweringCoverageRow( + source="amr-bootstrap:%s" % bootstrap_identity, + disposition="lowered", + targets=("amr-runtime-bootstrap:%s" % bootstrap_identity,), + ), + ] + registrations = { + registration.node_type: registration + for registration in tagging.registrations + } + + def append_predicate(node: Any, path: str) -> None: + registration = registrations[node.node_type] + rows.append(LoweringCoverageRow( + source="amr-tagging-predicate:%s:%s:%s" + % (tagging.qualified_id, path, node.node_type), + disposition="lowered", + targets=(registration.lowering.qualified_id,), + )) + for index, child in enumerate(node.operands()): + append_predicate(child, "%s/%d" % (path, index)) + + append_predicate(tagging.graph.refine, "refine") + if tagging.graph.coarsen is not None: + append_predicate(tagging.graph.coarsen, "coarsen") + rows.extend( + LoweringCoverageRow( + source="amr-transfer-entry:%s" % entry.identity.token, + disposition="lowered", + targets=( + "amr-runtime-transfer-operation:%s:%s" + % ( + entry.native_materialization.to_data()["action"], + entry.key.operation.name, + ), + ), + ) + for entry in transfer.entries + ) + rows.extend( + LoweringCoverageRow( + source="amr-subcycling:%s:%d-%d" + % (execution_identity, relation.parent_level, relation.child_level), + disposition="lowered", + targets=( + "amr-runtime-clock-relation:%d-%d:%d/%d" + % ( + relation.parent_level, + relation.child_level, + relation.temporal_ratio.numerator, + relation.temporal_ratio.denominator, + ), + ), + ) + for relation in execution.relations + ) + return LoweringCoverageReport(rows) + + +__all__ = ["amr_lowering_coverage"] diff --git a/python/pops/codegen/_amr_plan_validation.py b/python/pops/codegen/_amr_plan_validation.py index 63d9a8311..29fe06fd2 100644 --- a/python/pops/codegen/_amr_plan_validation.py +++ b/python/pops/codegen/_amr_plan_validation.py @@ -81,8 +81,9 @@ def validate_amr_authorities(plan: Any) -> None: or plan.bootstrap_plan.initial_identity != plan.initial_condition_plan.identity: raise ValueError("ResolvedSimulationPlan bootstrap does not authenticate AMR authorities") providers = plan.amr_providers - if tuple(providers) != ("clustering", "tagger"): - raise ValueError("AMR plan requires exact clustering and tagger provider bindings") + if tuple(providers) != ("clustering", "tagger", "reflux"): + raise ValueError( + "AMR plan requires exact clustering, tagger and reflux provider bindings") # Component inputs deliberately admit both source authorities and already-compiled # artifacts. Their representations differ, but both expose the same authenticated # projection protocol. Index that projection instead of reaching through the source-only diff --git a/python/pops/codegen/_cell_centered_field_lowering.py b/python/pops/codegen/_cell_centered_field_lowering.py index 5961683a2..341464a26 100644 --- a/python/pops/codegen/_cell_centered_field_lowering.py +++ b/python/pops/codegen/_cell_centered_field_lowering.py @@ -263,38 +263,6 @@ def _resolve( name, plan, rows, request.layout, request.operator.unknown ) dependencies = boundary_dependency_pack(plan, request.operator.unknown) - if target == "amr_system" and dependencies["fields"]: - _reject( - rows, "field:%s:boundaries" % name, - "field.boundary.amr_field_dependency_not_native", - "field %r has a boundary law depending on another solved field; the AMR " - "provider has no exact composite materialization route" % name, - ) - if ( - target == "amr_system" - and layout_contract.levels > 1 - and dependencies["states"] - ): - _reject( - rows, "field:%s:boundaries" % name, - "field.boundary.amr_multilevel_state_dependency_not_native", - "field %r has a state-dependent boundary law on a multilevel hierarchy" % name, - ) - for kind in ("states", "fields"): - for dependency in dependencies[kind]: - rows.append(LoweringCoverageRow( - "field:%s:boundary-dependency:%s:%d" % ( - name, dependency["qualified_id"], dependency["component"] - ), - "lowered", - ("field-install:%s:boundary-buffer:%s" % (name, kind),), - )) - for coordinate in dependencies["logical_time"]: - rows.append(LoweringCoverageRow( - "field:%s:boundary-time:%s" % (name, coordinate), - "lowered", - ("field-install:%s:logical-timepoint" % name,), - )) boundary_dynamic = faces is not None and any(face["dynamic"] for face in faces) boundary_iterate = faces is not None and any( face["iterate_dependent"] for face in faces @@ -330,6 +298,24 @@ def _resolve( "derived", rule="%s + provider-target=%s" % (policy, target), )) + for kind in ("states", "fields"): + for dependency in dependencies[kind]: + route = "field-install:%s:boundary-buffer:%s" % (name, kind) + if target == "amr_system": + route += ":level-qualified" + rows.append(LoweringCoverageRow( + "field:%s:boundary-dependency:%s:%d" % ( + name, dependency["qualified_id"], dependency["component"] + ), + "lowered", + (route,), + )) + for coordinate in dependencies["logical_time"]: + rows.append(LoweringCoverageRow( + "field:%s:boundary-time:%s" % (name, coordinate), + "lowered", + ("field-install:%s:logical-timepoint" % name,), + )) if plan.preconditioner is not None: _reject( @@ -389,6 +375,9 @@ def _resolve( "dependent": any( dependencies[kind] for kind in ("states", "fields", "logical_time") ), + "state_dependent": bool(dependencies["states"]), + "field_dependent": bool(dependencies["fields"]), + "logical_time_coordinates": tuple(dependencies["logical_time"]), "iterate_dependent": boundary_iterate, }, nonlinear=plan.nonlinear is not None, diff --git a/python/pops/codegen/_compile_drivers.py b/python/pops/codegen/_compile_drivers.py index 7347160e8..98d5a449d 100644 --- a/python/pops/codegen/_compile_drivers.py +++ b/python/pops/codegen/_compile_drivers.py @@ -186,7 +186,68 @@ def compile_problem(so_path: Any = None, *, model: Any = None, model_graph: Any backend: Any = "production", target: Any = "system", force: Any = False, cxx: Any = None, include: Any = None, std: Any = None, debug: Any = False, libraries: Any = None, problem_snapshot: Any = None, - field_plans: Any = None) -> Any: + field_plans: Any = None, balance_due_contract: Any = None) -> Any: + """Compile the public low-level Program route without privileged resolve evidence.""" + return _compile_problem_impl( + so_path, + model=model, + model_graph=model_graph, + time=time, + backend=backend, + target=target, + force=force, + cxx=cxx, + include=include, + std=std, + debug=debug, + libraries=libraries, + problem_snapshot=problem_snapshot, + field_plans=field_plans, + balance_due_contract=balance_due_contract, + shared_interface_codegen_evidence=None, + ) + + +def _compile_resolved_problem(plan: Any) -> Any: + """Compile only the route authenticated by one exact resolved plan.""" + from pops.codegen._plans import ResolvedSimulationPlan + + if type(plan) is not ResolvedSimulationPlan: + raise TypeError("resolved Program compilation requires an exact simulation plan") + from pops.codegen._shared_interface_evidence import ( + _issue_shared_interface_codegen_evidence, + ) + + evidence = _issue_shared_interface_codegen_evidence(plan) + from pops.codegen._orchestration_compile import build_program_model_graph + from pops.codegen.program_balance_due import validate_balance_due_contract + from pops._balance_due_contract import BalanceDueContract + + balance_due_contract = BalanceDueContract.from_consumer_graph(plan.consumer_graph) + validate_balance_due_contract(plan.time, balance_due_contract) + options = dict(plan.compile_options) + options["libraries"] = plan.libraries + return _compile_problem_impl( + model_graph=build_program_model_graph(plan), + time=plan.time, + backend=plan.backend, + target=plan.target, + problem_snapshot=plan.snapshot, + field_plans=plan.field_plans, + balance_due_contract=balance_due_contract, + shared_interface_codegen_evidence=evidence, + **options, + ) + + +def _compile_problem_impl( + so_path: Any = None, *, model: Any = None, model_graph: Any = None, + time: Any = None, backend: Any = "production", target: Any = "system", + force: Any = False, cxx: Any = None, include: Any = None, std: Any = None, + debug: Any = False, libraries: Any = None, problem_snapshot: Any = None, + field_plans: Any = None, balance_due_contract: Any = None, + shared_interface_codegen_evidence: Any, +) -> Any: """Compile a time Program into an ABI-compatible native ``problem.so``. Only the production backend is supported; ``target`` selects system or AMR entrypoints. An @@ -212,7 +273,6 @@ def compile_problem(so_path: Any = None, *, model: Any = None, model_graph: Any if target not in ("system", "amr_system"): raise ValueError("compiled time programs support target='system' | 'amr_system' " "(received %r)" % (target,)) - if libraries: raise TypeError( "compile_problem(libraries=) was removed; compile authenticated source components " @@ -234,12 +294,32 @@ def compile_problem(so_path: Any = None, *, model: Any = None, model_graph: Any from pops.time._program.detach import detach_compiled_program time = detach_compiled_program(time) program_graph = time.to_graph() + from pops._balance_due_contract import BalanceDueContract + if balance_due_contract is None: + balance_due_contract = BalanceDueContract.from_consumer_graph(None) + if type(balance_due_contract) is not BalanceDueContract: + raise TypeError( + "compile_problem balance_due_contract must be an exact BalanceDueContract" + ) from pops.codegen.program_emit_kernels import _prepared_native_components native_components = _prepared_native_components(time) - from pops.codegen.program_graph_lowering import emit_program_graph - src = emit_program_graph( - program_graph, lowering_program=time, model=model, - model_graph=model_graph, target=target, field_plans=field_plans) + if shared_interface_codegen_evidence is None: + from pops.codegen.program_graph_lowering import emit_program_graph + + src = emit_program_graph( + program_graph, lowering_program=time, model=model, + model_graph=model_graph, target=target, field_plans=field_plans, + balance_due_contract=balance_due_contract, + ) + else: + from pops.codegen.program_graph_lowering import _emit_resolved_program_graph + + src = _emit_resolved_program_graph( + program_graph, lowering_program=time, model=model, + model_graph=model_graph, target=target, field_plans=field_plans, + balance_due_contract=balance_due_contract, + shared_interface_codegen_evidence=shared_interface_codegen_evidence, + ) include = include or pops_include() sig = pops_header_signature(include) diff --git a/python/pops/codegen/_compile_emit.py b/python/pops/codegen/_compile_emit.py index 162c811e2..0e2190759 100644 --- a/python/pops/codegen/_compile_emit.py +++ b/python/pops/codegen/_compile_emit.py @@ -93,6 +93,11 @@ def _roles_for(names: Any, override: Any = None) -> list: parts.append("prim_state=%s" % ",".join(m.prim_state)) parts.append("proles=%s" % ",".join(_roles_for(m.prim_state, m.prim_roles))) parts.append("prim=%s" % ";".join("%s=%r" % (k, m.prim_defs[k]) for k in m.prim_defs)) + recovery_constraints = getattr(m, "_recovery_admissibility", None) + if recovery_constraints: + parts.append("recovery_admissibility=%s" % ";".join( + "%s=%r" % (name, recovery_constraints[name]) + for name in m.prim_state if name in recovery_constraints)) for d in ("x", "y"): parts.append("flux_%s=%s" % (d, ";".join(repr(e) for e in m._flux.get(d, [])))) parts.append("eig_%s=%s" % (d, ";".join(repr(e) for e in m._eig.get(d, [])))) @@ -135,16 +140,29 @@ def _roles_for(names: Any, override: Any = None) -> list: if m._src_jac is not None else "")) if getattr(m, "_proj", None) is not None: parts.append("proj=%s" % ";".join(repr(e) for e in m._proj)) + from pops.numerics.riemann.providers import authoring_provider_evidence + + riemann_evidence = authoring_provider_evidence(m) parts.append("hllc=%d" % (1 if m._hllc else 0)) + if riemann_evidence.hllc_provider is not None: + parts.append("hllc_provider=%s" % riemann_evidence.hllc_provider) forms = getattr(m, "_riemann_hook_forms", None) if forms: parts.append("riemann_hooks=%s" % ";".join( "%s=%r" % (k, forms[k]) for k in sorted(forms))) parts.append("roe=%d" % (1 if getattr(m, "_roe", False) else 0)) + if riemann_evidence.roe_provider is not None: + parts.append("roe_provider=%s" % riemann_evidence.roe_provider) + parts.append("roe_entropy_policy=%s" % riemann_evidence.roe_entropy_policy) + if riemann_evidence.roe_entropy_delta is not None: + parts.append("roe_entropy_delta=%s" % riemann_evidence.roe_entropy_delta) if getattr(m, "_roe_rows", None) is not None: parts.append("roe_rows=%s" % ";".join(repr(e) for k in ("x", "y") for e in m._roe_rows[k])) if getattr(m, "_roe_jacobian", None) is not None: + from pops.codegen.module_emit_riemann import has_characteristic_no_inflow_provider + if has_characteristic_no_inflow_provider(m): + parts.append("characteristic_no_inflow=flux_jacobian_v1") parts.append("roe_jac=%s" % ";".join(repr(e) for k in ("x", "y") for row in m._roe_jacobian[k] for e in row)) entropy_fix = m._roe_jacobian.get("entropy_fix") diff --git a/python/pops/codegen/_compiled_artifact.py b/python/pops/codegen/_compiled_artifact.py index 88c9fa798..4913ff96b 100644 --- a/python/pops/codegen/_compiled_artifact.py +++ b/python/pops/codegen/_compiled_artifact.py @@ -69,6 +69,7 @@ class CompiledPlanRecord: backend: str layout: Any layout_plan: Any + native_layouts: Mapping[str, Any] layout_targets: Mapping[str, str] bind_schema: Any compile_values: Mapping[Any, Any] @@ -87,6 +88,7 @@ class CompiledPlanRecord: bootstrap_plan: Any = None amr_execution: Any = None amr_providers: Mapping[str, Any] = field(default_factory=dict) + resolved_dimension: int = field(init=False) contract_identity: Identity = field(init=False) @classmethod @@ -101,6 +103,7 @@ def from_resolved(cls, plan: ResolvedSimulationPlan) -> CompiledPlanRecord: backend=plan.backend, layout=plan.layout, layout_plan=plan.layout_plan, + native_layouts=plan.native_layouts, layout_targets=plan.layout_targets, bind_schema=plan.bind_schema, compile_values=plan.compile_values, @@ -142,6 +145,22 @@ def __post_init__(self) -> None: from pops.codegen.lowering_coverage import LoweringCoverageReport if type(self.layout_plan) is not LayoutPlan: raise TypeError("CompiledPlanRecord.layout_plan must be an exact LayoutPlan") + from pops.codegen._native_spatial_layout import ( + native_spatial_layouts, + resolved_dimension, + ) + + expected_native_layouts = native_spatial_layouts(self.layout_plan) + if not isinstance(self.native_layouts, Mapping) \ + or tuple(self.native_layouts) != tuple(expected_native_layouts): + raise ValueError("CompiledPlanRecord has invalid native layout specializations") + for layout_id, expected in expected_native_layouts.items(): + actual = self.native_layouts[layout_id] + if type(actual) is not type(expected) or actual.to_data() != expected.to_data(): + raise ValueError( + "CompiledPlanRecord native layout specializations differ from LayoutPlan") + object.__setattr__(self, "native_layouts", _deep_freeze(self.native_layouts)) + object.__setattr__(self, "resolved_dimension", resolved_dimension(self.native_layouts)) targets = dict(self.layout_targets) expected_targets = tuple(row.handle.qualified_id for row in self.layout_plan.layouts) if tuple(targets) != expected_targets or any( @@ -232,6 +251,9 @@ def _payload(self) -> dict[str, Any]: "layout": _evidence(self.layout, where="compiled plan layout"), "layout_plan": _evidence( self.layout_plan, where="compiled plan layout plan"), + "native_layouts": _evidence( + self.native_layouts, where="compiled plan native layouts"), + "resolved_dimension": self.resolved_dimension, "layout_targets": _evidence( self.layout_targets, where="compiled plan layout targets"), "bind_schema": _evidence(self.bind_schema, where="compiled plan bind schema"), @@ -607,6 +629,14 @@ def layout(self) -> Any: def layout_plan(self) -> Any: return self.plan.layout_plan + @property + def native_layouts(self) -> Mapping[str, Any]: + return self.plan.native_layouts + + @property + def resolved_dimension(self) -> int: + return self.plan.resolved_dimension + @property def so_path(self) -> str: return str(self._common_executable_attribute("so_path")) diff --git a/python/pops/codegen/_compiled_model_boundary.py b/python/pops/codegen/_compiled_model_boundary.py index 63afa377f..19734ca51 100644 --- a/python/pops/codegen/_compiled_model_boundary.py +++ b/python/pops/codegen/_compiled_model_boundary.py @@ -15,9 +15,11 @@ "state_spaces", ) _SCALAR_FIELDS = ( - "has_hllc", "has_roe", "has_wave_speeds", "so_path", "backend", "target", + "has_hllc", "has_roe", "has_wave_speeds", "has_characteristic_no_inflow", + "so_path", "backend", "target", "n_vars", "gamma", "n_aux", "abi_key", "model_hash", "cxx", "std", - "wave_speed_provider", + "wave_speed_provider", "hllc_provider", "roe_provider", "roe_entropy_policy", + "roe_entropy_delta", ) _CORE_FIELDS = set(_SEQUENCE_FIELDS) | set(_SCALAR_FIELDS) | { "params", "caps", "bind_schema", "install_plan", "definition_identity", @@ -75,6 +77,9 @@ def _validate_core(compiled: Any, *, allow_install_plan: bool) -> None: raise ValueError( "CompiledModel without wave speeds cannot retain wave_speed_provider" ) + from pops.numerics.riemann.providers import compiled_provider_evidence + + compiled_provider_evidence(compiled) _data_mapping(_core_value(compiled, "caps"), where="caps") identity = _core_value(compiled, "definition_identity") if identity is not None: diff --git a/python/pops/codegen/_compiler_lowering.py b/python/pops/codegen/_compiler_lowering.py index 9d45e150e..885ec68aa 100644 --- a/python/pops/codegen/_compiler_lowering.py +++ b/python/pops/codegen/_compiler_lowering.py @@ -12,6 +12,7 @@ class _CompilerEmitter(Protocol): """Minimal executable half of a compiler lowering.""" def check(self) -> object: ... + def __pops_bind_component_provider_packs__(self, packs: Any) -> None: ... def __pops_native_loader_source__( self, *, name: Any = None, target: str = "system", hoist_reciprocals: bool = False, @@ -26,6 +27,14 @@ class CompilerLowering: source_module: Module facade: object + def bind_component_provider_packs(self, packs: Any) -> None: + """Bind one resolved provider-pack authority before native source emission.""" + result = self.emit_model.__pops_bind_component_provider_packs__(packs) + if result is not None: + raise TypeError( + "compiler provider-pack binding protocol must return None" + ) + def native_loader_source( self, *, name: Any = None, target: str = "system", hoist_reciprocals: bool = False, diff --git a/python/pops/codegen/_inspect_compiled_report.py b/python/pops/codegen/_inspect_compiled_report.py index 9ca7cdfca..6071cdc47 100644 --- a/python/pops/codegen/_inspect_compiled_report.py +++ b/python/pops/codegen/_inspect_compiled_report.py @@ -287,6 +287,16 @@ def build_compiled_report(compiled: Any) -> CompiledReport: layout = layout_runtime.get("layout", "system") from pops.runtime_environment import compiled_runtime_facts runtime = compiled_runtime_facts(supports_mpi=layout_runtime.get("supports_mpi")) + artifact = getattr(compiled, "artifact", compiled) + selected_dimension = getattr(artifact, "resolved_dimension", None) + if isinstance(selected_dimension, bool) or not isinstance(selected_dimension, int): + raise TypeError("compiled artifact report requires one exact resolved_dimension") + runtime["dimension"] = selected_dimension + platform_manifest = getattr(artifact, "platform_manifest", None) + if platform_manifest is not None: + runtime["supported_dimensions"] = list( + platform_manifest.capabilities["supported_dimensions"].require( + "compiled.platform.supported_dimensions")) so_path, so_paths = _qualified_executable_values(compiled, "so_path") abi_key, abi_keys = _qualified_executable_values(compiled, "abi_key") diff --git a/python/pops/codegen/_interface_validation.py b/python/pops/codegen/_interface_validation.py index dd03a1dff..8152e9d35 100644 --- a/python/pops/codegen/_interface_validation.py +++ b/python/pops/codegen/_interface_validation.py @@ -103,13 +103,130 @@ def _jacvec_location(value: Any, path: tuple[str, ...]) -> str: getattr(value, "name", ""), _block_name(value.inputs[2]), location) +def _state_component_count(value: Any, *, where: str) -> int: + components = getattr(getattr(value, "space", None), "components", None) + if not isinstance(components, tuple) or not components: + raise TypeError("%s requires a registry-issued non-empty StateSpace" % where) + return len(components) + + +def _validate_shared_interface_jacvec_pairs( + program: Any, *, target: str, hierarchy: Any, neighbours: dict[str, set[str]], + interface_count: int, coherence: Any) -> set[int]: + """Prove the deliberately narrow packed two-block implicit interface route. + + One matrix-free apply owns one packed Krylov vector. Its two rhs_jacvec nodes consume + disjoint component spans in apply-block order, but perturb both endpoint states before one + atomic shared-flux evaluation. Anything that cannot establish that exact shape is rejected at + resolve rather than degrading to two one-sided derivatives. + """ + participants = frozenset(neighbours) + all_jacvec = [ + value for value in _nested_values(program._values) + if getattr(value, "op", None) == "rhs_jacvec" + and _block_name(value.inputs[2]) in participants + ] + if not all_jacvec: + return set() + if target != "amr_system": + raise NotImplementedError( + "shared NumericalFlux implicit JVP is available only on a frozen two-level AMR " + "hierarchy") + + from pops.mesh._amr import FrozenHierarchy + + if type(hierarchy.regrid) is not FrozenHierarchy or hierarchy.level_count != 2: + raise NotImplementedError( + "shared NumericalFlux implicit JVP requires exactly one frozen two-level AMR " + "hierarchy") + # Unrelated blocks may carry the packed Krylov RHS or other independently authored state. + # The native pair is qualified by its two exact endpoint block identities, so only the + # participating interface graph must remain the proved one-edge bijection. + if (interface_count != 1 or len(participants) != 2 or + any(len(neighbours[name]) != 1 for name in participants)): + raise NotImplementedError( + "shared NumericalFlux implicit JVP supports exactly one two-block interface") + + rhs_round: dict[int, Any] = {} + for round_ in coherence.rounds: + for value in round_.values: + rhs_round[value.id] = round_ + + proved: set[int] = set() + for operator in program._values: + if getattr(operator, "op", None) != "matrix_free_operator": + continue + apply_block = operator.attrs.get("apply_block") or () + pair = [ + value for value in apply_block + if getattr(value, "op", None) == "rhs_jacvec" + and _block_name(value.inputs[2]) in participants + ] + if not pair: + continue + unsupported = [ + value.op for value in apply_block + if value.op not in {"apply_in", "apply_out", "rhs_jacvec"} + ] + if unsupported: + raise NotImplementedError( + "shared NumericalFlux rhs_jacvec apply cannot mix operators %s" + % sorted(set(unsupported))) + if len(pair) != 2: + raise NotImplementedError( + "shared NumericalFlux matrix-free apply requires exactly two rhs_jacvec nodes") + if any(value.attrs.get("field_coupled") is not False for value in pair): + raise NotImplementedError( + "shared NumericalFlux implicit JVP requires field_coupled=False") + if pair[0].inputs[0] is not pair[1].inputs[0] or pair[0].inputs[1] is not pair[1].inputs[1]: + raise ValueError( + "shared NumericalFlux rhs_jacvec pair must share the exact packed apply in/out") + blocks = tuple(_block_name(value.inputs[2]) for value in pair) + if None in blocks or len(set(blocks)) != 2 or set(blocks) != set(participants): + raise ValueError( + "shared NumericalFlux rhs_jacvec pair must cover both interface endpoints once") + first, second = pair + exact_attrs = ("c_dt", "eps", "flux", "sources", "field_coupled") + changed = [name for name in exact_attrs if first.attrs.get(name) != second.attrs.get(name)] + if changed or first.point != second.point: + raise ValueError( + "shared NumericalFlux rhs_jacvec pair must preserve one point and coefficient " + "contract; changed %s" % sorted(changed)) + widths = tuple( + _state_component_count(value.inputs[2], where=_jacvec_location(value, ())) + for value in pair + ) + if operator.attrs.get("ncomp") != sum(widths): + raise ValueError( + "shared NumericalFlux packed operator component count must equal the sum of its " + "two endpoint StateSpaces") + rounds = [rhs_round.get(value.inputs[3].id) for value in pair] + if rounds[0] is None or rounds[0] is not rounds[1]: + raise ValueError( + "shared NumericalFlux rhs_jacvec bases must come from one atomic top-level RHS " + "coherence round") + base_blocks = {_block_name(value) for value in rounds[0].values} + if not set(participants).issubset(base_blocks): + raise ValueError( + "shared NumericalFlux rhs_jacvec base coherence round is missing one endpoint") + proved.update(value.id for value in pair) + + missing = sorted(value.name for value in all_jacvec if value.id not in proved) + if missing: + raise NotImplementedError( + "shared NumericalFlux rhs_jacvec nodes require one paired matrix-free apply: %s" + % missing) + return proved + + def validate_prepared_boundary_jacvec(blocks: tuple[Any, ...], program: Any) -> None: """Fail closed when an external boundary JVP cannot execute the authored ``rhs_jacvec``. The current matrix-free runtime supplies one direction for the owning conservative state and - one mutable output. It can keep solved fields frozen, but it has no tangent-field materializer - for a field-coupled total derivative. Validate those facts at resolve rather than after the - first Krylov matvec. + one mutable output. A field-coupled apply re-solves its exact prepared field-provider closure + from the perturbed state and finite-differences the complete boundary residual while that + perturbed field publication is active. Validate the remaining single-block direction/output + facts at resolve rather than after the first Krylov matvec. """ if program is None: return @@ -180,20 +297,15 @@ def validate_prepared_boundary_jacvec(blocks: tuple[Any, ...], program: Any) -> "%s supports exactly one mutable external boundary output per residual/JVP; " "got residual=%d, jvp=%d" % (where, len(residual_outputs), len(jvp_outputs))) - fields = _qualified_table(residual, "fields") + _qualified_table(residual, "fields") field_coupled = value.attrs.get("field_coupled") if not isinstance(field_coupled, bool): raise TypeError("%s requires a boolean field_coupled contract" % where) - if field_coupled and fields: - raise NotImplementedError( - "%s reads solved boundary field(s) %s, but the native matrix-free runtime " - "has no field-tangent materializer for field_coupled=True" - % (where, list(fields))) def validate_shared_interface_program( blocks: tuple[Any, ...], layout_plan: Any, program: Any, *, - target: str, resolved_hierarchy: Any = None) -> bool: + target: str, resolved_hierarchy: Any = None) -> tuple[bool, bool]: """Prove that every interface is installed and evaluated as one atomic RHS group. This runs during resolve, before code generation or engine construction. The runtime @@ -223,7 +335,7 @@ def validate_shared_interface_program( if side.boundary in owned_boundaries: endpoint_owners[identity][side_name].add(block.name) if not declarations: - return False + return False, False if program is None: raise ValueError("shared block interfaces require one explicit whole-system Program") @@ -285,6 +397,13 @@ def validate_shared_interface_program( "requires at least two configured levels and the complete prefix active at bind" ) + values = list(program._values) + coherence = plan_rhs_coherence(program, values, block_key=_block_name) + hierarchy = None if resolved_hierarchy is None else resolved_hierarchy.plan + implicit_jacvec_ids = _validate_shared_interface_jacvec_pairs( + program, target=target, hierarchy=hierarchy, neighbours=neighbours, + interface_count=len(declarations), coherence=coherence) + participant_names = frozenset(neighbours) for value, path in _nested_control_values(program._values): block = _block_name(value) @@ -296,15 +415,6 @@ def validate_shared_interface_program( "StagePoint." % (value.name, block, " -> ".join(path)) ) - for value in _nested_values(program._values): - if getattr(value, "op", None) == "rhs_jacvec" \ - and _block_name(value.inputs[2]) in participant_names: - raise NotImplementedError( - "shared NumericalFlux implicit JVP requires a coupled two-sided trace " - "linearization; the current NumericalFlux scheduler is explicit-only" - ) - - values = list(program._values) covered: set[int] = set() for value in values: block = _block_name(value) @@ -315,7 +425,6 @@ def validate_shared_interface_program( "source work; split the named source into a separate Program node" % (value.name, block)) - coherence = plan_rhs_coherence(program, values, block_key=_block_name) for round_ in coherence.rounds: group = round_.values names = [_block_name(row) for row in group] @@ -341,7 +450,7 @@ def validate_shared_interface_program( raise ValueError( "shared interface default-flux evaluations were not proved simultaneous: %s" % sorted(ungrouped)) - return True + return True, bool(implicit_jacvec_ids) __all__ = ["validate_prepared_boundary_jacvec", "validate_shared_interface_program"] diff --git a/python/pops/codegen/_layout_resolution.py b/python/pops/codegen/_layout_resolution.py index 2f0cb715e..4e7742e48 100644 --- a/python/pops/codegen/_layout_resolution.py +++ b/python/pops/codegen/_layout_resolution.py @@ -233,6 +233,24 @@ def resolve_layout(problem: Any, layout: Any, *, providers: Any = None) \ plan, (ResolvedRuntimeLayout(plan.layouts[0].handle, runtime_descriptor),))) +def resolve_native_spatial_layouts(plan: Any) -> Mapping[str, Any]: + """Select the current production spatial specialization before artifact creation.""" + from pops.codegen._native_spatial_layout import ( + NativeSpatialLayoutError, + native_spatial_layouts, + ) + + try: + return native_spatial_layouts(plan) + except NativeSpatialLayoutError as exc: + _refuse_runtime( + plan, + gate=exc.code, + message=str(exc), + details=exc.to_data(), + ) + + def _select_runtime_providers(plan: Any, providers: Any) -> Any: if providers is None: return None @@ -372,7 +390,13 @@ def layout_lowering_coverage(plan: Any, *, rejected_gate: str | None = None) -> return LoweringCoverageReport(rows) -def _refuse_runtime(plan: Any, *, gate: str, message: str) -> NoReturn: +def _refuse_runtime( + plan: Any, + *, + gate: str, + message: str, + details: Mapping[str, Any] | None = None, +) -> NoReturn: coverage = layout_lowering_coverage(plan, rejected_gate=gate) evidence = { "gate": gate, @@ -381,12 +405,15 @@ def _refuse_runtime(plan: Any, *, gate: str, message: str) -> NoReturn: "resources": list(plan.resource_requirements()), "lowering_coverage": coverage.to_data(), } + if details is not None: + evidence["refusal"] = dict(details) raise LayoutCapabilityError(message, evidence=evidence, coverage_report=coverage) __all__ = [ "LayoutCapabilityError", "ResolvedLayoutAuthority", "ResolvedRuntimeLayout", "ResolvedRuntimeLayouts", "layout_lowering_coverage", - "materialized_layout_subjects", "resolve_layout", "validate_layout", + "materialized_layout_subjects", "resolve_layout", "resolve_native_spatial_layouts", + "validate_layout", "validate_layout_mapping_components", "validate_program_layout_reads", ] diff --git a/python/pops/codegen/_loader_model.py b/python/pops/codegen/_loader_model.py index 117002fca..a6c95db0c 100644 --- a/python/pops/codegen/_loader_model.py +++ b/python/pops/codegen/_loader_model.py @@ -31,10 +31,38 @@ def __init__(self, so_path: Any, backend: Any, cons_names: Any, cons_roles: Any, wave_speeds: Any = False, elliptic_field_names: Any = None, bind_schema: Any = None, definition_identity: Any = None, state_spaces: Any = ("U",), wave_speed_provider: Any = None, - module_manifest: Any = None) -> None: - self.has_hllc = bool(hllc) # HLLC capability emitted (enable_hllc): hllc available beyond 4-var Euler - self.has_roe = bool(roe) # ROE hook emitted (enable_roe roles OR m.roe_dissipation provided): roe available beyond 4-var Euler + module_manifest: Any = None, + characteristic_no_inflow: Any = False, + hllc_provider: Any = None, roe_provider: Any = None, + roe_entropy_policy: Any = None, roe_entropy_delta: Any = None) -> None: + from pops.numerics.riemann.providers import RiemannProviderEvidence + + riemann_evidence = RiemannProviderEvidence( + hllc_provider, + roe_provider, + roe_entropy_policy, + roe_entropy_delta, + ) + if bool(hllc) != (riemann_evidence.hllc_provider is not None): + raise ValueError( + "CompiledModel hllc flag disagrees with exact HLLC provider evidence" + ) + if bool(roe) != (riemann_evidence.roe_provider is not None): + raise ValueError( + "CompiledModel roe flag disagrees with exact Roe provider evidence" + ) + self.has_hllc = bool(hllc) + self.hllc_provider = riemann_evidence.hllc_provider + self.has_roe = bool(roe) + self.roe_provider = riemann_evidence.roe_provider + self.roe_entropy_policy = riemann_evidence.roe_entropy_policy + self.roe_entropy_delta = riemann_evidence.roe_entropy_delta self.has_wave_speeds = bool(wave_speeds) # wave_speeds emitted (explicit pair OR 'p'): hll available + self.has_characteristic_no_inflow = bool(characteristic_no_inflow) + if self.has_characteristic_no_inflow and self.roe_provider != "flux_jacobian_v1": + raise ValueError( + "characteristic no-inflow requires the compiled flux-Jacobian Roe provider" + ) allowed_wave_speed_providers = {"explicit_pair", "jacobian", "pressure_derived"} if self.has_wave_speeds: if wave_speed_provider not in allowed_wave_speed_providers: @@ -271,7 +299,9 @@ def estimate_memory(self, mesh: Any, *, platform: Any = None, layout: Any = None def __repr__(self) -> str: return ("CompiledModel(backend=%r, target=%r, so_path=%r, n_vars=%d, gamma=%r, n_aux=%d, " - "wave_speed_provider=%r, runtime_params=%r, abi_key=%.12s..., model_hash=%.12s...)" + "wave_speed_provider=%r, hllc_provider=%r, roe_provider=%r, " + "roe_entropy_policy=%r, runtime_params=%r, abi_key=%.12s..., model_hash=%.12s...)" % (self.backend, self.target, self.so_path, self.n_vars, self.gamma, self.n_aux, - self.wave_speed_provider, self.runtime_param_names, + self.wave_speed_provider, self.hllc_provider, self.roe_provider, + self.roe_entropy_policy, self.runtime_param_names, self.abi_key or "", self.model_hash or "")) diff --git a/python/pops/codegen/_native_spatial_layout.py b/python/pops/codegen/_native_spatial_layout.py new file mode 100644 index 000000000..3845fd4c4 --- /dev/null +++ b/python/pops/codegen/_native_spatial_layout.py @@ -0,0 +1,128 @@ +"""Resolve-time native spatial authority derived only from immutable ``LayoutPlan`` rows.""" +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import Any + + +NATIVE_SUPPORTED_DIMENSIONS = (2,) +NATIVE_SUPPORTED_CENTERINGS = ("cell",) + + +class NativeSpatialLayoutError(ValueError): + """Structured refusal before compilation or native storage allocation.""" + + def __init__( + self, + code: str, + message: str, + *, + layout_id: str | None = None, + evidence: Any = None, + ) -> None: + super().__init__(message) + self.code = code + self.layout_id = layout_id + self.evidence = evidence + + def to_data(self) -> dict[str, Any]: + return { + "code": self.code, + "layout_id": self.layout_id, + "message": str(self), + "evidence": self.evidence, + } + + +def _supported_dimensions(value: Any) -> tuple[int, ...]: + if not isinstance(value, tuple) or not value \ + or any(type(item) is not int or item not in (1, 2, 3) for item in value) \ + or len(value) != len(set(value)): + raise TypeError("supported_dimensions must be a unique non-empty tuple from {1,2,3}") + return value + + +def native_spatial_layouts( + layout_plan: Any, + *, + supported_dimensions: tuple[int, ...] = NATIVE_SUPPORTED_DIMENSIONS, + supported_centerings: tuple[str, ...] = NATIVE_SUPPORTED_CENTERINGS, +) -> Mapping[str, Any]: + """Return exact per-layout specializations, refusing unsupported routes fail-closed.""" + from pops.mesh import LayoutPlan, NativeSpatialLayout + + if type(layout_plan) is not LayoutPlan: + raise TypeError("native spatial resolution requires an exact LayoutPlan") + dimensions = _supported_dimensions(supported_dimensions) + if not isinstance(supported_centerings, tuple) or not supported_centerings \ + or any(not isinstance(item, str) or not item for item in supported_centerings) \ + or len(supported_centerings) != len(set(supported_centerings)): + raise TypeError("supported_centerings must be a unique non-empty tuple of names") + rows: dict[str, NativeSpatialLayout] = {} + selected_dimensions: set[int] = set() + for normalized in layout_plan.layouts: + native = normalized.native_spatial_layout + if native is None: + raise NativeSpatialLayoutError( + "native_spatial_layout_unavailable", + "layout %s has no authenticated native_spatial_data() projection" + % normalized.handle.qualified_id, + layout_id=normalized.handle.qualified_id, + evidence={"supported_dimensions": list(dimensions)}, + ) + if type(native) is not NativeSpatialLayout: + raise TypeError("LayoutPlan contains a non-exact NativeSpatialLayout") + if native.dimension not in dimensions: + raise NativeSpatialLayoutError( + "native_dimension_unavailable", + "native production supports dimensions %s, not layout %s dimension %d" + % (dimensions, native.layout_id, native.dimension), + layout_id=native.layout_id, + evidence={ + "resolved_dimension": native.dimension, + "supported_dimensions": list(dimensions), + }, + ) + if native.centering not in supported_centerings: + raise NativeSpatialLayoutError( + "native_centering_unavailable", + "native production does not support layout %s centering %r" + % (native.layout_id, native.centering), + layout_id=native.layout_id, + evidence={ + "centering": native.centering, + "supported_centerings": list(supported_centerings), + }, + ) + rows[native.layout_id] = NativeSpatialLayout.from_data(native.to_data()) + selected_dimensions.add(native.dimension) + if len(selected_dimensions) != 1: + raise NativeSpatialLayoutError( + "mixed_native_dimensions", + "one RuntimeInstance cannot combine layouts with different dimensions", + evidence={"resolved_dimensions": sorted(selected_dimensions)}, + ) + return MappingProxyType(rows) + + +def resolved_dimension(layouts: Mapping[str, Any]) -> int: + """Return the one exact rank carried by an authenticated native-layout mapping.""" + from pops.mesh import NativeSpatialLayout + + if not isinstance(layouts, Mapping) or not layouts: + raise TypeError("resolved_dimension requires a non-empty native-layout mapping") + rows = tuple(layouts.values()) + if any(type(row) is not NativeSpatialLayout for row in rows): + raise TypeError( + "resolved_dimension requires exact NativeSpatialLayout mapping values") + dimensions = {row.dimension for row in rows} + if len(dimensions) != 1: + raise ValueError("native-layout mapping does not carry one exact resolved dimension") + return next(iter(dimensions)) + + +__all__ = [ + "NATIVE_SUPPORTED_CENTERINGS", "NATIVE_SUPPORTED_DIMENSIONS", + "NativeSpatialLayoutError", "native_spatial_layouts", "resolved_dimension", +] diff --git a/python/pops/codegen/_phases.py b/python/pops/codegen/_phases.py index e5a9785e7..68b8afda2 100644 --- a/python/pops/codegen/_phases.py +++ b/python/pops/codegen/_phases.py @@ -96,6 +96,9 @@ def resolve( "qualified mapping lowering" % present), ) resolved_layouts = layout_authority.require_runtime() + from pops.codegen._layout_resolution import resolve_native_spatial_layouts + + native_layouts = resolve_native_spatial_layouts(layout_plan) validate_layout_mapping_components(layout_plan, components) if len(layout_plan.layouts) > 1 and tuple(problem.layout_subjects().fields): _refuse_runtime( @@ -243,9 +246,11 @@ def resolve_amr_handle(value: Any) -> Any: ) validate_prepared_boundary_jacvec(blocks, resolved_time) - has_shared_interfaces = validate_shared_interface_program( - blocks, layout_plan, resolved_time, target=target, - resolved_hierarchy=resolved_hierarchy, + has_shared_interfaces, has_shared_interface_implicit_jacvec = ( + validate_shared_interface_program( + blocks, layout_plan, resolved_time, target=target, + resolved_hierarchy=resolved_hierarchy, + ) ) field_plans = capture_field_plans( problem, detached_frozen, target=target, layout=detached_layout) @@ -293,9 +298,25 @@ def resolve_amr_handle(value: Any) -> Any: ) from pops.output._restart_provider import RestartAuthority restart_authority = RestartAuthority.from_consumer_graph(consumer_graph) + lowering_coverage = layout_lowering_coverage(layout_plan) + if bootstrap_plan is not None: + from pops.codegen._amr_lowering_coverage import amr_lowering_coverage + from pops.codegen.lowering_coverage import LoweringCoverageReport + + amr_coverage = amr_lowering_coverage( + resolved_hierarchy=resolved_hierarchy, + transfer=amr_transfer, + bootstrap=bootstrap_plan, + execution=amr_execution, + ) + lowering_coverage = LoweringCoverageReport(( + *lowering_coverage.rows, + *amr_coverage.rows, + )) return ResolvedSimulationPlan( snapshot=snapshot, target=target, backend=backend_token, layout=detached_layout, layout_plan=layout_plan, + native_layouts=native_layouts, layout_targets={ row.handle.qualified_id: ("amr_system" if row.adaptive else "system") for row in layout_plan.layouts @@ -309,8 +330,11 @@ def resolve_amr_handle(value: Any) -> Any: "amr_resources": amr_requirements}, capabilities={"resolution": evidence, "layout_plan": layout_plan.capability_evidence(), - "amr_bootstrap": amr_capabilities}, - lowering_coverage=layout_lowering_coverage(layout_plan), compile_options=options, + "amr_bootstrap": amr_capabilities, + "shared_interfaces": { + "implicit_jacvec_pair": has_shared_interface_implicit_jacvec, + }}, + lowering_coverage=lowering_coverage, compile_options=options, component_inputs=tuple(components), resolved_hierarchy=resolved_hierarchy, amr_transfer=amr_transfer, initial_condition_plan=initial_condition_plan, bootstrap_plan=bootstrap_plan, @@ -324,24 +348,15 @@ def compile(plan: Any) -> Any: if type(plan) is not ResolvedSimulationPlan: raise TypeError("pops.compile requires the ResolvedSimulationPlan returned by pops.resolve") plan.verify() - from pops.codegen._orchestration_compile import ( - build_program_model_graph, - compile_install_models, - ) + from pops.codegen._orchestration_compile import compile_install_models models = compile_install_models(plan, plan.compile_options) - from pops.codegen._compile_drivers import compile_problem + from pops.codegen._compile_drivers import _compile_resolved_problem, compile_problem from pops.codegen._compiled_artifact import CompiledLayoutProgram - from pops.codegen.program_models import ProgramModelGraph program = None - options = dict(plan.compile_options) - options["libraries"] = plan.libraries if len(plan.layout_plan.layouts) == 1: - model_graph = build_program_model_graph(plan) - program = compile_problem( - time=plan.time, model_graph=model_graph, backend=plan.backend, target=plan.target, - problem_snapshot=plan.snapshot, field_plans=plan.field_plans, **options) + program = _compile_resolved_problem(plan) program._discard_authoring() row = plan.layout_plan.layouts[0] layout_programs = (CompiledLayoutProgram( @@ -350,6 +365,14 @@ def compile(plan: Any) -> Any: else: from pathlib import Path from pops.codegen.program_slicing import slice_program + from pops.codegen.program_models import ProgramModelGraph + from pops.codegen.program_balance_due import validate_balance_due_contract + from pops._balance_due_contract import BalanceDueContract + + options = dict(plan.compile_options) + options["libraries"] = plan.libraries + balance_due_contract = BalanceDueContract.from_consumer_graph(plan.consumer_graph) + validate_balance_due_contract(plan.time, balance_due_contract) block_layouts = { assignment.subject.local_id: assignment.layout.qualified_id @@ -376,6 +399,7 @@ def compile(plan: Any) -> Any: target=plan.layout_targets[layout_id], problem_snapshot=plan.snapshot, field_plans={}, + balance_due_contract=balance_due_contract, **slice_options, ) compiled_program._discard_authoring() diff --git a/python/pops/codegen/_plans.py b/python/pops/codegen/_plans.py index 104d8f9bc..45ac385cd 100644 --- a/python/pops/codegen/_plans.py +++ b/python/pops/codegen/_plans.py @@ -221,6 +221,7 @@ class ResolvedSimulationPlan: requirements: Mapping[str, Any] capabilities: Mapping[str, Any] lowering_coverage: Any + native_layouts: Mapping[str, Any] = field(default_factory=dict) consumer_graph: Any = None restart_authority: Any = field(default_factory=_builtin_restart_authority) component_inputs: tuple[Any, ...] = () @@ -231,6 +232,7 @@ class ResolvedSimulationPlan: bootstrap_plan: Any = None amr_execution: Any = None amr_providers: Mapping[str, Any] = field(default_factory=dict) + resolved_dimension: int = field(init=False) plan_identity: Identity = field(init=False) def __post_init__(self) -> None: @@ -247,6 +249,24 @@ def __post_init__(self) -> None: raise TypeError("ResolvedSimulationPlan backend must be a resolved non-empty string") if type(self.layout_plan) is not LayoutPlan: raise TypeError("ResolvedSimulationPlan.layout_plan must be an exact LayoutPlan") + from pops.codegen._native_spatial_layout import ( + native_spatial_layouts, + resolved_dimension, + ) + + expected_native_layouts = native_spatial_layouts(self.layout_plan) + supplied_native_layouts = self.native_layouts or expected_native_layouts + if not isinstance(supplied_native_layouts, Mapping) \ + or tuple(supplied_native_layouts) != tuple(expected_native_layouts): + raise ValueError( + "ResolvedSimulationPlan.native_layouts must match normalized layout order exactly") + for layout_id, expected in expected_native_layouts.items(): + actual = supplied_native_layouts[layout_id] + if type(actual) is not type(expected) or actual.to_data() != expected.to_data(): + raise ValueError( + "ResolvedSimulationPlan.native_layouts differs from LayoutPlan normalization") + object.__setattr__(self, "native_layouts", _deep_freeze(supplied_native_layouts)) + object.__setattr__(self, "resolved_dimension", resolved_dimension(self.native_layouts)) from pops.time import Program if type(self.time) is not Program: raise TypeError( @@ -378,6 +398,8 @@ def _payload(self) -> dict[str, Any]: "compile_values": _evidence(self.compile_values, where="plan.compile_values"), "layout": _evidence(self.layout, where="plan.layout"), "layout_plan": _evidence(self.layout_plan, where="plan.layout_plan"), + "native_layouts": _evidence(self.native_layouts, where="plan.native_layouts"), + "resolved_dimension": self.resolved_dimension, "layout_targets": dict(self.layout_targets), "time": _evidence(self.time, where="plan.time"), "blocks": [{ diff --git a/python/pops/codegen/_shared_interface_evidence.py b/python/pops/codegen/_shared_interface_evidence.py new file mode 100644 index 000000000..219372f25 --- /dev/null +++ b/python/pops/codegen/_shared_interface_evidence.py @@ -0,0 +1,150 @@ +"""Private resolve-issued evidence for shared-interface Program lowering.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from pops.identity import Identity, make_identity + + +_EVIDENCE_ISSUER = object() + + +@dataclass(frozen=True, slots=True, init=False) +class _ResolvedSharedInterfaceCodegenEvidence: + """Nominal proof bound to one exact resolved plan and Program graph. + + Construction is intentionally unavailable. ``pops.resolve`` records the canonical capability + on its immutable plan; the private compiler route issues this value only after re-verifying that + plan. Public low-level emitters never accept this type or a boolean substitute. + """ + + plan_identity: Identity + program_graph_hash: str + target: str + layout_plan_id: str + hierarchy_identity: Identity + interfaces: tuple[tuple[str, Identity], ...] + identity: Identity + + def __new__(cls): + raise TypeError( + "shared-interface codegen evidence is issued only from an exact resolved plan" + ) + + @classmethod + def _issue(cls, issuer: object) -> _ResolvedSharedInterfaceCodegenEvidence: + if issuer is not _EVIDENCE_ISSUER: + raise TypeError("shared-interface codegen evidence issuer is invalid") + return object.__new__(cls) + + def _payload(self) -> dict[str, Any]: + return { + "schema_version": 1, + "plan_identity": self.plan_identity.to_data(), + "program_graph_hash": self.program_graph_hash, + "target": self.target, + "layout_plan_id": self.layout_plan_id, + "hierarchy_identity": self.hierarchy_identity.to_data(), + "interfaces": [ + {"qualified_id": name, "identity": identity.to_data()} + for name, identity in self.interfaces + ], + } + + def require(self, program: Any, *, target: str) -> None: + """Authenticate this proof against the exact detached Program being lowered.""" + from pops.time import Program + + if type(program) is not Program: + raise TypeError("shared-interface codegen evidence requires an exact Program") + if target != self.target or target != "amr_system": + raise ValueError("shared-interface codegen evidence changed its resolved target") + if program.to_graph().graph_hash != self.program_graph_hash: + raise ValueError("shared-interface codegen evidence belongs to another Program graph") + if self.identity != make_identity( + "resolved-shared-interface-codegen", self._payload() + ): + raise ValueError("shared-interface codegen evidence identity verification failed") + + +def _issue_shared_interface_codegen_evidence( + plan: Any, +) -> _ResolvedSharedInterfaceCodegenEvidence | None: + """Issue the nominal compiler proof from one exact, verified resolve result.""" + from pops.codegen._plans import ResolvedSimulationPlan + + if type(plan) is not ResolvedSimulationPlan: + raise TypeError("shared-interface codegen evidence requires a resolved simulation plan") + plan.verify() + capabilities = plan.capabilities.get("shared_interfaces") + if not isinstance(capabilities, Mapping) or set(capabilities) != { + "implicit_jacvec_pair" + }: + raise TypeError("resolved shared-interface codegen evidence is not canonical") + required = capabilities["implicit_jacvec_pair"] + if type(required) is not bool: + raise TypeError("resolved shared-interface implicit-JVP evidence must be an exact bool") + if not required: + return None + if plan.target != "amr_system" or len(plan.layout_plan.layouts) != 1: + raise ValueError( + "shared-interface implicit-JVP evidence requires one AMR runtime layout" + ) + hierarchy = plan.resolved_hierarchy + hierarchy_identity = getattr(hierarchy, "identity", None) + if type(hierarchy_identity) is not Identity: + raise TypeError("shared-interface codegen evidence lost its resolved hierarchy") + + declarations: dict[str, Identity] = {} + for block in plan.blocks: + numerics = block.numerics + for boundary in (() if numerics is None else numerics.boundaries): + for interface in getattr(boundary, "interfaces", ()): + name = getattr(interface, "qualified_id", None) + canonical = getattr(interface, "canonical_identity", None) + if not isinstance(name, str) or not name or not callable(canonical): + raise TypeError( + "shared-interface codegen evidence found an invalid declaration" + ) + identity = make_identity("shared-interface-declaration", canonical()) + previous = declarations.setdefault(name, identity) + if previous != identity: + raise ValueError( + "shared-interface codegen evidence found competing declarations" + ) + if len(declarations) != 1: + raise ValueError( + "shared-interface implicit-JVP evidence requires one exact interface declaration" + ) + + program_graph_hash = plan.time.to_graph().graph_hash + if not isinstance(program_graph_hash, str) or not program_graph_hash: + raise TypeError("shared-interface codegen evidence lost the Program graph identity") + evidence = _ResolvedSharedInterfaceCodegenEvidence._issue(_EVIDENCE_ISSUER) + object.__setattr__(evidence, "plan_identity", Identity.from_data(plan.plan_identity.to_data())) + object.__setattr__(evidence, "program_graph_hash", program_graph_hash) + object.__setattr__(evidence, "target", plan.target) + object.__setattr__(evidence, "layout_plan_id", plan.layout_plan.qualified_id) + object.__setattr__( + evidence, "hierarchy_identity", Identity.from_data(hierarchy_identity.to_data()) + ) + object.__setattr__( + evidence, + "interfaces", + tuple( + (name, Identity.from_data(identity.to_data())) + for name, identity in sorted(declarations.items()) + ), + ) + object.__setattr__( + evidence, + "identity", + make_identity("resolved-shared-interface-codegen", evidence._payload()), + ) + evidence.require(plan.time, target=plan.target) + return evidence + + +__all__: list[str] = [] diff --git a/python/pops/codegen/cache.py b/python/pops/codegen/cache.py index 150f8b31c..a6033ce9b 100644 --- a/python/pops/codegen/cache.py +++ b/python/pops/codegen/cache.py @@ -255,7 +255,7 @@ def _registry_cache_key() -> str: capabilities/reports vocabulary participate in the artifact identity: an artifact built against a different route set (a route added/removed/re-tokenized, a native entry renamed) or an older report vocabulary must be a cache MISS, never a silent reuse. The component is - readable ("routes=v2:;capvocab=1") so the mismatching field is nameable in + readable (for example, "routes=v3:;capvocab=4") so the mismatching field is nameable in diagnostics and in compiled.inspect().""" from pops.runtime.routes import (CAPABILITY_VOCAB_VERSION, ROUTE_REGISTRY_VERSION, route_registry_hash) diff --git a/python/pops/codegen/component_provider_packs.py b/python/pops/codegen/component_provider_packs.py new file mode 100644 index 000000000..0b9664895 --- /dev/null +++ b/python/pops/codegen/component_provider_packs.py @@ -0,0 +1,104 @@ +"""Exact component-provider packs shared by every compiler entry route. + +The operator-first :class:`pops.model.Module` is the authority for provider identity. Kernel +emitters must not rediscover providers from the legacy auxiliary layout: this module resolves the +full pack, every per-operator subset, and the physical-flux subset once and passes that immutable +value through the explicit compiler-emitter protocol. +""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any + +from pops.model.provider_pack import ( + ProviderPack, + build_operator_provider_pack, + build_provider_pack, +) + + +@dataclass(frozen=True, slots=True) +class ComponentProviderPacks: + """One immutable provider resolution for a canonical Module.""" + + complete: ProviderPack + by_operator: Mapping[str, ProviderPack] + physical_flux: ProviderPack + + def __post_init__(self) -> None: + if type(self.complete) is not ProviderPack: + raise TypeError("ComponentProviderPacks.complete must be an exact ProviderPack") + rows = dict(self.by_operator) + if any(not isinstance(name, str) or not name for name in rows): + raise TypeError( + "ComponentProviderPacks.by_operator keys must be non-empty strings" + ) + if any(type(pack) is not ProviderPack for pack in rows.values()): + raise TypeError( + "ComponentProviderPacks.by_operator values must be exact ProviderPack values" + ) + object.__setattr__(self, "by_operator", MappingProxyType(rows)) + if type(self.physical_flux) is not ProviderPack: + raise TypeError( + "ComponentProviderPacks.physical_flux must be an exact ProviderPack" + ) + + def attach(self, target: Any) -> None: + """Attach compiler-owned immutable evidence to one emitter carrier. + + Reattachment is idempotent and verifies byte-for-byte logical equality. This is needed + because a facade and its private formula carrier are distinct Python objects but emit one + native package; neither may retain a different provider resolution. + """ + values = { + "_component_provider_pack": self.complete, + "_component_provider_metadata": self.complete.to_data(), + "_component_operator_provider_packs": self.by_operator, + "_component_operator_provider_metadata": MappingProxyType({ + name: pack.to_data() for name, pack in self.by_operator.items() + }), + "_component_flux_provider_pack": self.physical_flux, + "_component_flux_provider_metadata": self.physical_flux.to_data(), + } + + def canonical(value: Any) -> Any: + if isinstance(value, ProviderPack): + return value.to_data() + if isinstance(value, Mapping): + return { + key: canonical(item) + for key, item in value.items() + } + return value + + for name, value in values.items(): + previous = getattr(target, name, None) + if previous is not None and canonical(previous) != canonical(value): + raise ValueError( + "compiler emitter retained a conflicting component-provider pack" + ) + object.__setattr__(target, name, value) + + +def resolve_component_provider_packs(module: Any) -> ComponentProviderPacks: + """Resolve all exact provider packs from one canonical Module authority.""" + complete = build_provider_pack(module) + by_operator = { + operator.name: build_operator_provider_pack(module, operator) + for operator in module.operator_registry() + } + flux_requirements = [] + for operator in module.operator_registry(): + if operator.kind == "grid_operator": + flux_requirements.extend(by_operator[operator.name]) + physical_flux = complete.select(flux_requirements) + return ComponentProviderPacks( + complete=complete, + by_operator=by_operator, + physical_flux=physical_flux, + ) + + +__all__ = ["ComponentProviderPacks", "resolve_component_provider_packs"] diff --git a/python/pops/codegen/inspect_compiled.py b/python/pops/codegen/inspect_compiled.py index 81c8c345d..c98945632 100644 --- a/python/pops/codegen/inspect_compiled.py +++ b/python/pops/codegen/inspect_compiled.py @@ -411,7 +411,7 @@ def _build_arguments( for value in getattr(program, "_values", []): if value.op == "store_history": outputs[value.name or "history"] = {"kind": "history"} - elif value.op == "record" or value.op == "record_scalar": + elif value.op in {"record", "record_scalar", "record_balance_term"}: outputs[value.name or "diagnostic"] = {"kind": "diagnostic"} ghost_depth_by_block = _ghost_depth_by_block(compiled, tuple(instances)) diff --git a/python/pops/codegen/inspect_report.py b/python/pops/codegen/inspect_report.py index e9180dd53..06dcd99f0 100644 --- a/python/pops/codegen/inspect_report.py +++ b/python/pops/codegen/inspect_report.py @@ -148,6 +148,44 @@ def build_requirements(compiled: Any) -> Any: except ValueError: provider = None row["wave_speed_provider"] = provider + elif flag in ("has_hllc", "has_roe"): + from pops.numerics.riemann.providers import compiled_provider_evidence + + evidence = [compiled_provider_evidence(candidate) for candidate in selected] + if flag == "has_hllc": + kinds = {item.hllc_provider for item in evidence} + if None in kinds: + raise ValueError("HLLC inspection requires exact provider evidence") + row["providers"] = [ + {"kind": kind} + for kind in sorted(kinds, key=str) + ] + else: + records = { + ( + item.roe_provider, + item.roe_entropy_policy, + item.roe_entropy_delta, + ) + for item in evidence + } + if any(kind is None for kind, _, _ in records): + raise ValueError("Roe inspection requires exact provider evidence") + row["providers"] = [ + { + "kind": kind, + "entropy_policy": entropy_policy, + **( + {"entropy_delta": entropy_delta} + if entropy_delta is not None + else {} + ), + } + for kind, entropy_policy, entropy_delta in sorted( + records, + key=lambda item: tuple(str(part) for part in item), + ) + ] capabilities.append(row) constraints = { @@ -159,8 +197,16 @@ def build_requirements(compiled: Any) -> Any: } from pops.runtime_environment import compiled_runtime_facts runtime = compiled_runtime_facts(supports_mpi=layout_runtime.get("supports_mpi")) + artifact = getattr(compiled, "artifact", compiled) + selected_dimension = getattr(artifact, "resolved_dimension", None) + if isinstance(selected_dimension, bool) or not isinstance(selected_dimension, int): + raise TypeError("compiled requirements require one exact resolved_dimension") + runtime["dimension"] = selected_dimension constraints.update({ "dimension": runtime["dimension"], + "supported_dimensions": list( + artifact.platform_manifest.capabilities["supported_dimensions"].require( + "compiled.platform.supported_dimensions")), "amr_refinement_ratio": runtime["amr_refinement_ratio"], "precision": runtime["precision"], "communicator": runtime["communicator"], diff --git a/python/pops/codegen/module_emit_brick.py b/python/pops/codegen/module_emit_brick.py index 1bdc4c745..8b3c7c1eb 100644 --- a/python/pops/codegen/module_emit_brick.py +++ b/python/pops/codegen/module_emit_brick.py @@ -48,6 +48,12 @@ def emit_cpp_brick(model: Any, name: Any = None, namespace: Any = "pops_generate type inside Kokkos kernels; no host-vtable execution path is emitted.""" if not model.prim_state: raise ValueError("emit_cpp_brick : call set_primitive_state(...) first") + if len(model.prim_state) != model.n_vars: + raise ValueError( + "emit_cpp_brick : primitive and conservative states must have equal arity " + "(got %d primitive and %d conservative components)" + % (len(model.prim_state), model.n_vars) + ) if model.cons_from is None or len(model.cons_from) != model.n_vars: raise ValueError("emit_cpp_brick : set_conservative_from([...]) expected (%d expressions)" % model.n_vars) @@ -71,11 +77,12 @@ def prim_locals(live: Any = None) -> list: return _prim_block(model, live, hoist_reciprocals) def aux_locals() -> list: - return model._aux_locals_lines() # canonical (a.) + named (a.extra_field(k)), ADC-70 + return model._flux_provider_locals_lines() - # Aux parameter named 'a' only if a formula reads an auxiliary field (canonical OR - # named ; otherwise anonymous, so as not to trigger an unused-parameter warning). - aux_param = "const Aux& a" if model._reads_aux() else "const Aux&" + # Physical laws consume the exact provider-read protocol. The parameter remains generic so + # direct pointwise callers may pass Aux while the FV route passes BoundFluxProviders + # without reconstructing the process-wide POD. + aux_param = "const auto& a" if model._reads_aux() else "const auto&" def eig_reduce(cpps: Any, ind: Any) -> list: # cpps : C++ already generated (possibly CSE) for the eigenvalues. Internal names suffixed @@ -241,8 +248,10 @@ def roles_init(roles: Any) -> Any: contract["representation"], contract["centering"], contract["unit"] or "", contract["layout"], contract["value_kind"] or "", provider["producer"] or "", ] - S.append(" {%s, %d}," % - (", ".join(json.dumps(value) for value in values), provider["slot"])) + availability = "true" if provider["availability"] else "false" + S.append(" {%s, %s, %d}," % + (", ".join(json.dumps(value) for value in values), + availability, provider["slot"])) S.append(" }};") if rt_member: # member pops::RuntimeParams params{count, {defaults}} (P7-b) S.append(rt_member.rstrip("\n")) @@ -269,11 +278,11 @@ def roles_init(roles: Any) -> Any: S += [" F[%d] = %s;" % (i, fcpps[nc + i]) for i in range(nc)] S += [" }", " return F;", " }", ""] - # in 'fd' jacobian mode WITHOUT eigenvalues, max_wave_speed calls flux(U, a, dir) : the - # Aux parameter must be named even if no formula reads an aux. + # In finite-difference Jacobian mode max_wave_speed calls flux(U, a, dir), so the provider + # parameter must be named even if no formula reads a provider directly. ws_jac: Any = model._ws_jacobian jac_fd = model._ws_jacobian is not None and model._ws_jacobian["eig"] == "fd" - mws_aux_param = "const Aux& a" if (jac_fd and not model._eig) else aux_param + mws_aux_param = "const auto& a" if (jac_fd and not model._eig) else aux_param S.append(" POPS_HD pops::Real max_wave_speed(const State& U, %s, int dir) const {" % mws_aux_param) if model._eig: @@ -387,7 +396,7 @@ def roles_init(roles: Any) -> Any: # flux ; extremes per sub-block via pops::real_eig_minmax. Non-convergence and non-real or # non-finite spectra invalidate the provider; the diagnostic Gershgorin enclosure is never # consumed as an HLL speed.) - ws_aux = aux_param if model._ws_jacobian["eig"] != "fd" else "const Aux& a" + ws_aux = aux_param if model._ws_jacobian["eig"] != "fd" else "const auto& a" S.append(" POPS_HD void wave_speeds(const State& U, %s, int dir, pops::Real& smin, " "pops::Real& smax) const {" % ws_aux) ws_drv = [] if model._ws_jacobian["eig"] == "fd" else _jac_entries(model) @@ -468,6 +477,26 @@ def roles_init(roles: Any) -> Any: S += [" Up[%d] = %s;" % (i, c) for i, c in enumerate(pcpps)] S += [" return Up;", " }", ""] + recovery_constraints = getattr(model, "_recovery_admissibility", {}) + if recovery_constraints: + S.append(" POPS_HD bool recovery_admissible(const Prim& P, int* failing_component_) const {") + S += [" const pops::Real %s = P[%d];" % (name, index) + for index, name in enumerate(model.prim_state)] + for component, name in enumerate(model.prim_state): + predicate = recovery_constraints.get(name) + if predicate is None: + continue + S.append(" if (!(%s)) {" % predicate.to_cpp()) + S.append(" if (failing_component_ != nullptr) *failing_component_ = %d;" % component) + S.append(" return false;") + S.append(" }") + S += [ + " if (failing_component_ != nullptr) *failing_component_ = -1;", + " return true;", + " }", + "", + ] + S.append(" POPS_HD Prim to_primitive(const State& U) const {") S += cons_locals() + prim_locals(_live_prims(model, [], seed=model.prim_state)) S.append(" Prim P{};") diff --git a/python/pops/codegen/module_emit_riemann.py b/python/pops/codegen/module_emit_riemann.py index 5c3a7d0f3..c6fb20210 100644 --- a/python/pops/codegen/module_emit_riemann.py +++ b/python/pops/codegen/module_emit_riemann.py @@ -19,6 +19,7 @@ from pops._dense_spectral import is_exact_block_triangular from pops.codegen.cpp_writer import _cpp_roe from pops.codegen.module_emit_helpers import ( + _AUX_CANONICAL, _codegen_exprs, _live_prims, _prim_block, @@ -27,6 +28,26 @@ from pops.identity.scalar import scalar_cpp +def has_characteristic_no_inflow_provider(model: Any) -> bool: + """Whether the generated block can evaluate its characteristic Jacobian locally. + + Boundary kernels receive the conservative cell state and model value parameters, but no + auxiliary field pack. Refuse a Jacobian that transitively reads an auxiliary field instead of + emitting a hook with an undeclared dependency or silently freezing that field. + """ + jacobian = getattr(model, "_roe_jacobian", None) + requirements = getattr(model, "_aux_requirements", None) + if jacobian is None or not callable(requirements): + return False + expressions = [ + expression + for direction in ("x", "y") + for row in jacobian[direction] + for expression in row + ] + return not bool(requirements(expressions).get("aux")) + + def _certified_roe_blocks(model: Any, jacobians: Any) -> Any: """Return exact block-triangular certificates reusable by dense Roe, or ``None``. @@ -127,6 +148,11 @@ def _emit_roe_roles(model: Any, nc: Any) -> list: E line, c = sqrt(p/rho) per side then Roe average (standard generalization). The components OUTSIDE the fluid roles are passive scalars carried by the entropy wave (tangential line, phi = q/rho). The core (HasRoeDissipation) does F = 1/2(FL+FR) - d/2.""" + from pops.numerics.riemann.providers import ENTROPY_HARTEN, RoeEntropyPolicy + + policy = getattr(model, "_roe_entropy_policy", None) + if type(policy) is not RoeEntropyPolicy: + raise ValueError("enable_roe: missing exact typed entropy policy") out = [] roles_l = _roles_for(model.cons_names, model.cons_roles) if "p" not in model.prim_defs: @@ -144,8 +170,8 @@ def _emit_roe_roles(model: Any, nc: Any) -> list: passives = [c for c in range(nc) if c not in (iD, iX, iY, iE)] out.append(" // CAPABILITY ROE generee depuis les ROLES (enable_roe) : dissipation") out.append(" // |A_roe| dU du coeur generique (HasRoeDissipation), aucun layout fige.") - out.append(" POPS_HD State roe_dissipation(const State& UL, const pops::Aux&, " - "const State& UR, const pops::Aux&, int dir) const {") + out.append(" POPS_HD State roe_dissipation(const State& UL, const auto&, " + "const State& UR, const auto&, int dir) const {") out.append(" const int in_ = dir == 0 ? %d : %d;" % (iX, iY)) out.append(" const int it_ = dir == 0 ? %d : %d;" % (iY, iX)) out.append(" const pops::Real rL = UL[%d], rR = UR[%d];" % (iD, iD)) @@ -178,12 +204,20 @@ def _emit_roe_roles(model: Any, nc: Any) -> list: out.append(" const pops::Real a2 = dr - dp / c2;") out.append(" const pops::Real a3 = rho * dut;") out.append(" const pops::Real a5 = (dp + rho * c * dun) / (pops::Real(2) * c2);") - out.append(" // Politique d'entropie explicite du provider Roe.") - out.append(" const pops::HartenEntropyFix entropy_fix{pops::Real(0.1)};") + out.append(" // Politique d'entropie explicite du provider Roe (%s)." % policy.kind) + if policy.kind == ENTROPY_HARTEN: + out.append(" const pops::HartenEntropyFix entropy_fix{%s};" + % scalar_cpp(policy.delta)) out.append(" const pops::Real l1r = un - c, l5r = un + c;") - out.append(" const pops::Real al1 = entropy_fix(l1r, c);") + if policy.kind == ENTROPY_HARTEN: + out.append(" const pops::Real al1 = entropy_fix(l1r, c);") + else: + out.append(" const pops::Real al1 = l1r < 0 ? -l1r : l1r;") out.append(" const pops::Real al2 = un < 0 ? -un : un;") - out.append(" const pops::Real al5 = entropy_fix(l5r, c);") + if policy.kind == ENTROPY_HARTEN: + out.append(" const pops::Real al5 = entropy_fix(l5r, c);") + else: + out.append(" const pops::Real al5 = l5r < 0 ? -l5r : l5r;") out.append(" State d{};") out.append(" d[%d] = al1 * a1 + al2 * a2 + al5 * a5;" % iD) out.append(" d[in_] = al1 * a1 * (un - c) + al2 * a2 * un + al5 * a5 * (un + c);") @@ -212,8 +246,8 @@ def _emit_roe_provided(model: Any, nc: Any) -> list: (guard at declaration and in check()).""" out = [] has_aux = bool(model.aux_names) # Aux parameters named aL/aR only if some aux exist - aL = "const pops::Aux& aL" if has_aux else "const pops::Aux&" - aR = "const pops::Aux& aR" if has_aux else "const pops::Aux&" + aL = "const auto& aL" if has_aux else "const auto&" + aR = "const auto& aR" if has_aux else "const auto&" out.append(" // CAPABILITY ROE FOURNIE (m.roe_dissipation) : dissipation d ecrite par") out.append(" // l'utilisateur via left()/right() des deux etats ; hook HasRoeDissipation.") out.append(" POPS_HD State roe_dissipation(const State& UL, %s, const State& UR, %s, " @@ -225,7 +259,8 @@ def _emit_roe_provided(model: Any, nc: Any) -> list: out += [" const pops::Real %s%s = %s;" % (side, p, _cpp_roe(e, side)) for p, e in model.prim_defs.items()] if has_aux: - out += [" const pops::Real %s%s = %s.%s;" % (side, n, av, n) + out += [" const pops::Real %s%s = %s.template flux_provider<%d>();" + % (side, n, av, _AUX_CANONICAL[n]) for n in model.aux_names] out.append(" State d{};") out.append(" if (dir == 0) {") @@ -260,8 +295,8 @@ def _emit_roe_jacobian(model: Any, nc: Any, cse: Any) -> list: else: out.append(" // Phi_delta(A), delta=%s ; spectre complexe/non converge refuse." % scalar_cpp(entropy_fix)) - out.append(" POPS_HD State roe_dissipation(const State& UL, const pops::Aux&, " - "const State& UR, const pops::Aux&, int dir) const {") + out.append(" POPS_HD State roe_dissipation(const State& UL, const auto&, " + "const State& UR, const auto&, int dir) const {") # conservatives at the ARITHMETIC-MEAN interface state Uavg = 1/2 (UL + UR) out += [" const pops::Real %s = pops::Real(0.5) * (UL[%d] + UR[%d]);" % (c, i, i) for i, c in enumerate(model.cons_names)] @@ -338,4 +373,44 @@ def _emit_roe_jacobian(model: Any, nc: Any, cse: Any) -> list: for i in range(nc)] out.append(" }") out += [" return d;", " }", ""] + if not has_characteristic_no_inflow_provider(model): + return out + out.append(" // Prepared characteristic no-inflow: the same complete model Jacobian, oriented") + out.append(" // by the physical-face normal. Sonic modes are neutral; no model-specific fallback.") + out.append(" POPS_HD bool characteristic_no_inflow(const State& interior, ") + out.append(" const State& reference, int dir, int outward_sign, State& ghost) const {") + out += [" const pops::Real %s = interior[%d];" % (c, i) + for i, c in enumerate(model.cons_names)] + out += _prim_block(model, live) + out.append(" pops::Real A[%d][%d];" % (nc, nc)) + out.append(" if (dir == 0) {") + ctlx, ccppx = _codegen_exprs( + model, [Jx[i][j] for i in range(nc) for j in range(nc)], cse, indent=" ") + out += ctlx + for i in range(nc): + out += [" A[%d][%d] = %s;" % (i, j, ccppx[i * nc + j]) + for j in range(nc)] + out.append(" } else if (dir == 1) {") + ctly, ccppy = _codegen_exprs( + model, [Jy[i][j] for i in range(nc) for j in range(nc)], cse, indent=" ") + out += ctly + for i in range(nc): + out += [" A[%d][%d] = %s;" % (i, j, ccppy[i * nc + j]) + for j in range(nc)] + out.append(" } else {") + out.append(" return false;") + out.append(" }") + out.append(" pops::Real jump[%d], incoming[%d];" % (nc, nc)) + out += [" jump[%d] = interior[%d] - reference[%d];" % (i, i, i) + for i in range(nc)] + out.append( + " if (!pops::characteristic_incoming_apply(A, jump, incoming, outward_sign, " + "80, static_cast(1e-13), static_cast(%s), %d))" + % (im_tol_cpp, eig_max_iter_value) + ) + out.append(" return false;") + for i in range(nc): + out.append(" ghost[%d] = interior[%d] - pops::Real(2) * incoming[%d];" % (i, i, i)) + out.append(" if (!std::isfinite(ghost[%d])) return false;" % i) + out += [" return true;", " }", ""] return out diff --git a/python/pops/codegen/module_lowering.py b/python/pops/codegen/module_lowering.py index 56cce3234..d5df9934b 100644 --- a/python/pops/codegen/module_lowering.py +++ b/python/pops/codegen/module_lowering.py @@ -20,7 +20,6 @@ from __future__ import annotations -from types import MappingProxyType from collections.abc import Iterable, Mapping from typing import Any, cast @@ -30,6 +29,36 @@ LoweringRejection, ) +_NATIVE_ROLE_ALIASES = { + "axial_x": "AxialX", + "axial_y": "AxialY", + "axial_z": "AxialZ", + "density": "Density", + "momentum_x": "MomentumX", + "momentum_y": "MomentumY", + "momentum_z": "MomentumZ", + "energy": "Energy", + "pressure": "Pressure", + "velocity_x": "VelocityX", + "velocity_y": "VelocityY", + "velocity_z": "VelocityZ", + "temperature": "Temperature", + "scalar": "Scalar", +} +_NATIVE_ROLE_TOKENS = frozenset(_NATIVE_ROLE_ALIASES.values()) + + +def _lower_native_role(value: Any) -> str | None: + from pops.physics.roles import ComponentRole, native_role_token + + if isinstance(value, ComponentRole): + return native_role_token(value) + if isinstance(value, str): + if value in _NATIVE_ROLE_TOKENS: + return value + return _NATIVE_ROLE_ALIASES.get(value) + return None + def _module_to_model(module: Any, state_space: Any = None) -> Any: """Lower a :class:`pops.model.Module` to a :class:`pops.dsl.Model` @@ -75,30 +104,12 @@ def _body_for_state(body: Any) -> Any: # Preserve the canonical source-Module identity across the internal facade lowering. The # resulting CompiledModel authenticates this scalar hash; it never retains ``module`` itself. object.__setattr__(m, "_compile_source_module_hash", module.module_hash()) - from pops.model.provider_pack import ( # noqa: PLC0415 - build_operator_provider_pack, - build_provider_pack, + from pops.codegen.component_provider_packs import ( # noqa: PLC0415 + resolve_component_provider_packs, ) - provider_pack = build_provider_pack(module) - object.__setattr__(m, "_component_provider_pack", provider_pack) - object.__setattr__(m, "_component_provider_metadata", provider_pack.to_data()) - operator_provider_packs = { - operator.name: build_operator_provider_pack(module, operator) - for operator in module.operator_registry() - } - object.__setattr__(m, "_component_operator_provider_packs", - MappingProxyType(operator_provider_packs)) - object.__setattr__(m, "_component_operator_provider_metadata", MappingProxyType({ - name: pack.to_data() for name, pack in operator_provider_packs.items() - })) - flux_keys = [] - for operator in module.operator_registry(): - if operator.kind == "grid_operator": - flux_keys.extend(operator_provider_packs[operator.name]) - flux_provider_pack = provider_pack.select(flux_keys) - object.__setattr__(m, "_component_flux_provider_pack", flux_provider_pack) - object.__setattr__(m, "_component_flux_provider_metadata", flux_provider_pack.to_data()) + provider_packs = resolve_component_provider_packs(module) + m.__pops_bind_component_provider_packs__(provider_packs) # The facade is a lowering view of THIS Module, not a newly declared model. Re-anchor its empty # backing model before the first declaration so every derived operator registry retains the # Module's exact authoring authority. Without this, owner-qualified Program nodes would be @@ -112,13 +123,9 @@ def _body_for_state(body: Any) -> Any: if registry.owner_path != module.owner_path: raise ValueError("compile_problem: Module ParamRegistry owner drift") object.__setattr__(m, "_param_registry", registry) - _spec_role = {"density": "Density", "momentum_x": "MomentumX", "momentum_y": "MomentumY", - "momentum_z": "MomentumZ", "energy": "Energy", "pressure": "Pressure", - "velocity_x": "VelocityX", "velocity_y": "VelocityY", "velocity_z": "VelocityZ", - "temperature": "Temperature"} roles = None if state.roles: - roles = [_spec_role.get(state.roles.get(c)) for c in state.components] + roles = [_lower_native_role(state.roles.get(c)) for c in state.components] if all(r is None for r in roles): roles = None cvars = m.conservative_vars(*state.components, roles=roles) @@ -191,7 +198,7 @@ def _declare_aux(nm: Any, key: Any) -> None: coverage_rows.append(LoweringCoverageRow( "module:%s:eigenvalues" % module.name, "documentary")) - for key in provider_pack: + for key in provider_packs.complete: key_data = key.to_data() stable_key = "%s/%s/%s" % ( key_data["space_kind"], key_data["space_name"], key_data["component"]) @@ -441,6 +448,11 @@ def lower_and_validate(model: Any, facade: Any = None, state_space: Any = None) lowering = require_compiler_lowering(model) if diagnostic_facade is None: diagnostic_facade = lowering.facade + from pops.codegen.component_provider_packs import resolve_component_provider_packs + + lowering.bind_component_provider_packs( + resolve_component_provider_packs(lowering.source_module) + ) states = lowering.source_module.state_spaces() if len(states) > 1: emit_model = _module_to_model( diff --git a/python/pops/codegen/program_balance_due.py b/python/pops/codegen/program_balance_due.py new file mode 100644 index 000000000..2d2c2336b --- /dev/null +++ b/python/pops/codegen/program_balance_due.py @@ -0,0 +1,268 @@ +"""Compile-time fusion of accepted Balance consumers into Program scalar producers.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import json +from types import MappingProxyType +from typing import Any + +from pops._balance_contract import BALANCE_TERM_NAMES +from pops.identity import Identity +from pops._balance_due_contract import BalanceDueContract +from pops.time.values import ProgramValue + + +@dataclass(frozen=True, slots=True) +class BalanceDueLowering: + """Immutable lowering facts for one Program and exact ConsumerGraph contract.""" + + contract: Identity + route_periods: Mapping[str, tuple[int, ...]] + guarded_values: Mapping[int, tuple[str, ...]] + record_routes: Mapping[int, str] + + def __post_init__(self) -> None: + if ( + type(self.contract) is not Identity + or self.contract.domain != "balance-due-contract" + or self.contract.schema_version != 1 + ): + raise TypeError( + "BalanceDueLowering.contract must be a version-1 balance-due-contract Identity" + ) + object.__setattr__( + self, + "route_periods", + MappingProxyType(dict(self.route_periods)), + ) + object.__setattr__( + self, + "guarded_values", + MappingProxyType(dict(self.guarded_values)), + ) + object.__setattr__( + self, + "record_routes", + MappingProxyType(dict(self.record_routes)), + ) + + +def _attribute_sources(value: ProgramValue) -> tuple[ProgramValue, ...]: + sources = [] + for key in ( + "true_result", + "false_result", + "body", + "residual", + "apply_result", + ): + candidate = value.attrs.get(key) + if isinstance(candidate, ProgramValue): + sources.append(candidate) + return tuple(sources) + + +def _program_balance_records( + program: Any, +) -> tuple[ + tuple[ProgramValue, ...], + dict[int, str], + dict[str, dict[str, ProgramValue]], +]: + from pops.codegen.program_lowerability import all_ops + + operations = tuple(all_ops(program)) + ids = [value.id for value in operations] + if len(ids) != len(set(ids)): + raise ValueError("Program balance due lowering requires globally unique SSA ids") + record_routes: dict[int, str] = {} + terms: dict[str, dict[str, ProgramValue]] = {} + for value in operations: + if value.op != "record_balance_term": + continue + route = Identity.from_token(value.attrs.get("route")) + if ( + route.domain != "balance-ledger-route" + or route.schema_version != 1 + or value.attrs.get("term") not in BALANCE_TERM_NAMES + ): + raise ValueError( + "record_balance_term requires one canonical route and five-term name" + ) + term = value.attrs["term"] + by_term = terms.setdefault(route.token, {}) + if term in by_term: + raise ValueError( + "Program records balance route %s term %s more than once" + % (route.token, term) + ) + by_term[term] = value + record_routes[value.id] = route.token + return operations, record_routes, terms + + +def validate_balance_due_contract(program: Any, contract: Any) -> None: + """Fail before codegen when a Balance consumer has no matching five-term producer.""" + if type(contract) is not BalanceDueContract: + raise TypeError( + "balance due validation requires an exact BalanceDueContract" + ) + _operations, _records, terms = _program_balance_records(program) + failures = [] + for row in contract.routes: + expected = set(BALANCE_TERM_NAMES).difference(row.automatic_terms) + actual = set(terms.get(row.route.token, {})) + if actual != expected: + failures.append( + "%s missing=%s extra=%s" + % ( + row.route.token, + sorted(expected.difference(actual)), + sorted(actual.difference(expected)), + ) + ) + if failures: + raise ValueError( + "ConsumerGraph Balance routes have no Program.record_balance producer: %s" + % "; ".join(failures) + ) + + +def prepare_balance_due_lowering( + program: Any, + contract: Any, +) -> BalanceDueLowering: + """Return exclusive balance-producer guards without mutating the Program graph.""" + if type(contract) is not BalanceDueContract: + raise TypeError( + "balance due lowering requires an exact BalanceDueContract" + ) + validate_balance_due_contract(program, contract) + operations, record_routes, terms = _program_balance_records(program) + route_periods = { + route: ( + () if (row := contract.route(route)) is None + else row.accepted_step_periods() + ) + for route in terms + } + by_id = {value.id: value for value in operations} + required_routes: dict[int, set[str]] = {} + + def require(value: ProgramValue, route: str) -> None: + if value.op not in {"reduce", "scalar_op"}: + raise ValueError( + "record_balance producer %r is not an additive reduction/scalar chain" + % value.name + ) + routes = required_routes.setdefault(value.id, set()) + if route in routes: + return + routes.add(route) + if value.op == "scalar_op": + for source in value.inputs: + require(source, route) + + for record_id, route in record_routes.items(): + record = by_id[record_id] + if len(record.inputs) != 1: + raise ValueError("record_balance_term must consume one exact scalar") + require(record.inputs[0], route) + + balance_nodes = set(required_routes).union(record_routes) + consumers: dict[int, set[int]] = {value_id: set() for value_id in by_id} + for consumer in operations: + for source in (*consumer.inputs, *_attribute_sources(consumer)): + consumers.setdefault(source.id, set()).add(consumer.id) + + # A scalar chain shared with a non-balance use remains unconditional. Propagate that liveness + # backwards so an upstream reduction cannot be skipped while a downstream ordinary diagnostic + # still reads it. + always_required = { + value_id + for value_id in balance_nodes + if any(consumer not in balance_nodes for consumer in consumers.get(value_id, ())) + } + pending = list(always_required) + while pending: + value = by_id[pending.pop()] + for source in value.inputs: + if source.id in balance_nodes and source.id not in always_required: + always_required.add(source.id) + pending.append(source.id) + + guarded = { + value_id: tuple(sorted(routes)) + for value_id, routes in required_routes.items() + if value_id not in always_required + } + return BalanceDueLowering( + contract.identity, + route_periods, + guarded, + record_routes, + ) + + +def emit_balance_due_guards( + lowering: BalanceDueLowering, + var: dict[Any, Any], + lines: list[str], +) -> None: + """Emit one host-side due decision per recorded route before any balance collective.""" + if type(lowering) is not BalanceDueLowering: + raise TypeError("balance due guard emission requires BalanceDueLowering") + contract = json.dumps(lowering.contract.token) + automatic_tokens = [] + for index, (route, periods) in enumerate(sorted(lowering.route_periods.items())): + if not periods: + token = "false" + else: + calls = [ + "ctx.balance_consumer_is_due(%s, %s, %d)" + % (contract, json.dumps(route), period) + for period in periods + ] + token = "balance_due_%d" % index + lines.append("const bool %s = (%s);" % (token, " || ".join(calls))) + automatic_tokens.append(token) + var[("balance_due_route", route)] = token + if automatic_tokens: + lines.append( + "ctx.note_automatic_balance_capture_due(%s);" + % (" || ".join(automatic_tokens)) + ) + var[("balance_guarded_values",)] = lowering.guarded_values + var[("balance_record_routes",)] = lowering.record_routes + + +def balance_value_due_expression(var: Mapping[Any, Any], value_id: int) -> str | None: + routes = var.get(("balance_guarded_values",), {}).get(value_id) + if routes is None: + return None + tokens = tuple(var[("balance_due_route", route)] for route in routes) + if "true" in tokens: + return "true" + tokens = tuple(token for token in tokens if token != "false") + return "false" if not tokens else "(" + " || ".join(tokens) + ")" + + +def balance_record_due_expression(var: Mapping[Any, Any], value_id: int) -> str: + route = var.get(("balance_record_routes",), {}).get(value_id) + if not isinstance(route, str) or not route: + raise ValueError("record_balance_term lost its compile-time due route") + token = var.get(("balance_due_route", route)) + if not isinstance(token, str) or not token: + raise ValueError("record_balance_term route has no compile-time due decision") + return token + + +__all__ = [ + "BalanceDueLowering", + "balance_record_due_expression", + "balance_value_due_expression", + "emit_balance_due_guards", + "prepare_balance_due_lowering", + "validate_balance_due_contract", +] diff --git a/python/pops/codegen/program_codegen.py b/python/pops/codegen/program_codegen.py index 9851836c5..f181d5e41 100644 --- a/python/pops/codegen/program_codegen.py +++ b/python/pops/codegen/program_codegen.py @@ -117,6 +117,60 @@ def emit_cpp_program( *, model_graph: Any = None, field_plans: Any = None, + balance_due_contract: Any = None, +) -> str: + """Lower the public low-level Program route without privileged resolve evidence.""" + return _emit_cpp_program_impl( + program, + model=model, + target=target, + model_graph=model_graph, + field_plans=field_plans, + balance_due_contract=balance_due_contract, + has_shared_interface_implicit_jacvec=False, + ) + + +def _emit_resolved_cpp_program( + program: Any, + model: Any = None, + target: str = "system", + *, + model_graph: Any = None, + field_plans: Any = None, + balance_due_contract: Any = None, + shared_interface_codegen_evidence: Any, +) -> str: + """Lower the private resolve-authenticated shared-interface route.""" + from pops.codegen._shared_interface_evidence import ( + _ResolvedSharedInterfaceCodegenEvidence, + ) + + if type(shared_interface_codegen_evidence) is not _ResolvedSharedInterfaceCodegenEvidence: + raise TypeError( + "resolved shared-interface lowering requires exact nominal codegen evidence" + ) + shared_interface_codegen_evidence.require(program, target=target) + return _emit_cpp_program_impl( + program, + model=model, + target=target, + model_graph=model_graph, + field_plans=field_plans, + balance_due_contract=balance_due_contract, + has_shared_interface_implicit_jacvec=True, + ) + + +def _emit_cpp_program_impl( + program: Any, + model: Any = None, + target: str = "system", + *, + model_graph: Any = None, + field_plans: Any = None, + balance_due_contract: Any = None, + has_shared_interface_implicit_jacvec: bool, ) -> str: """Generate the C++ source of a problem.so implementing this Program (codegen). @@ -134,17 +188,18 @@ def emit_cpp_program( closure against the wrong topology provider. Lowers the Program by a topological walk of the SSA IR: each block's current state is its base - (``ctx.state(idx)``); ``solve_fields()`` runs the elliptic solve; each RHS becomes a - scratch + ``rhs_into``; each intermediate ``linear_combine`` becomes a zero scratch accumulated - with ``axpy``; the committed combine writes the block state via ``lincomb``. Forward Euler, - SSPRK2/SSPRK3 and RK4 all lower this way -- no per-scheme class. + (``ctx.state(idx)``); each field node runs its exact point/provider-qualified solve; each RHS + becomes a scratch + ``rhs_into``; each intermediate ``linear_combine`` becomes a zero scratch + accumulated with ``axpy``; the committed combine writes the block state via ``lincomb``. + Forward Euler, SSPRK2/SSPRK3 and RK4 all lower this way -- no per-scheme class. Multi-block (ADC-426): N typed ``T.state(block[U])`` declarations + N ``T.commit`` are lowered -- each op routes to its own block's runtime index (``_block_indices``, in the order the blocks are first declared via ``T.state``). The .so also exports its block NAMES in that order (``pops_program_block_count`` / ``pops_program_block_name``); ``System::install_program`` binds them to the instantiated System blocks BY NAME (Spec 3 criterion 23, ADC-457), so the - System blocks (``sim.add_equation`` / ``sim.add_block``) may be added in ANY order -- a Program + System blocks (through the private ``sim.add_equation`` install seam) may be added in ANY + order -- a Program block whose name has no instantiated System block fails loud (``Program requires block instance '', but simulation did not instantiate it``). A block declared but never committed is a READ-ONLY block (allowed; e.g. a passive field whose charge couples the others through the shared @@ -175,19 +230,23 @@ def emit_cpp_program( listed). More than one block now lowers (ADC-426): each op routes to its block's runtime index (``_block_indices``, in T.state declaration order) and control flow (while/range/if) inside a block lowers per block; a SIMULTANEOUS multi-target coupled field solve - (``solve_fields_from_blocks([Ua, Ub])``) lowers to ``ctx.solve_fields_from_blocks`` (see below). - - Each ``solve_fields(state=...)`` op lowers to ``ctx.solve_fields_from_state(idx, )`` - (ADC-409): the elliptic fields are re-solved -- and the shared aux re-filled -- from THAT stage's - state, not the block's current state. So a field-coupled multi-stage scheme (Poisson feedback - into the flux) is exact: stage k's RHS reads phi solved from stage k's own state. For the first - stage the stage state is U^n, so this is identical to the historical ``solve_fields()``; for an - uncoupled model the field solve is inert either way. This is already a COUPLED multi-block solve: + (``solve_fields_from_blocks([Ua, Ub])``) lowers to + ``ctx.solve_fields_from_blocks_at(point, field, )`` (see below). + + Each ``solve_fields(state=...)`` op lowers to the owner-qualified + ``ctx.solve_fields_from_state_at(point, field, idx, )`` route (ADC-409/ADC-759): + the exact provider is re-solved at the active hierarchy level and logical stage time from THAT + stage's state, not the block's current state. So a field-coupled multi-stage scheme (Poisson + feedback into the flux) is exact: stage k's RHS reads phi solved from stage k's own state. For + the first stage the stage state is U^n, so this is identical to the historical + ``solve_fields()``; for an uncoupled model the field solve is inert either way. This is already a + COUPLED multi-block solve: the system Poisson RHS is ``Sum_s elliptic_rhs_s(U_s)`` (``assemble_poisson_rhs``), so block ``idx`` reads its stage state while every OTHER block contributes its LIVE state into the one shared phi/aux. A per-block callable field operator therefore sees all blocks' charge. A SIMULTANEOUS multi-target override (several blocks at their stage states in ONE solve) lowers to - ``ctx.solve_fields_from_blocks()`` (Spec 3 criterion 24, ADC-457): the RHS is + ``ctx.solve_fields_from_blocks_at(point, field, )`` (Spec 3 criterion 24, + ADC-457/ADC-759): the RHS is ``Sum_s elliptic_rhs_s(U_s)`` reading EVERY listed block's stage state at once (``assemble_poisson_rhs_from_blocks``), each slotted at its block index (nullptr = the block's live state) -- the coupled multi-species field solve.""" @@ -198,10 +257,26 @@ def emit_cpp_program( authority = model_graph if model_graph is not None else model if target not in ("system", "amr_system"): raise ValueError("emit_cpp_program: target 'system' | 'amr_system' (got %r)" % (target,)) + if type(has_shared_interface_implicit_jacvec) is not bool: + raise TypeError( + "emit_cpp_program shared-interface implicit-JVP evidence must be an exact bool" + ) + from pops._balance_due_contract import BalanceDueContract + if balance_due_contract is None: + balance_due_contract = BalanceDueContract.from_consumer_graph(None) + if type(balance_due_contract) is not BalanceDueContract: + raise TypeError( + "emit_cpp_program balance_due_contract must be an exact BalanceDueContract" + ) program.validate() _check_lowerable(program, authority, field_plans or {}, target=target) prelude, body, operator_authorities = _emit_body( - program, authority, target=target, field_plans=field_plans or {} + program, + authority, + target=target, + field_plans=field_plans or {}, + balance_due_contract=balance_due_contract, + has_shared_interface_implicit_jacvec=has_shared_interface_implicit_jacvec, ) # Optional dt bound (spec s18 / ADC-417): emit the SECOND ABI pair -- pops_program_has_dt_bound() # (true iff a bound was set) and one target-qualified entry accepting the authenticated runtime @@ -234,7 +309,14 @@ def emit_cpp_program( target, prelude, body, - _emit_amr_hierarchy_bodies(program, authority, field_plans or {}) + _emit_amr_hierarchy_bodies( + program, + authority, + field_plans or {}, + has_shared_interface_implicit_jacvec=( + has_shared_interface_implicit_jacvec + ), + ) if target == "amr_system" else None, ), diff --git a/python/pops/codegen/program_emit_amr.py b/python/pops/codegen/program_emit_amr.py index 0e609a142..a7b47d027 100644 --- a/python/pops/codegen/program_emit_amr.py +++ b/python/pops/codegen/program_emit_amr.py @@ -4,11 +4,66 @@ budget. ``_emit_amr_install`` is the only public name; ``program_codegen`` re-imports it and calls it from ``emit_cpp_program`` when ``target='amr_system'``. """ + from __future__ import annotations +import json from typing import Any +def _require_bounded_cell_local_program(program: Any, target: Any, + hierarchy_bodies: Any) -> Any: + """Validate the exact Program shape consumed by the first local-time provider. + + The native provider performs one transport-only forward-Euler update itself. Accepting a + broader IR and then skipping its generated body would be a second, divergent temporal + authority, so every unsupported node is refused before source emission. + """ + contract = program.cell_local_time_contract() + if contract is None: + return None + if target != "amr_system": + raise ValueError("Program.cell_local_time requires target='amr_system'") + if not program.cadence_contract().is_default: + raise ValueError( + "Program.cell_local_time currently requires the default Program cadence") + if hierarchy_bodies is not None: + raise ValueError( + "Program.cell_local_time does not support hierarchy-scoped field solves") + if getattr(program, "_dt_bound", None) is not None: + raise ValueError("Program.cell_local_time does not support a Program dt-bound body") + if getattr(program, "_histories", None): + raise ValueError("Program.cell_local_time does not support history operators") + + values = tuple(program._values) + if len(values) != 3 or tuple(value.op for value in values) != ( + "state", "rhs", "linear_combine"): + raise ValueError( + "Program.cell_local_time currently requires exactly one transport-only " + "ForwardEuler state/rhs/commit chain") + state, rhs, result = values + if tuple(rhs.inputs) != (state,) or rhs.attrs.get("flux") is not True or \ + rhs.attrs.get("fluxes") is not None or tuple(rhs.attrs.get("sources", ())) != (): + raise ValueError( + "Program.cell_local_time currently requires one default-flux RHS without sources " + "or fields") + if tuple(result.inputs) != (state, rhs): + raise ValueError( + "Program.cell_local_time ForwardEuler result must consume its accepted state and RHS") + coefficients = tuple(result.attrs.get("coeffs", ())) + if len(coefficients) != 2 or dict(coefficients[0]) != {0: 1} or \ + dict(coefficients[1]) != {1: 1}: + raise ValueError( + "Program.cell_local_time requires the exact update U_next = U + dt * rhs(U)") + commits = tuple(program._commits.items()) + if len(commits) != 1 or commits[0][1] is not result or commits[0][0] != state.state_ref: + raise ValueError( + "Program.cell_local_time requires one exact commit to the advanced state") + if len(program._block_indices()) != 1: + raise ValueError("Program.cell_local_time currently requires exactly one Program block") + return contract + + def _emit_amr_install(program: Any, target: Any, prelude: Any, body: Any, hierarchy_bodies: Any = None) -> str: """C++ source of the AMR install entry the .so exports (epic ADC-511 / ADC-508, Spec 6). @@ -29,20 +84,46 @@ def _emit_amr_install(program: Any, target: Any, prelude: Any, body: Any, Shape: one macro-step recursively advances each child on its declared parent/child clock relation, with exact stage abscissae and mandatory temporal interpolation from parent old/new snapshots, then - synchronizes finest-first by conservative reflux followed by average-down. The - body's head-of-step ``ctx.solve_fields()`` fires EXACTLY ONCE per macro-step (a level-0 / not-yet-solved - guard inside the context), so the coarse Poisson is OncePerStep and injected to every level -- parity - with the native AMR cadence. The C/F interface is now conservative to round-off: the per-level effective - flux is captured through the Program's own linear combination and routed through the native - ``route_reflux`` at level sync (ADC-639), so mass/momentum/energy are conserved across the interface on a - genuinely multilevel run; a coarse-only / flat Program stays bit-identical.""" + synchronizes finest-first by conservative reflux followed by average-down. Authored single-state + field nodes use the exact point/provider-qualified solve at each active level. The context exposes + only an explicitly level-0-only default-field route for legacy/manual drivers, so coarse auxiliary + injection can never masquerade as a requested fine-level solve. The C/F interface is now conservative to round-off: the + per-level effective flux is captured through the Program's own linear combination and routed + through the native ``route_reflux`` at level sync (ADC-639), so mass/momentum/energy are conserved + across the interface on a genuinely multilevel run; a coarse-only / flat Program stays + bit-identical.""" + cell_local_time = _require_bounded_cell_local_program( + program, target, hierarchy_bodies) if target != "amr_system": return "" + if cell_local_time is not None: + clock_identity = json.dumps(program.clock.qualified_id) + return ( + '\n#include \n' + 'extern "C" void pops_install_program_amr(pops::AmrSystem* sys) {\n' + ' auto ctx_owner = pops::runtime::program::make_program_execution_provider(sys);\n' + ' auto& ctx = *ctx_owner;\n' + f' ctx.configure_primary_clock({clock_identity});\n' + ' ctx.prepare_same_level_cell_temporal_execution(' + f'{clock_identity}, {cell_local_time.tick_denominator}, ' + f'{cell_local_time.rung});\n' + ' ctx.install([ctx_owner](double dt) {\n' + ' ctx_owner->advance_same_level_cell_temporal(dt);\n' + ' }, ctx_owner);\n' + '}\n' + ) + def walk(values: Any) -> Any: for value in values: yield value - for key in ("cond_block", "body_block", "apply_block", "residual_block", - "true_block", "false_block"): + for key in ( + "cond_block", + "body_block", + "apply_block", + "residual_block", + "true_block", + "false_block", + ): nested = value.attrs.get(key) if isinstance(nested, (list, tuple)): yield from walk(nested) @@ -56,30 +137,32 @@ def walk(values: Any) -> Any: ' if (ctx.program_resource_topology().levels > 1)\n' ' throw std::runtime_error("local_transform on multi-level AMR requires a typed ' 'post-synchronization Program phase; refusing pre-reflux execution");\n' - ' };\n' - ' _require_local_transform_level_contract();\n') - transform_refresh_guard = ( - ' _require_local_transform_level_contract();\n') + " };\n" + " _require_local_transform_level_contract();\n" + ) + transform_refresh_guard = " _require_local_transform_level_contract();\n" if hierarchy_bodies is None: - phase_fields = ' std::function step;\n' + phase_fields = " std::function step;\n" phase_initializers = ( ' [=](double dt) {\n' ' auto& ctx = *ctx_owner;\n' ' (void)dt;\n' + body + '\n' ' }\n') installed_driver = ( - ' auto _advance_level = [&](double level_dt) {\n' - ' _refresh_level_programs();\n' - ' _level_programs->at(static_cast(ctx.level())).step(level_dt);\n' - ' };\n' - ' ctx.advance_hierarchy(dt, _advance_level);\n') + " auto _advance_level = [&](double level_dt) {\n" + " _refresh_level_programs();\n" + " _level_programs->at(static_cast(ctx.level())).step(level_dt);\n" + " };\n" + " ctx.advance_hierarchy(dt, _advance_level);\n" + ) else: gather, solve, publish = hierarchy_bodies phase_fields = ( - ' std::function step;\n' - ' std::function gather;\n' - ' std::function solve;\n' - ' std::function publish;\n') + " std::function step;\n" + " std::function gather;\n" + " std::function solve;\n" + " std::function publish;\n" + ) phase_initializers = ( ' [=](double dt) {\n' ' auto& ctx = *ctx_owner;\n' diff --git a/python/pops/codegen/program_emit_control.py b/python/pops/codegen/program_emit_control.py index ff7397cfd..041f35734 100644 --- a/python/pops/codegen/program_emit_control.py +++ b/python/pops/codegen/program_emit_control.py @@ -171,7 +171,8 @@ def _emit_contiguous_rhs_group( def _emit_body(program: Any, model: Any = None, target: Any = "system", - field_plans: Any = None) -> tuple: + field_plans: Any = None, balance_due_contract: Any = None, + has_shared_interface_implicit_jacvec: bool = False) -> tuple: """Generate the C++ of the install function in TWO phases (each list indented uniformly by the template). Assumes `_check_lowerable` has passed. @p model supplies the symbolic coefficients of the Phase-4b source / apply / solve_local_linear ops. Returns ``(prelude, body)``: @@ -243,6 +244,18 @@ def _emit_body(program: Any, model: Any = None, target: Any = "system", -1 if owner_index is None else int(owner_index), json.dumps(state_identity), json.dumps(space_identity), json.dumps(row["clock"]), json.dumps(interpolation))) + from pops.codegen.program_balance_due import ( + emit_balance_due_guards, + prepare_balance_due_lowering, + ) + if balance_due_contract is None: + from pops._balance_due_contract import BalanceDueContract + balance_due_contract = BalanceDueContract.from_consumer_graph(None) + emit_balance_due_guards( + prepare_balance_due_lowering(program, balance_due_contract), + var, + lines, + ) values = list(program._values) index = 0 # Group identities occupy compiler-reserved slots after the authored SSA namespace. They are @@ -270,7 +283,10 @@ def _emit_body(program: Any, model: Any = None, target: Any = "system", continue base = bases.get(v.block) # the block-state value of THIS op's block (None: a scalar op) _emit_op(program, v, base, committed_ids, var, model, lines, prelude, block_idx, - target=target, field_plans=field_plans) + target=target, field_plans=field_plans, + has_shared_interface_implicit_jacvec=( + has_shared_interface_implicit_jacvec + )) index += 1 # Each committed block: a scratch commit (solve_local_linear / solve_linear / a non-base # linear_combine wrote a scratch) is copied into the block state; a linear_combine commit already @@ -292,7 +308,8 @@ def _emit_body(program: Any, model: Any = None, target: Any = "system", return prelude_src, body_src, authorities def _emit_amr_hierarchy_bodies(program: Any, model: Any = None, - field_plans: Any = None) -> tuple | None: + field_plans: Any = None, *, + has_shared_interface_implicit_jacvec: bool) -> tuple | None: """Emit gather / solve-once / publish regions for one hierarchy-scoped linear solve. The transform keys only on the generic solve scope. It does not recognize a physical scheme. @@ -301,6 +318,10 @@ def _emit_amr_hierarchy_bodies(program: Any, model: Any = None, from pops.codegen.program_emit_ops import _emit_op from pops.codegen.program_lowerability import all_ops + if type(has_shared_interface_implicit_jacvec) is not bool: + raise TypeError( + "AMR hierarchy lowering requires exact shared-interface JVP evidence" + ) solves = [v for v in all_ops(program) if v.op == "solve_linear"] scoped = [v for v in solves if v.attrs.get("scope") == "hierarchy"] if not scoped: @@ -407,7 +428,10 @@ def emit_phase(phase: str) -> str: ignored_prelude = [] _emit_op(program, value, bases.get(value.block), committed_ids, var, model, emitted, ignored_prelude, block_idx, target="amr_system", - field_plans=field_plans or {}) + field_plans=field_plans or {}, + has_shared_interface_implicit_jacvec=( + has_shared_interface_implicit_jacvec + )) if phase == "gather": keep = index < split elif phase == "solve": diff --git a/python/pops/codegen/program_emit_kernels.py b/python/pops/codegen/program_emit_kernels.py index 0482de6a2..d5df0f15c 100644 --- a/python/pops/codegen/program_emit_kernels.py +++ b/python/pops/codegen/program_emit_kernels.py @@ -73,6 +73,7 @@ "fill_boundary", "project", "record_scalar", + "record_balance_term", "cell_compare", "where", "rhs_jacvec", @@ -483,6 +484,7 @@ def _emit_where_kernel(mask_var: Any, a_var: Any, b_var: Any, out_var: Any) -> l #include // Array4 / ConstArray4 (per-cell handles) #include // for_each_cell (Phase-4b per-cell kernels) #include // pops::detail::mat_inverse (local dense solve) +#include // exact failure location #include // one prepared local solver #include // prepared affine Krylov route #include diff --git a/python/pops/codegen/program_emit_model_kernels.py b/python/pops/codegen/program_emit_model_kernels.py index 0088984b7..8ccc60440 100644 --- a/python/pops/codegen/program_emit_model_kernels.py +++ b/python/pops/codegen/program_emit_model_kernels.py @@ -338,9 +338,7 @@ def _emit_solve_coupled_implicit_kernel(components: Any, by_block: Any, var: Any " %sA(i, j, 10) = static_cast(" "pops::local_nonlinear_status_priority(solved_.status));" % status, " if (!solved_.solved()) {", - " %sA(i, j, 8) = pops::detail::encode_ranked_local_nonlinear_failure(" - "pops::local_nonlinear_status_priority(solved_.status), " - "i, j, solved_.failing_component);" % status, + " %sA(i, j, 8) = static_cast(solved_.failing_component);" % status, " %sA(i, j, 9) = pops::Real(1);" % status, " } else {", " %sA(i, j, 8) = pops::Real(0);" % status, @@ -628,10 +626,7 @@ def _emit_solve_local_nonlinear_kernel( " solve_statusA(i, j, 10) = static_cast(" "pops::local_nonlinear_status_priority(solved_.status));", " if (!solved_.solved()) {", - " solve_statusA(i, j, 8) = " - "pops::detail::encode_ranked_local_nonlinear_failure(" - "pops::local_nonlinear_status_priority(solved_.status), " - "i, j, solved_.failing_component);", + " solve_statusA(i, j, 8) = static_cast(solved_.failing_component);", " solve_statusA(i, j, 9) = pops::Real(1);", " } else {", " solve_statusA(i, j, 8) = pops::Real(0);", diff --git a/python/pops/codegen/program_emit_ops.py b/python/pops/codegen/program_emit_ops.py index 35c48bc44..d9fa0208d 100644 --- a/python/pops/codegen/program_emit_ops.py +++ b/python/pops/codegen/program_emit_ops.py @@ -169,25 +169,24 @@ def _append_local_nonlinear_report( lines.append("const int %s = static_cast(%s);" % (token, expression)) else: lines.append("const pops::Real %s = %s;" % (token, expression)) - encoded = "%s_failure_location" % report + location = "%s_failure_location" % report failed_count = "%s_failed_count" % report failed_i = "%s_failed_i" % report failed_j = "%s_failed_j" % report failed_component = "%s_failed_component" % report - encoded_priority = "%s_encoded_priority" % report lines += [ - "const pops::Real %s = pops::reduce_max(%s, 8);" % (encoded, status), "const pops::Real %s = pops::reduce_sum(%s, 9);" % (failed_count, status), - "int %s = 0;" % encoded_priority, - "int %s = -1;" % failed_i, - "int %s = -1;" % failed_j, - "int %s = -1;" % failed_component, + "pops::LocalNonlinearFailureLocation %s;" % location, "if (%s > pops::Real(0))" % failed_count, - " pops::detail::decode_ranked_local_nonlinear_failure(" - "%s, %s, %s, %s, %s);" % (encoded, encoded_priority, failed_i, failed_j, failed_component), - "if (%s > pops::Real(0) && %s != %s)" % (failed_count, encoded_priority, priority), + " %s = pops::collective_first_local_nonlinear_failure(%s, %s, 10, 8);" + % (location, status, priority), + "if (%s > pops::Real(0) && (!%s.found || %s.priority != %s))" + % (failed_count, location, location, priority), " throw std::runtime_error(" '"local nonlinear collective status/location precedence mismatch");', + "const int %s = %s.i;" % (failed_i, location), + "const int %s = %s.j;" % (failed_j, location), + "const int %s = %s.component;" % (failed_component, location), ] lines.append( "pops::SolveReport %s = pops::local_nonlinear_solve_report(" @@ -220,7 +219,8 @@ def _append_local_nonlinear_report( def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, model: Any, lines: Any, prelude: Any = None, block_idx: Any = None, target: Any = "system", - field_plans: Any = None) -> None: + field_plans: Any = None, + has_shared_interface_implicit_jacvec: bool = False) -> None: """Lower a SINGLE op to C++, appending to @p lines and recording its C++ token in @p var. Shared by the top-level walk and the while sub-blocks (a while body re-runs this per op each pass), so reductions / compares / linear_combine all lower identically inside the loop. @p base is the @@ -318,19 +318,26 @@ def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, mode # Per-stage field solve: the callable Case field operator re-solves phi from THIS # stage's explicit state (the shared aux is re-filled before the stage's RHS reads it; the # first stage state == U^n == the context's current state). Multi-block: - # solve_fields_from_state(idx, U_stage) is a genuinely COUPLED solve -- the Poisson RHS is - # Sum_s elliptic_rhs_s(U_s), block idx at its stage state, every other block contributing - # its live state into the shared phi/aux. + # solve_fields_from_state_at(point, provider, idx, U_stage) is a genuinely COUPLED solve -- + # the Poisson RHS is Sum_s elliptic_rhs_s(U_s), block idx at its exact active level/stage + # state, every other block contributing its live state into the shared phi/aux. (state_in,) = v.inputs # solve_fields inputs = (state,) field_ref = v.attrs.get("field") if field_ref is None: raise ValueError("solve_fields node has no exact field identity") field, _ = resolved_field_route(field_ref, field_plans) lines += field_point_cpp(program, v, field) + boundary_point = "field_boundary_point_%d" % v.id + lines.append( + "const auto %s = ctx.boundary_evaluation_point(%d);" + % (boundary_point, v.id) + ) report = "field_report_%d" % v.id - solve_stmt = ('pops::SolveOutcome %s = ' - 'ctx.solve_fields_from_state(%s, %d, %s);' - % (report, json.dumps(field), bidx, var[state_in.id])) + solve_stmt = ( + "pops::SolveOutcome %s = " + "ctx.solve_fields_from_state_at(%s, %s, %d, %s);" + % (report, boundary_point, json.dumps(field), bidx, var[state_in.id]) + ) lines.append(solve_stmt) _append_solve_report_guard(program, v, report, lines, label="field_solve") var[v.id] = var[state_in.id] @@ -355,10 +362,22 @@ def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, mode raise ValueError("solve_fields_from_blocks node has no exact field identity") field, _ = resolved_field_route(field_ref, field_plans) lines += field_point_cpp(program, v, field) + boundary_point = "field_boundary_point_%d" % v.id + lines.append( + "const auto %s = ctx.boundary_evaluation_point(%d);" + % (boundary_point, v.id) + ) report = "field_report_%d" % v.id lines.append( - "pops::SolveOutcome %s = ctx.solve_fields_from_blocks(%d, %s, {%s});" - % (report, int(v.id), json.dumps(field), ", ".join(overrides))) + "pops::SolveOutcome %s = ctx.solve_fields_from_blocks_at(%s, %d, %s, {%s});" + % ( + report, + boundary_point, + int(v.id), + json.dumps(field), + ", ".join(overrides), + ) + ) _append_solve_report_guard(program, v, report, lines, label="field_solve") # solve_fields_from_blocks returns a FieldContext (the shared aux); its var aliases the first # listed state so a downstream rhs(state, fields) reads the refreshed shared aux like any @@ -548,6 +567,24 @@ def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, mode lines.append("ctx.record_scalar(%s, %s);" % (json.dumps(v.attrs["diagnostic"]), var[scalar_in.id])) var[v.id] = var[scalar_in.id] + elif v.op == "record_balance_term": + # Dedicated, non-bindable sink for a validated Program.record_balance term. Ordinary + # record_scalar names cannot enter the reserved native attempt mailbox. + from pops.codegen.program_balance_due import balance_record_due_expression + + (scalar_in,) = v.inputs + due = balance_record_due_expression(var, v.id) + if due != "false": + lines.append( + "if (%s) { ctx.record_balance_term(%s, %s, %s); }" + % ( + due, + json.dumps(v.attrs["route"]), + json.dumps(v.attrs["term"]), + var[scalar_in.id], + ) + ) + var[v.id] = var[scalar_in.id] elif v.op == "rhs": state_in = v.inputs[0] # rhs inputs = (state[, fields]); the state is first var[v.id] = "r%d" % v.id @@ -739,7 +776,10 @@ def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, mode # rhs_jacvec apply (ADC-431) also captures persistent jac_uk / jac_r0 scratch the lambda # dereferences; the step body refreshes them from the live iterate / rhs(U^k) here (@p lines). _emit_matrix_free_operator( - program, v, var, prelude, lines, field_plans=field_plans) + program, v, var, prelude, lines, field_plans=field_plans, target=target, + has_shared_interface_implicit_jacvec=( + has_shared_interface_implicit_jacvec + )) elif v.op in ("apply_in", "apply_out", "apply_laplacian_coeff"): # The lambda in/out placeholders and the coefficiented apply matvec only appear INSIDE a # matrix_free_operator apply sub-block (lowered by _emit_matrix_free_operator); they never @@ -770,12 +810,10 @@ def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, mode owner = _required_block_index(block_idx, v.block, "reduce value %r" % v.name) if kind == "norm2": (u,) = v.inputs - lines.append("const pops::Real %s = ctx.norm2(%d, %s);" - % (var[v.id], owner, var[u.id])) + reduction = "ctx.norm2(%d, %s)" % (owner, var[u.id]) elif kind == "norm_inf": (u,) = v.inputs - lines.append("const pops::Real %s = ctx.norm_inf(%d, %s);" - % (var[v.id], owner, var[u.id])) + reduction = "ctx.norm_inf(%d, %s)" % (owner, var[u.id]) elif kind in ("sum", "max", "min", "abs_sum"): (u,) = v.inputs comp = int(v.attrs.get("comp", 0)) @@ -785,12 +823,25 @@ def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, mode "min": "min_component", "abs_sum": "abs_sum_component", }[kind] - lines.append("const pops::Real %s = ctx.%s(%d, %s, %d);" - % (var[v.id], context_op, owner, var[u.id], comp)) + reduction = "ctx.%s(%d, %s, %d)" % ( + context_op, + owner, + var[u.id], + comp, + ) else: # dot a, b = v.inputs - lines.append("const pops::Real %s = ctx.dot(%d, %s, %s);" - % (var[v.id], owner, var[a.id], var[b.id])) + reduction = "ctx.dot(%d, %s, %s)" % ( + owner, + var[a.id], + var[b.id], + ) + from pops.codegen.program_balance_due import balance_value_due_expression + + due = balance_value_due_expression(var, v.id) + if due is not None: + reduction = "(%s) ? (%s) : pops::Real(0)" % (due, reduction) + lines.append("const pops::Real %s = %s;" % (var[v.id], reduction)) elif v.op == "cfl": # The dt_bound's runtime cfl argument -- the C++ parameter of pops_program_dt_bound. It is # NOT a statement; its token is the bound parameter name (spec s18 / ADC-417). @@ -819,8 +870,13 @@ def _emit_op(program: Any, v: Any, base: Any, committed_ids: Any, var: Any, mode else: # a literal constant toks.append(scalar_cpp(val)) cppop = {"add": "+", "sub": "-", "mul": "*", "div": "/"}[v.attrs["fn"]] - lines.append("const pops::Real %s = (%s %s %s);" - % (var[v.id], toks[0], cppop, toks[1])) + expression = "(%s %s %s)" % (toks[0], cppop, toks[1]) + from pops.codegen.program_balance_due import balance_value_due_expression + + due = balance_value_due_expression(var, v.id) + if due is not None: + expression = "(%s) ? (%s) : pops::Real(0)" % (due, expression) + lines.append("const pops::Real %s = %s;" % (var[v.id], expression)) elif v.op == "compare": # A predicate over scalars -> an inline boolean C++ expression (no statement of its own; the # while op embeds it directly in `if (!()) break;`). diff --git a/python/pops/codegen/program_emit_solve.py b/python/pops/codegen/program_emit_solve.py index 9f1bbfa7a..72e40a9eb 100644 --- a/python/pops/codegen/program_emit_solve.py +++ b/python/pops/codegen/program_emit_solve.py @@ -39,6 +39,7 @@ validated_krylov_footprint, validated_prepared_problem_contract, ) +from pops.codegen._rhs_coherence import plan_rhs_coherence def _program_nodes(program: Any) -> Any: @@ -182,6 +183,18 @@ def _validate_matrix_free_contract(v: Any, model: Any) -> None: raise ValueError( "rhs_jacvec field coupling requires one unambiguous field context solved " "only from the frozen iterate") + if len(r0.inputs) != 2: + raise ValueError( + "field-coupled rhs_jacvec requires one complete rhs(iterate, fields) base") + fields = r0.inputs[1] + if (getattr(fields, "vtype", None) != "fields" + or getattr(fields, "field_context", None) != context): + raise ValueError( + "field-coupled rhs_jacvec base must consume its exact solved-field provider") + if fields.block != iterate.block or fields.point != iterate.point: + raise ValueError( + "field-coupled rhs_jacvec base field must share the frozen iterate's exact " + "block and temporal point") elif context is not None: raise ValueError( "rhs_jacvec field_coupled=False requires an r0 with no field-solve provenance") @@ -239,6 +252,19 @@ def _rhs_stage_fraction(value: Any) -> Fraction: "rhs_jacvec r0 carries no exact stage fraction") from exc +def _rhs_evaluation_identity(program: Any, value: Any) -> int: + """Return the exact rate or compiler-reserved atomic-group identity for one RHS.""" + grouped = sorted( + (round_.barrier_index, round_.values) + for round_ in plan_rhs_coherence(program, list(program._values)).rounds + if len(round_.values) > 1 + ) + for offset, (_barrier, values) in enumerate(grouped): + if any(candidate.id == value.id for candidate in values): + return int(program._next_id) + offset + return int(value.id) + + def _solve_stage_fraction(value: Any) -> Fraction: """Return the exact solve evaluation coordinate, preferring the implicit partition.""" point = getattr(value, "point", None) @@ -285,8 +311,62 @@ def _rhs_jacvec_field_slot(r0: Any, field_plans: Any) -> str: return slot +def _coupled_interface_jacvec_plan( + v: Any, + block: Any, + *, + target: str, + has_shared_interface_implicit_jacvec: bool, +) -> Any: + jac_ops = [value for value in block if value.op == "rhs_jacvec"] + if target != "amr_system" or len(jac_ops) != 2: + return None + if jac_ops[0].inputs[2].block == jac_ops[1].inputs[2].block: + return None + if not has_shared_interface_implicit_jacvec: + raise NotImplementedError( + "two-block rhs_jacvec lowering requires authenticated shared-interface " + "implicit-JVP evidence from resolve" + ) + unsupported = [ + value.op for value in block + if value.op not in {"apply_in", "apply_out", "rhs_jacvec"} + ] + if unsupported: + raise NotImplementedError( + "coupled shared-interface rhs_jacvec apply cannot mix operators %s" + % sorted(set(unsupported))) + first, second = jac_ops + if first.inputs[0] is not second.inputs[0] or first.inputs[1] is not second.inputs[1]: + raise ValueError("coupled shared-interface rhs_jacvec must share packed apply in/out") + if first.point != second.point: + raise ValueError("coupled shared-interface rhs_jacvec must share one exact point") + if any(bool(value.attrs.get("field_coupled")) for value in jac_ops): + raise NotImplementedError( + "coupled shared-interface rhs_jacvec does not support field-coupled boundaries") + exact_attrs = ("c_dt", "eps", "flux", "sources", "field_coupled") + changed = [name for name in exact_attrs if first.attrs.get(name) != second.attrs.get(name)] + if changed: + raise ValueError( + "coupled shared-interface rhs_jacvec changed coefficient contract %s" + % sorted(changed)) + widths = [] + for value in jac_ops: + components = getattr(getattr(value.inputs[2], "space", None), "components", None) + if not isinstance(components, tuple) or not components: + raise TypeError( + "coupled shared-interface rhs_jacvec requires complete StateSpace metadata") + widths.append(len(components)) + if int(v.attrs["ncomp"]) != sum(widths): + raise ValueError( + "coupled shared-interface packed width differs from endpoint StateSpaces") + return tuple(jac_ops), tuple(widths) + + def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, - lines: Any = None, *, field_plans: Any = None) -> None: + lines: Any = None, *, field_plans: Any = None, + target: str = "system", + has_shared_interface_implicit_jacvec: bool = False) -> None: """Lower a matrix_free_operator to an authenticated factory of C++ execution sessions. Each session owns a fresh ``ApplyFn`` and deep-copied scratch snapshot; its body re-emits the apply sub-block: @@ -298,8 +378,12 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, - ``rhs_jacvec(out, in, iterate, r0, ...)`` (ADC-431) -> a finite-difference Jacobian-vector product over the core residual plus the exact prepared-boundary JVP. The lambda captures one shared ``BoundaryEvaluationPoint`` refreshed from r0's exact stage in the step body, freezing - that point even if later operators advance the shared context stage. Boundary-only scratch is - allocated once and only when that block has an installed boundary linearization; + that point even if later operators advance the shared context stage. A field-coupled apply + instead finite-differences the complete boundary residual before restoring its perturbed + provider publication. Boundary-only scratch is allocated once and only when that block has + an installed boundary linearization. On the proved frozen two-level shared-interface AMR + route, exactly two endpoint nodes instead gather one packed direction, perturb both states, + and execute one atomic two-sided residual before scattering the packed JVP; - the apply RESULT (the affine the body returned, e.g. ``in - alpha*Lap(in)``) is written into ``out`` via the same accumulate-then-lincomb idiom as a linear_combine commit. @@ -317,6 +401,14 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, out_sf = v.attrs["apply_out"] block = v.attrs["apply_block"] result = v.attrs["apply_result"] + coupled_jacvec = _coupled_interface_jacvec_plan( + v, + block, + target=target, + has_shared_interface_implicit_jacvec=( + has_shared_interface_implicit_jacvec + ), + ) # Sub-scope token map: the lambda params + persistent scratch. `in` is the const lambda param; # `out` is the (non-const) lambda param the result is written into. sub = {in_sf.id: "in", out_sf.id: "out"} @@ -413,10 +505,46 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, # iteration, so -- like schur_coeffs -- they become PERSISTENT shared_ptr scratch (jac_uk / jac_r0) # captured by value (shared pointee), refreshed from the live iterate / r0 in the step body BEFORE # the solve. Plus a perturbed-state scratch (jac_up) and a perturbed-rhs scratch (jac_rp) the - # lambda fills per matvec. All carry the operator's component count (= the block n_cons). The - # exact BoundaryEvaluationPoint is a shared pointee because it must remain frozen at r0's stage - # while other operator nodes may advance the shared context to a later stage. + # lambda fills per matvec. A single-block route carries the operator component count; a proved + # two-block route gives each endpoint its exact StateSpace width and owns one additional packed + # iterate. The exact BoundaryEvaluationPoint is a shared pointee because it must remain frozen + # at r0's stage while other operator nodes may advance the shared context to a later stage. jac_ops = [w for w in block if w.op == "rhs_jacvec"] + coupled_pair = () if coupled_jacvec is None else coupled_jacvec[0] + coupled_widths = () if coupled_jacvec is None else coupled_jacvec[1] + coupled_width_by_id = { + value.id: width for value, width in zip(coupled_pair, coupled_widths, strict=True) + } + coupled_packed_uk = None + coupled_point = None + coupled_cdt = None + coupled_metric_scratch = None + if coupled_jacvec is not None: + coupled_packed_uk = "jac_packed_uk%d" % apply_id + prelude.append( + "auto %s = std::make_shared(ctx.alloc_scalar_field(%d, 1));" + % (coupled_packed_uk, op_ncomp)) + captures.append(coupled_packed_uk) + session_fields.append(coupled_packed_uk) + coupled_point = "jac_pair_point%d" % apply_id + prelude.append( + "auto %s = std::make_shared<" + "pops::runtime::multiblock::BoundaryEvaluationPoint>();" % coupled_point) + captures.append(coupled_point) + session_points.append(coupled_point) + coupled_cdt = "jac_pair_cdt%d" % apply_id + prelude.append( + "auto %s = std::make_shared(static_cast(0));" + % coupled_cdt) + captures.append(coupled_cdt) + session_scalars.append(coupled_cdt) + coupled_metric_scratch = "jac_pair_metric_scratch%d" % apply_id + session_dynamic.append( + (coupled_metric_scratch, + "std::make_shared>(" + "ctx_owner->program_resource_vector_distribution()." + "reduction_scratch_value_count(" + "pops::detail::PreparedFieldAlgebra::kRobustDotPayloadWidth), 0.0)")) jac_scratch = {} # jacvec op id -> (uk, r0, up, rp, r0_core, boundary_work, point, has_boundary, # field_slot, cdt, block_idx) names/provenance @@ -433,34 +561,44 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, r0 = "jac_r0%d_%d" % (apply_id, w.id) up = "jac_up%d_%d" % (apply_id, w.id) rp = "jac_rp%d_%d" % (apply_id, w.id) + jac_ncomp = coupled_width_by_id.get(w.id, op_ncomp) for sp in (uk, r0, up, rp): prelude.append( "auto %s = std::make_shared(ctx.alloc_scalar_field(%d, %s));" - % (sp, op_ncomp, ng_state)) + % (sp, jac_ncomp, ng_state)) captures.append(sp) session_fields.append(sp) - point = "jac_point%d_%d" % (apply_id, w.id) - prelude.append( - "auto %s = std::make_shared<" - "pops::runtime::multiblock::BoundaryEvaluationPoint>();" % point) - captures.append(point) - session_points.append(point) - has_boundary = "jac_has_boundary%d_%d" % (apply_id, w.id) - prelude.append( - "const bool %s = ctx.has_boundary_linearization(%d);" - % (has_boundary, block_idx)) - captures.append(has_boundary) - session_direct.append(has_boundary) + if coupled_jacvec is None: + point = "jac_point%d_%d" % (apply_id, w.id) + prelude.append( + "auto %s = std::make_shared<" + "pops::runtime::multiblock::BoundaryEvaluationPoint>();" % point) + captures.append(point) + session_points.append(point) + has_boundary = "jac_has_boundary%d_%d" % (apply_id, w.id) + prelude.append( + "const bool %s = ctx.has_boundary_linearization(%d);" + % (has_boundary, block_idx)) + captures.append(has_boundary) + session_direct.append(has_boundary) + else: + point = coupled_point + has_boundary = "false" # Krylov invokes this ApplyFn sequentially. Reuse one boundary buffer first for C(U^k) in # the step-body refresh, then for C'(U^k)v in each matvec. Both conditional allocations are # skipped entirely for the ordinary no-boundary-linearization path. - r0_core = "jac_r0_core%d_%d" % (apply_id, w.id) - boundary_work = "jac_boundary_work%d_%d" % (apply_id, w.id) - for sp in (r0_core, boundary_work): + r0_core = None + boundary_work = None if coupled_jacvec is not None else ( + "jac_boundary_work%d_%d" % (apply_id, w.id)) + optional_boundary_scratch = [] if boundary_work is None else [boundary_work] + if not w.attrs["field_coupled"] and coupled_jacvec is None: + r0_core = "jac_r0_core%d_%d" % (apply_id, w.id) + optional_boundary_scratch.insert(0, r0_core) + for sp in optional_boundary_scratch: prelude.append( "auto %s = %s ? std::make_shared(" "ctx.alloc_scalar_field(%d, %s)) : std::shared_ptr{};" - % (sp, has_boundary, op_ncomp, ng_state)) + % (sp, has_boundary, jac_ncomp, ng_state)) captures.append(sp) session_optional_fields.append(sp) field_slot = None @@ -474,17 +612,22 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, # The BDF coefficient c*dt depends on the step's dt (the step-closure parameter), which the # install-time lambda cannot see; carry it through a captured shared_ptr the step body # sets to its dt value before the solve (the same persistent-scratch idiom as jac_uk). - cdt = "jac_cdt%d_%d" % (apply_id, w.id) - prelude.append("auto %s = std::make_shared(static_cast(0));" % cdt) - captures.append(cdt) - session_scalars.append(cdt) - metric_scratch = "jac_metric_scratch%d_%d" % (apply_id, w.id) - session_dynamic.append( - (metric_scratch, - "std::make_shared>(" - "ctx_owner->program_resource_vector_distribution()." - "reduction_scratch_value_count(" - "pops::detail::PreparedFieldAlgebra::kRobustDotPayloadWidth), 0.0)")) + if coupled_jacvec is None: + cdt = "jac_cdt%d_%d" % (apply_id, w.id) + prelude.append( + "auto %s = std::make_shared(static_cast(0));" % cdt) + captures.append(cdt) + session_scalars.append(cdt) + metric_scratch = "jac_metric_scratch%d_%d" % (apply_id, w.id) + session_dynamic.append( + (metric_scratch, + "std::make_shared>(" + "ctx_owner->program_resource_vector_distribution()." + "reduction_scratch_value_count(" + "pops::detail::PreparedFieldAlgebra::kRobustDotPayloadWidth), 0.0)")) + else: + cdt = coupled_cdt + metric_scratch = coupled_metric_scratch jac_scratch[w.id] = ( uk, r0, up, rp, r0_core, boundary_work, point, has_boundary, field_slot, cdt, block_idx, metric_scratch) @@ -493,17 +636,24 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, # removed from the frozen base so the finite difference covers only the core residual; their # derivative is supplied separately by boundary_jvp_into_at in the ApplyFn. stage = _rhs_stage_fraction(r0_in) - prepare_refresh.append( - "ctx.set_stage_time(%d, %d);" % (stage.numerator, stage.denominator)) - prepare_refresh.append( - "*%s = ctx.boundary_evaluation_point(%d);" % (point, int(r0_in.id))) + if coupled_jacvec is None or w is coupled_pair[0]: + evaluation_identity = ( + _rhs_evaluation_identity(program, r0_in) + if coupled_jacvec is not None else int(r0_in.id) + ) + prepare_refresh.append( + "ctx.set_stage_time(%d, %d);" % (stage.numerator, stage.denominator)) + prepare_refresh.append( + "*%s = ctx.boundary_evaluation_point(%d);" % (point, evaluation_identity)) prepare_refresh.append( "pops::PureFieldAlgebra::copy(*%s, %s);" % (uk, var[iterate_in.id])) prepare_refresh.append( "pops::PureFieldAlgebra::copy(*%s, %s);" % (r0, var[r0_in.id])) - prepare_refresh.append("*%s = %s;" % (cdt, _coeff_cpp(w.attrs["c_dt"]))) + if coupled_jacvec is None or w is coupled_pair[0]: + prepare_refresh.append("*%s = %s;" % (cdt, _coeff_cpp(w.attrs["c_dt"]))) boundary_sessions = {} - for block_idx in sorted({entry[-2] for entry in jac_scratch.values()}): + for block_idx in (() if coupled_jacvec is not None else + sorted({entry[-2] for entry in jac_scratch.values()})): prototype_entry = next( entry for entry in jac_scratch.values() if entry[-2] == block_idx) prototype = prototype_entry[2] @@ -570,6 +720,87 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, % (sub[o.id], _apply_in_arg(sub, i), ex, ey, axy, ayx, stencil_boundary, point_arg)) elif w.op == "rhs_jacvec": + if coupled_jacvec is not None: + sub[w.id] = sub[w.inputs[0].id] + if w is coupled_pair[0]: + continue + first, second = coupled_pair + first_width, second_width = coupled_widths + first_entry = jac_scratch[first.id] + second_entry = jac_scratch[second.id] + (first_uk, first_r0, first_up, first_rp, _first_r0_core, + _first_boundary_work, point, _first_has_boundary, _first_field_slot, cdt, + first_block_idx, metric_scratch) = first_entry + (second_uk, second_r0, second_up, second_rp, _second_r0_core, + _second_boundary_work, _second_point, _second_has_boundary, _second_field_slot, + _second_cdt, second_block_idx, _second_metric_scratch) = second_entry + eps = scalar_cpp(first.attrs["eps"]) + first_default = first.attrs.get("sources") + first_default = first_default is None or "default" in first_default + second_default = second.attrs.get("sources") + second_default = second_default is None or "default" in second_default + first_flux_only = "false" if first_default else "true" + second_flux_only = "false" if second_default else "true" + body.append("{") + body.append( + " const pops::Real jvn = std::sqrt(" + "pops::detail::PreparedFieldAlgebra::dot(" + "in, in, ctx.program_resource_vector_distribution(), " + "*%s, *execution_lane));" % metric_scratch) + body.append( + " const pops::Real jukn = std::sqrt(" + "pops::detail::PreparedFieldAlgebra::dot(" + "*%s, *%s, ctx.program_resource_vector_distribution(), " + "*%s, *execution_lane));" + % (coupled_packed_uk, coupled_packed_uk, metric_scratch)) + body.append( + " const pops::Real jh = jvn > pops::Real(0) ? " + "static_cast(%s) * (pops::Real(1) + jukn) / jvn " + ": static_cast(%s);" % (eps, eps)) + body.append( + " ctx.copy_component_span(*%s, 0, in, 0, %d);" + % (first_rp, first_width)) + body.append( + " ctx.copy_component_span(*%s, 0, in, %d, %d);" + % (second_rp, first_width, second_width)) + body.append( + " pops::PureFieldAlgebra::lincomb(*%s, pops::Real(1), *%s, jh, *%s);" + % (first_up, first_uk, first_rp)) + body.append( + " pops::PureFieldAlgebra::lincomb(*%s, pops::Real(1), *%s, jh, *%s);" + % (second_up, second_uk, second_rp)) + body.append( + " ctx.rhs_jacvec_pair_into_at(*%s, %d, *%s, *%s, %s, " + "%d, *%s, *%s, %s);" + % (point, first_block_idx, first_up, first_rp, first_flux_only, + second_block_idx, second_up, second_rp, second_flux_only)) + body.append(" const pops::Real jc = *%s / jh;" % cdt) + body.append( + " ctx.copy_component_span(*%s, 0, in, 0, %d);" + % (first_up, first_width)) + body.append( + " pops::PureFieldAlgebra::lincomb(*%s, pops::Real(1), *%s, -jc, *%s);" + % (first_up, first_up, first_rp)) + body.append( + " pops::PureFieldAlgebra::axpy(*%s, jc, *%s);" + % (first_up, first_r0)) + body.append( + " ctx.copy_component_span(*%s, 0, in, %d, %d);" + % (second_up, first_width, second_width)) + body.append( + " pops::PureFieldAlgebra::lincomb(*%s, pops::Real(1), *%s, -jc, *%s);" + % (second_up, second_up, second_rp)) + body.append( + " pops::PureFieldAlgebra::axpy(*%s, jc, *%s);" + % (second_up, second_r0)) + body.append( + " ctx.copy_component_span(out, 0, *%s, 0, %d);" + % (first_up, first_width)) + body.append( + " ctx.copy_component_span(out, %d, *%s, 0, %d);" + % (first_width, second_up, second_width)) + body.append("}") + continue # out = J(U^k) in = in - (c*dt/h)(rhs(U^k + h*in) - rhs(U^k)), the finite-difference # Jacobian-vector product of the implicit-flux BDF residual (ADC-431). h is a relatively # scaled FD step (Brown-Saad / WP: h = eps*(1+||U^k||)/||in||, eps the relative step). The @@ -610,25 +841,44 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, % (point, field_slot, block_idx, up, uk)) body.append(" ctx.rhs_core_into_at(*%s, %d, *%s, *%s, %s, *%s);" % (point, block_idx, up, rp, flux_only, boundary_session)) + # Keep the perturbed provider publication active while evaluating the boundary + # contribution. This finite-differences the complete residual, including a + # boundary law that reads a solved field, instead of applying its analytic JVP + # after evaluate_with_field_state_at() has restored the frozen provider. + body.append(" if (%s) {" % has_boundary) + body.append(" pops::PureFieldAlgebra::zero_valid(*%s);" % boundary_work) + body.append( + " ctx.boundary_residual_into_at(*%s, %d, *%s, *%s, *%s);" + % (point, block_idx, up, boundary_work, boundary_session)) + body.append( + " pops::PureFieldAlgebra::axpy(*%s, pops::Real(1), *%s);" + % (rp, boundary_work)) + body.append(" }") body.append(" });") else: body.append(" ctx.rhs_core_into_at(*%s, %d, *%s, *%s, %s, *%s);" % (point, block_idx, up, rp, flux_only, boundary_session)) - # out = v - (c*dt/h)(Rcore(U^k + h*v) - Rcore(U^k)). The boundary contribution uses its - # exact JVP contract below, avoiding an invalid finite difference of ghost/action effects. + # A field-coupled apply finite-differences the complete residual while the perturbed + # provider publication is active. The ordinary state-only route keeps the split core + # difference plus its exact prepared boundary JVP, avoiding an invalid finite + # difference of ghost/action effects. body.append(" const pops::Real jc = *%s / jh;" % cdt) body.append(" pops::PureFieldAlgebra::lincomb(%s, pops::Real(1), %s, -jc, *%s);" % (out_tok, in_arg, rp)) - body.append(" if (%s) {" % has_boundary) - body.append(" pops::PureFieldAlgebra::axpy(%s, jc, *%s);" % (out_tok, r0_core)) - body.append(" pops::PureFieldAlgebra::zero_valid(*%s);" % boundary_work) - body.append(" ctx.boundary_jvp_into_at(*%s, %d, *%s, %s, *%s, *%s);" - % (point, block_idx, uk, in_arg, boundary_work, boundary_session)) - body.append(" pops::PureFieldAlgebra::axpy(%s, -*%s, *%s);" - % (out_tok, cdt, boundary_work)) - body.append(" } else {") - body.append(" pops::PureFieldAlgebra::axpy(%s, jc, *%s);" % (out_tok, r0)) - body.append(" }") + if w.attrs["field_coupled"]: + body.append(" pops::PureFieldAlgebra::axpy(%s, jc, *%s);" % (out_tok, r0)) + else: + body.append(" if (%s) {" % has_boundary) + body.append(" pops::PureFieldAlgebra::axpy(%s, jc, *%s);" + % (out_tok, r0_core)) + body.append(" pops::PureFieldAlgebra::zero_valid(*%s);" % boundary_work) + body.append(" ctx.boundary_jvp_into_at(*%s, %d, *%s, %s, *%s, *%s);" + % (point, block_idx, uk, in_arg, boundary_work, boundary_session)) + body.append(" pops::PureFieldAlgebra::axpy(%s, -*%s, *%s);" + % (out_tok, cdt, boundary_work)) + body.append(" } else {") + body.append(" pops::PureFieldAlgebra::axpy(%s, jc, *%s);" % (out_tok, r0)) + body.append(" }") body.append("}") else: raise NotImplementedError( @@ -701,20 +951,29 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, % ", ".join(prepare_captures)) prelude.append(" auto& ctx = *ctx_owner;") prelude += [" " + statement for statement in session_refresh] - for w in jac_ops: + if coupled_jacvec is not None: + offset = 0 + for value, width in zip(coupled_pair, coupled_widths, strict=True): + endpoint_uk = jac_scratch[value.id][0] + prelude.append( + " ctx.copy_component_span(*%s, %d, *%s, 0, %d);" + % (coupled_packed_uk, offset, endpoint_uk, width)) + offset += width + for w in (() if coupled_jacvec is not None else jac_ops): (uk, r0, _up, _rp, r0_core, boundary_work, point, has_boundary, _field_slot, _cdt, block_idx, _metric_scratch) = jac_scratch[w.id] boundary_session = boundary_sessions[block_idx] - prelude.append(" if (%s) {" % has_boundary) - prelude.append(" pops::PureFieldAlgebra::copy(*%s, *%s);" % (r0_core, r0)) - prelude.append(" pops::PureFieldAlgebra::zero_valid(*%s);" % boundary_work) - prelude.append( - " ctx.boundary_residual_into_at(*%s, %d, *%s, *%s, *%s);" - % (point, block_idx, uk, boundary_work, boundary_session)) - prelude.append( - " pops::PureFieldAlgebra::axpy(*%s, static_cast(-1), *%s);" - % (r0_core, boundary_work)) - prelude.append(" }") + if not w.attrs["field_coupled"]: + prelude.append(" if (%s) {" % has_boundary) + prelude.append(" pops::PureFieldAlgebra::copy(*%s, *%s);" % (r0_core, r0)) + prelude.append(" pops::PureFieldAlgebra::zero_valid(*%s);" % boundary_work) + prelude.append( + " ctx.boundary_residual_into_at(*%s, %d, *%s, *%s, *%s);" + % (point, block_idx, uk, boundary_work, boundary_session)) + prelude.append( + " pops::PureFieldAlgebra::axpy(*%s, static_cast(-1), *%s);" + % (r0_core, boundary_work)) + prelude.append(" }") prelude.append(" };") # Apply sees only its private session state. The outer template snapshots are refresh inputs # for prepare() and must not bloat every hot matvec closure. @@ -732,6 +991,7 @@ def _emit_matrix_free_operator(program: Any, v: Any, var: Any, prelude: Any, exact_parameters = "%s:%d" % (program._ir_hash(), apply_id) exclusive_context = any( w.op == "rhs_jacvec" and bool(w.attrs["field_coupled"]) for w in block) + exclusive_context = exclusive_context or coupled_jacvec is not None concurrency = ( "pops::PreparedOperatorConcurrency::Exclusive" if exclusive_context diff --git a/python/pops/codegen/program_graph_lowering.py b/python/pops/codegen/program_graph_lowering.py index fff69526c..778192edf 100644 --- a/python/pops/codegen/program_graph_lowering.py +++ b/python/pops/codegen/program_graph_lowering.py @@ -7,8 +7,44 @@ def emit_program_graph( graph: Any, *, lowering_program: Any, model: Any = None, model_graph: Any = None, target: str = "system", field_plans: Any = None, + balance_due_contract: Any = None, ) -> str: """Lower exactly ``graph`` through its frozen, graph-equivalent Program adapter.""" + return _emit_program_graph( + graph, + lowering_program=lowering_program, + model=model, + model_graph=model_graph, + target=target, + field_plans=field_plans, + balance_due_contract=balance_due_contract, + shared_interface_codegen_evidence=None, + ) + + +def _emit_resolved_program_graph( + graph: Any, *, lowering_program: Any, model: Any = None, + model_graph: Any = None, target: str = "system", field_plans: Any = None, + balance_due_contract: Any = None, shared_interface_codegen_evidence: Any, +) -> str: + """Lower one graph through the private resolve-authenticated route.""" + return _emit_program_graph( + graph, + lowering_program=lowering_program, + model=model, + model_graph=model_graph, + target=target, + field_plans=field_plans, + balance_due_contract=balance_due_contract, + shared_interface_codegen_evidence=shared_interface_codegen_evidence, + ) + + +def _emit_program_graph( + graph: Any, *, lowering_program: Any, model: Any = None, + model_graph: Any = None, target: str = "system", field_plans: Any = None, + balance_due_contract: Any = None, shared_interface_codegen_evidence: Any, +) -> str: from pops.time import ProgramGraph if type(graph) is not ProgramGraph: @@ -17,12 +53,21 @@ def emit_program_graph( raise TypeError("ProgramGraph lowering adapter must be a detached compiled Program") if lowering_program.to_graph().graph_hash != graph.graph_hash: raise ValueError("ProgramGraph lowering adapter does not match the compiler input graph") - from pops.codegen.program_codegen import emit_cpp_program + if shared_interface_codegen_evidence is None: + from pops.codegen.program_codegen import emit_cpp_program - source = emit_cpp_program( - lowering_program, model=model, model_graph=model_graph, target=target, - field_plans=field_plans, - ) + source = emit_cpp_program( + lowering_program, model=model, model_graph=model_graph, target=target, + field_plans=field_plans, balance_due_contract=balance_due_contract, + ) + else: + from pops.codegen.program_codegen import _emit_resolved_cpp_program + + source = _emit_resolved_cpp_program( + lowering_program, model=model, model_graph=model_graph, target=target, + field_plans=field_plans, balance_due_contract=balance_due_contract, + shared_interface_codegen_evidence=shared_interface_codegen_evidence, + ) if lowering_program.to_graph().graph_hash != graph.graph_hash: raise RuntimeError("ProgramGraph lowering mutated or diverged from its compiler input") return source diff --git a/python/pops/diagnostics/__init__.py b/python/pops/diagnostics/__init__.py index 76a1ac342..f8b3e6296 100644 --- a/python/pops/diagnostics/__init__.py +++ b/python/pops/diagnostics/__init__.py @@ -3,9 +3,11 @@ Historical lowercase descriptor factories are intentionally absent: diagnostics are authored with immutable typed measures and attached to the Case consumer graph. """ +from .balance import BalanceLedger from .invariants import invariants -from .measures import ConservationCheck, Integral, MinMax, Norm, StepChangeNorm +from .measures import Balance, ConservationCheck, Integral, MinMax, Norm, StepChangeNorm __all__ = [ - "ConservationCheck", "Integral", "MinMax", "Norm", "StepChangeNorm", "invariants", + "Balance", "BalanceLedger", "ConservationCheck", "Integral", "MinMax", "Norm", + "StepChangeNorm", "invariants", ] diff --git a/python/pops/diagnostics/balance.py b/python/pops/diagnostics/balance.py new file mode 100644 index 000000000..b087a8145 --- /dev/null +++ b/python/pops/diagnostics/balance.py @@ -0,0 +1,9 @@ +"""Public balance diagnostic contract. + +The implementation lives in :mod:`pops._balance_contract` so native Program/codegen modules do not +depend on this package initializer. These aliases preserve the documented public import route. +""" + +from pops._balance_contract import BALANCE_TERM_NAMES, BalanceLedger, balance_record_name + +__all__ = ["BALANCE_TERM_NAMES", "BalanceLedger", "balance_record_name"] diff --git a/python/pops/diagnostics/measures.py b/python/pops/diagnostics/measures.py index 138fbffa2..4242e8907 100644 --- a/python/pops/diagnostics/measures.py +++ b/python/pops/diagnostics/measures.py @@ -2,7 +2,7 @@ Spec 5 names a diagnostic with a TYPED object, not the string form ``diagnostics.norm(kind="l2")``. :class:`Norm` / :class:`Integral` / :class:`MinMax` / -:class:`ConservationCheck` are those objects -- inert descriptors that DESCRIBE a scalar +:class:`Balance` / :class:`ConservationCheck` are those objects -- inert descriptors that DESCRIBE a scalar reduction over a block (and an optional model role): the reduction kind, whether it needs an MPI reduction, its cadence slot and its AMR / multi-level compatibility, all carried as METADATA. They compute nothing; the C++ / Kokkos / MPI runtime evaluates the reduction. @@ -23,6 +23,8 @@ from pops.descriptors import Availability, Descriptor from pops.linalg.norms import _Norm +from .balance import BalanceLedger + def _ref_name(value: Any) -> Any: """The stable display name for a block / role reference (its ``name`` or its repr). @@ -52,14 +54,21 @@ def _role_name(value: Any) -> str | None: ) from exc -def _operation(name: str, reduction: str, *, transform: str = "identity", - metric_weighted: bool = False) -> dict[str, Any]: +def _operation( + name: str, + reduction: str, + *, + transform: str = "identity", + metric_weighted: bool = False, + coefficient: float = 1.0, +) -> dict[str, Any]: """Build one callback-free native scalar-reduction instruction.""" return { "name": name, "reduction": reduction, "transform": transform, "metric_weighted": metric_weighted, + "coefficient": coefficient.hex(), } @@ -214,7 +223,7 @@ def diagnostic_execution(self) -> dict[str, Any]: if kind is None: raise ValueError("typed norm descriptor has no canonical kind") return { - "schema_version": 1, + "schema_version": 2, "role": _role_name(self.role), "operations": [operations[kind]], "conservation": None, @@ -250,7 +259,7 @@ def options(self) -> dict: def diagnostic_execution(self) -> dict[str, Any]: return { - "schema_version": 1, + "schema_version": 2, "role": None, "operations": [ _operation("step_change_l2", "step_change_l2"), @@ -263,19 +272,52 @@ class Integral(_Measure): """A typed domain-integral reduction over a block: ``Integral(role=Density())``. Sums the (role-selected) quantity over the block volume; ``mass`` is - ``Integral(role=Density())``. Lowers to the native ``integral`` reduction. + ``Integral(role=Density())``. ``coefficient`` applies one exact finite scalar after the + collective reduction, so signed contributions such as charge remain owner-qualified without + copying or transforming fields in Python. Lowers to the native ``integral`` reduction. """ category = "diagnostic_integral" scheme = "integral" reduction = "sum" + def __init__( + self, + block: Any = None, + role: Any = None, + cadence: Any = None, + *, + coefficient: float = 1.0, + ) -> None: + super().__init__(block=block, role=role, cadence=cadence) + if isinstance(coefficient, bool) or not isinstance(coefficient, (int, float)): + raise TypeError("Integral coefficient must be a finite real number") + try: + normalized = float(coefficient) + except OverflowError as exc: + raise ValueError("Integral coefficient must be finite") from exc + if not math.isfinite(normalized): + raise ValueError("Integral coefficient must be finite") + if normalized == 0.0: + raise ValueError("Integral coefficient must be nonzero") + self.coefficient = normalized + + def options(self) -> dict: + options = super().options() + options["coefficient"] = self.coefficient.hex() + return options + def diagnostic_execution(self) -> dict[str, Any]: return { - "schema_version": 1, + "schema_version": 2, "role": _role_name(self.role), "operations": [ - _operation("integral", "sum", metric_weighted=True), + _operation( + "integral", + "sum", + metric_weighted=True, + coefficient=self.coefficient, + ), ], "conservation": None, } @@ -294,7 +336,7 @@ class MinMax(_Measure): def diagnostic_execution(self) -> dict[str, Any]: return { - "schema_version": 1, + "schema_version": 2, "role": _role_name(self.role), "operations": [ _operation("min", "min"), @@ -304,6 +346,65 @@ def diagnostic_execution(self) -> dict[str, Any]: } +class Balance(_Measure): + """Accepted five-term discrete balance produced by the native time Program. + + ``Balance`` never reconstructs terms from output arrays. The matching + :class:`BalanceLedger` must be populated with ``Program.record_balance`` during + the same native attempt. The runtime then consumes exactly storage change, + outward boundary flux, sources, reflux and projection while its accepted-state + transaction still retains the pre-step image. The residual convention is storage + change plus outward flux, minus sources, reflux and projection. + """ + + category = "diagnostic_balance" + scheme = "discrete_balance" + reduction = "accepted_balance" + + def __init__( + self, + ledger: Any, + *, + block: Any, + cadence: Any = None, + ) -> None: + if type(ledger) is not BalanceLedger: + raise TypeError( + "Balance(ledger=...) requires an exact pops.diagnostics.BalanceLedger" + ) + if block is None: + raise TypeError("Balance(block=...) requires an exact physics BlockHandle") + super().__init__(block=block, role=ledger.role, cadence=cadence) + self.ledger = ledger + + def options(self) -> dict: + options = super().options() + options["ledger"] = self.ledger.to_data() + return options + + def diagnostic_execution(self) -> dict[str, Any]: + route = self.ledger.route_identity(self.block) + return { + "schema_version": 2, + "role": _role_name(self.ledger.role), + "operations": [ + { + **_operation("balance", "accepted_balance"), + "balance_route": route.token, + **( + { + "automatic_terms": list(self.ledger.automatic_terms), + "balance_component": self.ledger.component, + } + if self.ledger.automatic_terms + else {} + ), + }, + ], + "conservation": None, + } + + class ConservationCheck(Descriptor): """A typed conservation check on a diagnostic quantity: ``ConservationCheck(Integral(...))``. @@ -373,15 +474,20 @@ def diagnostic_execution(self) -> dict[str, Any]: raise TypeError( "ConservationCheck quantity must implement diagnostic_execution()") plan = provider() - if type(plan) is not dict or plan.get("schema_version") != 1: + if type(plan) is not dict or plan.get("schema_version") != 2: raise TypeError("ConservationCheck quantity returned an invalid execution plan") operations = plan.get("operations") if not isinstance(operations, list) or len(operations) != 1: raise ValueError( "ConservationCheck requires one scalar diagnostic quantity; " "a multi-valued MinMax check is ambiguous") + if operations[0].get("reduction") == "accepted_balance": + raise ValueError( + "ConservationCheck cannot wrap an open-domain Balance; inspect its explicit " + "five-term residual instead" + ) return { - "schema_version": 1, + "schema_version": 2, "role": plan.get("role"), "operations": [dict(operations[0])], "conservation": {"tolerance": self.tolerance.hex()}, @@ -425,4 +531,11 @@ def inspect(self) -> Any: return info -__all__ = ["Norm", "Integral", "MinMax", "ConservationCheck"] +__all__ = [ + "Balance", + "Norm", + "Integral", + "MinMax", + "ConservationCheck", + "StepChangeNorm", +] diff --git a/python/pops/external/compiler.py b/python/pops/external/compiler.py index 766e1a586..8f1863a14 100644 --- a/python/pops/external/compiler.py +++ b/python/pops/external/compiler.py @@ -51,6 +51,8 @@ def compile_component( """Instantiate, compile, link and audit one source component for the proved CPU target.""" if type(component) is not ExternalComponent: raise TypeError("compile_component requires an exact ExternalComponent") + package = component.component_type.package + package.verify() from pops.codegen._compile_platform import require_shared_library_compile_platform require_shared_library_compile_platform("compile_component", windows_supported=False) @@ -67,7 +69,6 @@ def compile_component( interface = component.component_type.interface target = interface.resolve_native_target(component) - package = component.component_type.package include = include or pops_include() signature = _check_headers_match_module(include) compiler, cflags, lflags = pops_loader_build_flags(cxx) diff --git a/python/pops/external/packages.py b/python/pops/external/packages.py index fbba3ad2d..b561ece18 100644 --- a/python/pops/external/packages.py +++ b/python/pops/external/packages.py @@ -133,7 +133,65 @@ def from_manifest(cls, path: Any) -> SourceComponentPackage: "source_digest", record["path"], "payload bytes do not match the manifest") payloads.append(PackagePayload(record["path"], record["kind"], expected, content)) identity = verify_package_identity(row) - return cls(manifests, exports, tuple(payloads), identity, manifest_path) + package = cls(manifests, exports, tuple(payloads), identity, manifest_path) + package.verify() + return package + + def verify(self) -> None: + """Re-authenticate retained package bytes before a phase boundary. + + Loading detaches source payloads from their filesystem paths, but the public package value + must not become an authority merely because it has the right Python type. Registries and + the compiler call this method again immediately before consuming the value, so a forged or + in-memory-corrupted package cannot bypass the wire digest checks. + """ + if self.protocol_abi != PROTOCOL_ABI: + raise ComponentPackageError( + "protocol_abi", "protocol_abi", "unsupported component protocol ABI") + if type(self.manifests) is not tuple or any( + type(manifest) is not ComponentManifest for manifest in self.manifests): + raise ComponentPackageError( + "component_manifest", "components", + "source package manifests must be exact ComponentManifest values") + canonical_manifests = _components([manifest.to_data() for manifest in self.manifests]) + if canonical_manifests != self.manifests: + raise ComponentPackageError( + "component_manifest", "components", "component manifests are not canonical") + canonical_exports = _exports(self.exports, self.manifests) + if dict(canonical_exports) != dict(self.exports): + raise ComponentPackageError( + "exports", "exports", "source package exports are not canonical") + if type(self.payloads) is not tuple or not self.payloads: + raise ComponentPackageError( + "payloads", "payloads", "source package has no exact payload tuple") + paths: set[str] = set() + for index, payload in enumerate(self.payloads): + if type(payload) is not PackagePayload or type(payload.content) is not bytes: + raise ComponentPackageError( + "payloads", "payloads[%d]" % index, + "source package payloads must be exact immutable byte values") + if not isinstance(payload.identity, Identity): + raise ComponentPackageError( + "digest", "payloads[%d].digest" % index, + "payload identity must be a canonical PoPS identity") + validate_payload_row(payload.to_data(), index) + if payload.path in paths: + raise ComponentPackageError( + "payloads", "payloads", "payload paths must be unique") + paths.add(payload.path) + if payload.identity != content_identity(payload.kind, payload.content): + raise ComponentPackageError( + "source_digest", payload.path, + "retained payload bytes do not match the package identity") + if not isinstance(self.identity, Identity) \ + or self.identity.domain != "component-package": + raise ComponentPackageError( + "package_digest", "package_digest", + "source package identity must be a component-package identity") + if self.identity != package_identity(self.to_data()): + raise ComponentPackageError( + "package_digest", "package_digest", + "retained package content does not match package digest") def manifest(self, component_id: str) -> ComponentManifest: for manifest in self.manifests: @@ -142,6 +200,7 @@ def manifest(self, component_id: str) -> ComponentManifest: raise KeyError(component_id) def require(self, alias: str, *, interface: ComponentInterface) -> ExternalComponentType: + self.verify() if type(interface) is not ComponentInterface: raise TypeError("interface must be an exact pops.interfaces.ComponentInterface") try: @@ -330,7 +389,8 @@ def _require_fixed_signature(manifest: ComponentManifest) -> None: def _require_platform_matches(manifests: tuple[ComponentManifest, ...], platform: Any) -> None: - dimensions = tuple(platform.capabilities["dimensions"].require("platform.dimensions")) + dimensions = tuple(platform.capabilities["supported_dimensions"].require( + "platform.supported_dimensions")) scalar = platform.precision.compute.require("platform.precision.compute") device = platform.device.require("platform.device") normalized_device = "cpu" if device in ("host", "cpu") else device diff --git a/python/pops/external/registries.py b/python/pops/external/registries.py index bbb740c49..441dea9cd 100644 --- a/python/pops/external/registries.py +++ b/python/pops/external/registries.py @@ -37,6 +37,7 @@ def revision(self) -> int: def register(self, package: SourceComponentPackage) -> SourceComponentPackage: if type(package) is not SourceComponentPackage: raise TypeError("SourcePackageRegistry accepts exact SourceComponentPackage values") + package.verify() incoming = [] for component_id in package.exports.values(): manifest = package.manifest(component_id) diff --git a/python/pops/fields/providers.py b/python/pops/fields/providers.py index fe8349cab..ce0f1488c 100644 --- a/python/pops/fields/providers.py +++ b/python/pops/fields/providers.py @@ -15,14 +15,77 @@ from pops.descriptors_report import CapabilitySet, RequirementSet +_EXTERNAL_PROVIDER_ID = "pops.fields.external-field-solver" +_EXTERNAL_PROVIDER_VERSION = 2 +_EXTERNAL_PROVIDER_INTERFACE = "pops.prepared-field-solver-provider@1" +_EXTERNAL_RESOLVER_ID = "pops.fields.external-field-solver.resolve@3" +_EXTERNAL_INSTALLER_ID = "pops.fields.external-field-solver.install@3" +_EXTERNAL_USE_POLICY_ID = "pops.fields.external-field-solver.use" +_EXTERNAL_USE_POLICY_VERSION = 4 +_EXTERNAL_ADAPTER_ID = "pops.fields.external-field-solver.system-amr-host@2" +_EXTERNAL_LEVEL_LOCAL_POLICY = { + "policy_id": "pops.field-hierarchy.level-local", + "interface_version": 1, + "option_schema": "pops.field-hierarchy.options.empty@1", + "options": {}, +} +_EXTERNAL_COMPOSITE_POLICY = { + "policy_id": "pops.field-hierarchy.composite", + "interface_version": 1, + "option_schema": "pops.field-hierarchy.options.empty@1", + "options": {}, +} + + +def _external_adapter_capabilities() -> dict[str, Any]: + """Return the exact proved adapter envelope, detached for public inspection.""" + return { + "provider_id": _EXTERNAL_PROVIDER_ID, + "provider_version": _EXTERNAL_PROVIDER_VERSION, + "adapter_identity": _EXTERNAL_ADAPTER_ID, + "targets": ["system", "amr_system"], + "layout_kinds": ["uniform", "amr"], + "max_levels": None, + "refinement_ratios": [2], + "hierarchy_policies": [ + _EXTERNAL_LEVEL_LOCAL_POLICY["policy_id"], + _EXTERNAL_COMPOSITE_POLICY["policy_id"], + ], + "abi_patch_level_metadata": True, + "hierarchy_materialization": True, + "amr_provider_bridge": True, + "binary_coarse_fine_coverage": True, + "execution": "host-serial-or-declared-mpi-hierarchy-batch", + "components": ["FieldTopology@2", "FieldSolver@2"], + } + + +def _external_provider_authority() -> dict[str, Any]: + """Project the provider authority without capturing the process-local registry object.""" + return { + "schema_version": 1, + "interface": _EXTERNAL_PROVIDER_INTERFACE, + "provider_id": _EXTERNAL_PROVIDER_ID, + "version": _EXTERNAL_PROVIDER_VERSION, + "resolver_id": _EXTERNAL_RESOLVER_ID, + "installer_id": _EXTERNAL_INSTALLER_ID, + "use_policy": { + "policy_id": _EXTERNAL_USE_POLICY_ID, + "version": _EXTERNAL_USE_POLICY_VERSION, + "capabilities": _external_adapter_capabilities(), + }, + } + + def _declared_execution(component: Any) -> dict[str, bool]: variants = [ row for row in component.component_manifest.target["variants"] if row["dimension"] == 2 and row["scalar"] == "float64" ] + host = [row for row in variants if row["device"] in ("cpu", "host")] return { - "host": any(row["device"] in ("cpu", "host") for row in variants), - "mpi": any("mpi" in row["features"] for row in variants), + "host": bool(host), + "mpi": any("mpi" in row["features"] for row in host), "gpu": any(row["device"] not in ("cpu", "host") for row in variants), } @@ -65,8 +128,9 @@ class ExternalFieldSolver(Descriptor): ``relative_tolerance``, ``absolute_tolerance`` and ``max_iterations`` are request controls of the generated ``FieldSolver`` ABI. Package/component parameters remain owned independently by - each :class:`~pops.external.ExternalComponent` and are prepared exactly once by the native - loader. + each :class:`~pops.external.ExternalComponent`. The uniform adapter prepares one cached state; + the AMR adapter prepares one fresh state pair per materialized hierarchy and recreates it after + regridding. """ category = "field_solver_provider" @@ -133,13 +197,21 @@ def options(self) -> dict[str, Any]: } def to_data(self) -> dict[str, Any]: - return {"type": type(self).__name__, "options": self.options()} + return { + "type": type(self).__name__, + "provider": _external_provider_authority(), + "options": self.options(), + } def requirements(self) -> RequirementSet: return RequirementSet({ "external_components": True, "field_topology": True, - "field_topology_contract": "uniform_cartesian_full_material_v1", + "field_topology_contract": "cartesian_binary_coverage_hierarchy_v1", + "field_hierarchy_policies": ( + _EXTERNAL_LEVEL_LOCAL_POLICY["policy_id"], + _EXTERNAL_COMPOSITE_POLICY["policy_id"], + ), "host_execution": True, }) @@ -150,16 +222,22 @@ def capabilities(self) -> CapabilitySet: and solver["declared_execution"][name] for name in ("host", "mpi", "gpu") } - adapter = {"host": True, "mpi": False, "gpu": False} - # The component pair may declare broader targets, but this concrete adapter intentionally - # intersects them with the runtime facts it actually implements. It passes host views and - # does not yet publish an inter-rank topology-consensus proof, hence serial host is the sole - # truthful route in v2. + adapter = {"host": True, "mpi": True, "gpu": False} + provider = _external_provider_authority() return CapabilitySet({ + "provider": provider, + "adapter": provider["use_policy"]["capabilities"], "external_field_solver_v2": True, "topology_provenance": True, - "topology_contract": "uniform_cartesian_full_material_v1", - "execution_adapter": "host_serial_multi_patch_batch_v1", + "topology_contract": "cartesian_binary_coverage_hierarchy_v1", + "execution_adapter": "host_serial_or_declared_mpi_hierarchy_batch_v2", + "supports_amr": True, + "max_levels": None, + "refinement_ratios": (2,), + "hierarchy_policies": ( + _EXTERNAL_LEVEL_LOCAL_POLICY["policy_id"], + _EXTERNAL_COMPOSITE_POLICY["policy_id"], + ), "host": declared["host"] and adapter["host"], "mpi": declared["mpi"] and adapter["mpi"], "gpu": declared["gpu"] and adapter["gpu"], @@ -214,6 +292,7 @@ def _prepared_field_solver(self) -> tuple[Any, dict[str, Any]]: def _external_resolver(options, facts, where): from ._prepared_field_solver_registry import PreparedFieldSolverResolution + _validate_external_facts(facts, where) if not isinstance(options, Mapping) or set(options) != {"topology", "solver", "request"}: raise TypeError("%s external field solver options have an invalid shape" % where) topology = options["topology"] @@ -234,6 +313,9 @@ def _external_resolver(options, facts, where): return PreparedFieldSolverResolution( { "schema_identity": "pops.external.field-solver-request@2", + "provider_id": _EXTERNAL_PROVIDER_ID, + "provider_version": _EXTERNAL_PROVIDER_VERSION, + "adapter_identity": _EXTERNAL_ADAPTER_ID, "options": { "relative_tolerance": relative, "absolute_tolerance": absolute, @@ -243,7 +325,13 @@ def _external_resolver(options, facts, where): { "provider_id": "pops.external.field-topology", "version": 1, + "adapter_identity": _EXTERNAL_ADAPTER_ID, "topology_identity": facts.layout["topology_identity"], + "layout": { + "kind": facts.layout["kind"], + "levels": facts.layout["levels"], + }, + "hierarchy_policy": dict(facts.hierarchy), "component": dict(topology), }, (dict(topology), dict(solver)), @@ -259,17 +347,75 @@ def _finite_nonnegative(value: Any, *, where: str) -> float: return result -def _validate_external_use(use, where): - facts = use.facts - if facts.target != "system": +def _validate_external_facts(facts: Any, where: str) -> None: + hierarchy = facts.hierarchy + requested_policy = hierarchy.get("policy_id", "") + if facts.target not in ("system", "amr_system"): raise ValueError( - "%s external FieldSolver@2 requires a hierarchy-aware interface for AMR" % where + "%s provider %s supports only system and amr_system, got target=%r, layout=%r, " + "levels=%r, hierarchy_policy=%r" + % ( + where, + _EXTERNAL_PROVIDER_ID, + facts.target, + facts.layout.get("kind"), + facts.layout.get("levels"), + requested_policy, + ) ) - if facts.layout.get("kind") != "uniform" or facts.layout.get("levels") != 1: - raise ValueError("%s external FieldSolver@2 requires one uniform layout" % where) - if facts.layout.get("embedded_boundary") or facts.layout.get("adaptive"): + policy = ( + _EXTERNAL_LEVEL_LOCAL_POLICY + if facts.target == "system" + else _EXTERNAL_COMPOSITE_POLICY + ) + expected_kind = "uniform" if facts.target == "system" else "amr" + levels = facts.layout.get("levels") + if ( + facts.layout.get("kind") != expected_kind + or type(levels) is not int + or levels < 1 + or (facts.target == "system" and levels != 1) + ): + raise ValueError( + "%s provider %s adapter %s requires %s layout, got kind=%r levels=%r" + % ( + where, + _EXTERNAL_PROVIDER_ID, + _EXTERNAL_ADAPTER_ID, + "one uniform level" if facts.target == "system" else "one or more AMR levels", + facts.layout.get("kind"), + levels, + ) + ) + transition_ratios = tuple(facts.layout.get("transition_ratios", ())) + if facts.target == "amr_system" and ( + len(transition_ratios) != levels - 1 + or any(type(ratio) is not int or ratio != 2 for ratio in transition_ratios) + ): raise ValueError( - "%s external FieldSolver@2 requires a full-material non-adaptive topology" % where + "%s provider %s adapter %s requires one ratio-2 transition between each AMR level, " + "got %r" + % (where, _EXTERNAL_PROVIDER_ID, _EXTERNAL_ADAPTER_ID, transition_ratios) + ) + if ( + requested_policy != policy["policy_id"] + or hierarchy.get("interface_version") != policy["interface_version"] + or hierarchy.get("option_schema") != policy["option_schema"] + or dict(hierarchy.get("options", {})) != policy["options"] + ): + raise ValueError( + "%s provider %s adapter %s supports only hierarchy policy %s, got %r" + % ( + where, + _EXTERNAL_PROVIDER_ID, + _EXTERNAL_ADAPTER_ID, + policy["policy_id"], + requested_policy, + ) + ) + if facts.layout.get("embedded_boundary"): + raise ValueError( + "%s external FieldSolver@2 does not carry embedded/cut-cell material geometry" % where ) if facts.operator.get("screened"): raise ValueError( @@ -283,6 +429,11 @@ def _validate_external_use(use, where): raise ValueError( "%s external FieldSolver@2 has no shared nonlinear iterate/JVP protocol" % where ) + + +def _validate_external_use(use, where): + facts = use.facts + _validate_external_facts(facts, where) bindings = use.resolution.component_bindings if len(bindings) != 2 or any( not binding.get("declared_execution", {}).get("host") for binding in bindings @@ -308,24 +459,21 @@ def _install_external(context: Any, binding: Any) -> None: _EXTERNAL_FIELD_SOLVER_PROVIDER = register(Provider( - provider_id="pops.fields.external-field-solver", - version=2, - resolver_id="pops.fields.external-field-solver.resolve@2", - installer_id="pops.fields.external-field-solver.install@2", + provider_id=_EXTERNAL_PROVIDER_ID, + version=_EXTERNAL_PROVIDER_VERSION, + resolver_id=_EXTERNAL_RESOLVER_ID, + installer_id=_EXTERNAL_INSTALLER_ID, use_policy=UsePolicy( - "pops.fields.external-field-solver.use", - 2, - { - "targets": ("system",), - "topology": "uniform-cartesian-full-material", - "execution": "host-serial-multi-patch-batch", - "components": ("FieldTopology@2", "FieldSolver@2"), - }, + _EXTERNAL_USE_POLICY_ID, + _EXTERNAL_USE_POLICY_VERSION, + _external_adapter_capabilities(), _validate_external_use, ), resolver=_external_resolver, native_installer=_install_external, )) +if _EXTERNAL_FIELD_SOLVER_PROVIDER.authority() != _external_provider_authority(): + raise RuntimeError("external field solver provider authority projection is inconsistent") __all__ = [ diff --git a/python/pops/frames/__init__.py b/python/pops/frames/__init__.py index 6e560de9b..df2f35af6 100644 --- a/python/pops/frames/__init__.py +++ b/python/pops/frames/__init__.py @@ -6,8 +6,9 @@ CartesianDirection, X_AXIS, Y_AXIS, + Z_AXIS, ) __all__ = [ - "Cartesian2D", "CartesianAxis", "CartesianDirection", "X_AXIS", "Y_AXIS", + "Cartesian2D", "CartesianAxis", "CartesianDirection", "X_AXIS", "Y_AXIS", "Z_AXIS", ] diff --git a/python/pops/frames/cartesian.py b/python/pops/frames/cartesian.py index 296284102..2fbc14712 100644 --- a/python/pops/frames/cartesian.py +++ b/python/pops/frames/cartesian.py @@ -17,10 +17,15 @@ class CartesianDirection(Enum): - """Closed set of directions carried by :class:`Cartesian2D`.""" + """Closed physical x/y/z component directions. + + :class:`Cartesian2D` carries only x/y as mesh axes; z remains available to type transverse + polar components and out-of-plane axial components in a 2.5D model. + """ X = "x" Y = "y" + Z = "z" @dataclass(frozen=True, slots=True) @@ -35,7 +40,11 @@ def __post_init__(self) -> None: @property def index(self) -> int: - return 0 if self.direction is CartesianDirection.X else 1 + return { + CartesianDirection.X: 0, + CartesianDirection.Y: 1, + CartesianDirection.Z: 2, + }[self.direction] @property def name(self) -> str: @@ -61,7 +70,7 @@ def from_dict(cls, data: Any) -> CartesianAxis: try: result = cls(CartesianDirection(data["direction"])) except (TypeError, ValueError) as exc: - raise ValueError("CartesianAxis direction must be 'x' or 'y'") from exc + raise ValueError("CartesianAxis direction must be 'x', 'y', or 'z'") from exc if result.to_dict() != dict(data): raise ValueError("CartesianAxis data is not canonical") return result @@ -69,6 +78,7 @@ def from_dict(cls, data: Any) -> CartesianAxis: X_AXIS = CartesianAxis(CartesianDirection.X) Y_AXIS = CartesianAxis(CartesianDirection.Y) +Z_AXIS = CartesianAxis(CartesianDirection.Z) @dataclass(frozen=True, slots=True) @@ -127,5 +137,10 @@ def from_dict(cls, data: Any) -> Cartesian2D: __all__ = [ - "Cartesian2D", "CartesianAxis", "CartesianDirection", "X_AXIS", "Y_AXIS", + "Cartesian2D", + "CartesianAxis", + "CartesianDirection", + "X_AXIS", + "Y_AXIS", + "Z_AXIS", ] diff --git a/python/pops/identity/semantic.py b/python/pops/identity/semantic.py index 0ee2d7893..5af8f8a57 100644 --- a/python/pops/identity/semantic.py +++ b/python/pops/identity/semantic.py @@ -121,6 +121,7 @@ def program_semantic_data(program: Any) -> dict[str, Any]: "history_persistence", "dt_bound", "step_transaction", + "cadence", } if not expected.issubset(serialized) or not set(serialized).issubset(expected | optional): raise TypeError("Program semantic projection received an unsupported IR schema") @@ -133,7 +134,11 @@ def program_semantic_data(program: Any) -> dict[str, Any]: "block_order": serialized["block_order"], } for key in ( - "histories", "history_contracts", "history_persistence", "step_transaction", + "histories", + "history_contracts", + "history_persistence", + "step_transaction", + "cadence", ): if key in serialized: result[key] = serialized[key] diff --git a/python/pops/interfaces.py b/python/pops/interfaces.py index fdb7533f2..4662da304 100644 --- a/python/pops/interfaces.py +++ b/python/pops/interfaces.py @@ -170,6 +170,7 @@ def resolve(name: str) -> ComponentInterface: Tagger = resolve("tagger") Clustering = resolve("clustering") Transfer = resolve("transfer") +Reflux = resolve("reflux") FieldSolver = resolve("field_solver") Writer = resolve("writer") FieldTopology = resolve("field_topology") @@ -177,6 +178,6 @@ def resolve(name: str) -> ComponentInterface: __all__ = [ "ComponentInterface", "resolve", "NumericalFlux", "GhostBoundary", - "FieldBoundaryClosure", "Tagger", "Clustering", "Transfer", + "FieldBoundaryClosure", "Tagger", "Clustering", "Transfer", "Reflux", "FieldSolver", "Writer", "FieldTopology", ] diff --git a/python/pops/layouts/__init__.py b/python/pops/layouts/__init__.py index 60644fa31..0a691faca 100644 --- a/python/pops/layouts/__init__.py +++ b/python/pops/layouts/__init__.py @@ -196,6 +196,16 @@ def semantic_data(self) -> dict[str, Any]: def normalized_geometry(self) -> NormalizedGeometry: return _delegated_geometry(self.mesh, where="Uniform.mesh") + def native_spatial_data(self) -> dict[str, Any]: + projection = getattr(self.mesh, "native_spatial_data", None) + if not callable(projection): + raise TypeError( + "Uniform.mesh must implement native_spatial_data() for production lowering") + first, second = projection(), projection() + if not isinstance(first, dict) or first != second: + raise TypeError("Uniform.mesh native_spatial_data() must be one deterministic dict") + return first + def capabilities(self) -> CapabilitySet: return CapabilitySet({ "layout": "uniform", @@ -396,6 +406,7 @@ def __init__( load_balance: Any = None, tagger: Any = None, clustering: Any = None, + reflux: Any = None, ) -> None: # Structural snapshots consume ``options()``. Keeping authorities private prevents the # generic snapshotter from recursively treating Schedule implementation helpers as public @@ -407,15 +418,22 @@ def __init__( self._transfer = transfer self._execution = execution self._patch_layout = PatchLayout() if patch_layout is None else patch_layout - if load_balance is None or tagger is None or clustering is None: - from pops.lib.amr import BergerRigoutsos, SpaceFillingCurve, SymbolicTagger + if load_balance is None or tagger is None or clustering is None or reflux is None: + from pops.lib.amr import ( + BergerRigoutsos, + FluxRegisterReflux, + SpaceFillingCurve, + SymbolicTagger, + ) load_balance = SpaceFillingCurve() if load_balance is None else load_balance tagger = SymbolicTagger() if tagger is None else tagger clustering = BergerRigoutsos() if clustering is None else clustering + reflux = FluxRegisterReflux() if reflux is None else reflux self._load_balance = load_balance self._tagger = tagger self._clustering = clustering + self._reflux = reflux @property def grid(self) -> Any: @@ -457,6 +475,10 @@ def tagger(self) -> Any: def clustering(self) -> Any: return self._clustering + @property + def reflux(self) -> Any: + return self._reflux + def _validate_authorities(self) -> None: if type(self.regrid) is not AMRRegrid: raise TypeError("AMR.regrid must be an exact AMRRegrid authority") @@ -470,6 +492,7 @@ def _validate_authorities(self) -> None: _load_balance_data(self.load_balance) _provider_data(self.tagger, "tagger") _provider_data(self.clustering, "clustering") + _provider_data(self.reflux, "reflux") for method in ("validate", "capabilities", "requirements", "options", "to_dict"): if not callable(getattr(self.grid, method, None)): raise TypeError("AMR.grid must implement %s()" % method) @@ -518,6 +541,7 @@ def options(self) -> dict[str, Any]: "load_balance": _load_balance_data(self.load_balance), "tagger": self.tagger.inspect(), "clustering": self.clustering.inspect(), + "reflux": self.reflux.inspect(), } def _summary(self) -> str: @@ -569,6 +593,7 @@ def resolved(value: Any) -> Any: load_balance=self.load_balance, tagger=self.tagger.resolve_references(resolved), clustering=self.clustering.resolve_references(resolved), + reflux=self.reflux.resolve_references(resolved), ) def resolve_amr_authorities(self, context: Any) -> Any: @@ -585,6 +610,7 @@ def resolve_amr_authorities(self, context: Any) -> Any: load_balance=self.load_balance, tagger=self.tagger, clustering=self.clustering, + reflux=self.reflux, context=context, ) @@ -603,6 +629,29 @@ def runtime_layout_data(self) -> dict[str, Any]: def normalized_geometry(self) -> NormalizedGeometry: return _delegated_geometry(self.grid, where="AMR.grid") + def native_spatial_data(self) -> dict[str, Any]: + """Capture exact base topology and adaptive decomposition policies.""" + projection = getattr(self.grid, "native_spatial_data", None) + if not callable(projection): + raise TypeError( + "AMR.grid must implement native_spatial_data() for production lowering") + first, second = projection(), projection() + if not isinstance(first, dict) or first != second: + raise TypeError("AMR.grid native_spatial_data() must be one deterministic dict") + data = dict(first) + required = {"schema_version", "periodicity", "centering", "decomposition"} + if set(data) != required or data["schema_version"] != 1: + raise TypeError("AMR.grid native_spatial_data() uses an unsupported schema") + data["decomposition"] = { + "schema_version": 1, + "kind": "adaptive", + "base_domain": data["decomposition"], + "hierarchy": _authority_data(self.hierarchy, "hierarchy"), + "patch_layout": _patch_layout_data(self.patch_layout), + "load_balance": _load_balance_data(self.load_balance), + } + return data + def inspect(self) -> dict[str, Any]: from pops._capabilities_inspect import _layout_amr_report diff --git a/python/pops/lib/amr/__init__.py b/python/pops/lib/amr/__init__.py index e59531afa..2fab823dd 100644 --- a/python/pops/lib/amr/__init__.py +++ b/python/pops/lib/amr/__init__.py @@ -21,6 +21,9 @@ class _BuiltinLoadBalance: option_schema_identity: ClassVar[str] consumes_weights: ClassVar[bool] + def _native_options(self) -> dict[str, Any]: + return {} + def load_balance_provider_data(self) -> dict[str, Any]: data: dict[str, Any] = { "schema_version": 1, @@ -28,7 +31,7 @@ def load_balance_provider_data(self) -> dict[str, Any]: "provider_id": self.provider_id, "native_route": self.native_route, "option_schema_identity": self.option_schema_identity, - "options": {}, + "options": self._native_options(), "weight_capability": { "authenticated": True, "consumed": self.consumes_weights, @@ -64,6 +67,56 @@ class Knapsack(_BuiltinLoadBalance): consumes_weights: ClassVar[bool] = True +@dataclass(frozen=True, slots=True) +class MeasuredKnapsack(_BuiltinLoadBalance): + """Knapsack plus a measured, migration-aware net-benefit decision policy.""" + + minimum_improvement_ppm: int = 50_000 + amortization_steps: int = 20 + migration_bandwidth_bytes_per_second: int = 1_000_000_000 + per_patch_migration_latency_nanoseconds: int = 0 + + provider_id: ClassVar[str] = "pops.lib.amr::measured_knapsack" + native_route: ClassVar[str] = "measured_knapsack" + option_schema_identity: ClassVar[str] = "pops.amr.load-balance.measured-knapsack@1" + consumes_weights: ClassVar[bool] = True + + def __post_init__(self) -> None: + values = { + "minimum_improvement_ppm": self.minimum_improvement_ppm, + "amortization_steps": self.amortization_steps, + "migration_bandwidth_bytes_per_second": (self.migration_bandwidth_bytes_per_second), + "per_patch_migration_latency_nanoseconds": ( + self.per_patch_migration_latency_nanoseconds + ), + } + for name, value in values.items(): + if type(value) is not int: + raise TypeError("MeasuredKnapsack.%s must be an exact integer" % name) + if not 0 <= self.minimum_improvement_ppm < 1_000_000: + raise ValueError("MeasuredKnapsack.minimum_improvement_ppm must be in [0, 1000000)") + if self.amortization_steps < 1: + raise ValueError("MeasuredKnapsack.amortization_steps must be positive") + if self.migration_bandwidth_bytes_per_second < 1: + raise ValueError( + "MeasuredKnapsack.migration_bandwidth_bytes_per_second must be positive" + ) + if self.per_patch_migration_latency_nanoseconds < 0: + raise ValueError( + "MeasuredKnapsack.per_patch_migration_latency_nanoseconds must be non-negative" + ) + + def _native_options(self) -> dict[str, Any]: + return { + "minimum_improvement_ppm": self.minimum_improvement_ppm, + "amortization_steps": self.amortization_steps, + "migration_bandwidth_bytes_per_second": (self.migration_bandwidth_bytes_per_second), + "per_patch_migration_latency_nanoseconds": ( + self.per_patch_migration_latency_nanoseconds + ), + } + + @dataclass(frozen=True, slots=True) class RoundRobin(_BuiltinLoadBalance): """Index policy; weights are authenticated but intentionally do not select owners.""" @@ -373,6 +426,58 @@ def runtime_binding_data(self) -> dict[str, Any]: canonical_identity = runtime_binding_data +@dataclass(frozen=True, slots=True) +class FluxRegisterReflux: + """Builtin conservative flux-register correction through the Reflux provider protocol.""" + + __pops_ir_immutable__: ClassVar[bool] = True + + def resolve_references(self, resolver: Any) -> FluxRegisterReflux: + if not callable(resolver): + raise TypeError("FluxRegisterReflux.resolve_references requires a callable resolver") + return self + + def require_component_inputs(self, components: Any) -> None: + del components + + def lower_amr_provider(self, context: Any) -> Any: + from pops.amr.providers import ( + AMRProviderLoweringContext, + ResolvedAMRProviderBinding, + amr_provider_binding_identity, + ) + + if type(context) is not AMRProviderLoweringContext: + raise TypeError("FluxRegisterReflux requires an AMRProviderLoweringContext") + self.require_component_inputs(context.components) + data = { + **self.runtime_binding_data(), + "layout_identity": context.layout_identity, + "clock_identity": context.clock_identity, + } + data["provider_identity"] = amr_provider_binding_identity("reflux", data) + return ResolvedAMRProviderBinding("reflux", data) + + def runtime_binding_data(self) -> dict[str, Any]: + from pops import interfaces + + data = { + "schema_version": 1, + "provider_type": "builtin_amr_reflux", + "runtime_installation": { + "schema_version": 1, + "protocol": "builtin", + }, + "provider_id": "pops.lib.amr::flux_register_reflux", + "native_interface": interfaces.Reflux.to_data(), + } + data["provider_identity"] = make_identity("amr-reflux-provider", data).token + return data + + inspect = runtime_binding_data + canonical_identity = runtime_binding_data + + @dataclass(frozen=True, slots=True) class BergerRigoutsos: """Builtin clustering provider with intrinsic validated algorithm controls.""" @@ -459,8 +564,10 @@ def runtime_binding_data(self) -> dict[str, Any]: "DivergencePreservingFace", "EllipticRecompute", "FaceTransfer", + "FluxRegisterReflux", "LinearTimeInterpolation", "Knapsack", + "MeasuredKnapsack", "NodeTransfer", "PatchTopologyRebuild", "StateTransfer", diff --git a/python/pops/mesh/__init__.py b/python/pops/mesh/__init__.py index 9cff0478f..f7f2d8796 100644 --- a/python/pops/mesh/__init__.py +++ b/python/pops/mesh/__init__.py @@ -31,7 +31,8 @@ from .layout_plan import ( LayoutHandle, LayoutMappingOperation, LayoutMappingPort, LayoutMappingProvider, LayoutMappingRequirement, LayoutRepresentation, LayoutSynchronization, - LayoutPlan, LayoutPlanBuilder, NormalizedGeometry, NormalizedGeometryProvider, + LayoutPlan, LayoutPlanBuilder, NativeSpatialLayout, NormalizedGeometry, + NormalizedGeometryProvider, normalize_layout_plan) from .layout_mapping import NativeLayoutMapping from . import geometry, masks, boundaries @@ -43,7 +44,7 @@ "LayoutHandle", "LayoutMappingOperation", "LayoutMappingPort", "LayoutMappingProvider", "LayoutMappingRequirement", "LayoutRepresentation", "LayoutSynchronization", "LayoutPlan", "LayoutPlanBuilder", "NativeLayoutMapping", - "NormalizedGeometry", "NormalizedGeometryProvider", + "NativeSpatialLayout", "NormalizedGeometry", "NormalizedGeometryProvider", "normalize_layout_plan", "geometry", "masks", "boundaries", ] diff --git a/python/pops/mesh/_layout_plan_contracts.py b/python/pops/mesh/_layout_plan_contracts.py index 688573984..22f251128 100644 --- a/python/pops/mesh/_layout_plan_contracts.py +++ b/python/pops/mesh/_layout_plan_contracts.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum, IntEnum import hashlib import json @@ -327,6 +327,163 @@ class NormalizedGeometryProvider(Protocol): def normalized_geometry(self) -> NormalizedGeometry: ... +@dataclass(frozen=True, slots=True) +class NativeSpatialLayout: + """Exact immutable spatial specialization accepted by a native runtime. + + The rank is derived exclusively from ``shape``. Bounds must authenticate the same + :class:`NormalizedGeometry`; topology and decomposition remain explicit so neither compile nor + bind can recover them from a mutable authoring descriptor or a backend default. + """ + + layout_id: str + coordinate_system: str + cell_measure: str + axis_names: tuple[str, ...] + shape: tuple[int, ...] + lower: tuple[float, ...] + upper: tuple[float, ...] + periodicity: tuple[bool, ...] + centering: str + decomposition: Mapping[str, Any] + identity: Any = field(init=False) + + def __post_init__(self) -> None: + from pops.identity import make_identity + + if not isinstance(self.layout_id, str) or not self.layout_id: + raise TypeError("NativeSpatialLayout.layout_id must be non-empty text") + coordinate_system = _geometry_uri( + self.coordinate_system, where="NativeSpatialLayout.coordinate_system") + cell_measure = _geometry_uri( + self.cell_measure, where="NativeSpatialLayout.cell_measure") + axis_names = _geometry_axis_names(self.axis_names) + shape = _geometry_cells(self.shape) + lower = _geometry_points(self.lower, where="NativeSpatialLayout.lower") + upper = _geometry_points(self.upper, where="NativeSpatialLayout.upper") + periodicity = tuple(self.periodicity) + rank = len(shape) + if rank not in (1, 2, 3): + raise ValueError("NativeSpatialLayout supports only dimensions 1, 2, and 3") + if len(axis_names) != rank or len(lower) != rank or len(upper) != rank \ + or len(periodicity) != rank: + raise ValueError( + "NativeSpatialLayout shape, axes, bounds and periodicity must have one rank") + if any(high <= low for low, high in zip(lower, upper, strict=True)): + raise ValueError("NativeSpatialLayout.upper must be strictly above lower") + if any(type(value) is not bool for value in periodicity): + raise TypeError("NativeSpatialLayout.periodicity must contain exact bool values") + if not isinstance(self.centering, str) or self.centering not in { + "cell", "node", "face_x", "face_y", "face_z"}: + raise ValueError("NativeSpatialLayout.centering is unsupported") + decomposition = json_data( + self.decomposition, where="NativeSpatialLayout.decomposition") + if not isinstance(decomposition, dict) or not decomposition: + raise TypeError("NativeSpatialLayout.decomposition must be a non-empty mapping") + object.__setattr__(self, "coordinate_system", coordinate_system) + object.__setattr__(self, "cell_measure", cell_measure) + object.__setattr__(self, "axis_names", axis_names) + object.__setattr__(self, "shape", shape) + object.__setattr__(self, "lower", lower) + object.__setattr__(self, "upper", upper) + object.__setattr__(self, "periodicity", periodicity) + object.__setattr__(self, "decomposition", freeze(decomposition)) + object.__setattr__( + self, "identity", make_identity("native-spatial-layout", self._payload())) + + @property + def dimension(self) -> int: + return len(self.shape) + + def _payload(self) -> dict[str, Any]: + return { + "schema_version": 1, + "layout_id": self.layout_id, + "dimension": self.dimension, + "coordinate_system": self.coordinate_system, + "cell_measure": self.cell_measure, + "axis_names": list(self.axis_names), + "shape": list(self.shape), + "lower": [value.hex() for value in self.lower], + "upper": [value.hex() for value in self.upper], + "periodicity": list(self.periodicity), + "centering": self.centering, + "decomposition": thaw(self.decomposition), + } + + def to_data(self) -> dict[str, Any]: + return {**self._payload(), "identity": self.identity.token} + + @classmethod + def from_data(cls, data: Any) -> NativeSpatialLayout: + from pops.identity import Identity + + required = { + "schema_version", "layout_id", "dimension", "coordinate_system", "cell_measure", + "axis_names", "shape", "lower", "upper", "periodicity", "centering", + "decomposition", "identity", + } + if not isinstance(data, Mapping) or set(data) != required: + raise TypeError("NativeSpatialLayout data has an unsupported shape") + if data["schema_version"] != 1: + raise ValueError("NativeSpatialLayout data uses an unsupported schema") + for name in ("lower", "upper"): + values = data[name] + if not isinstance(values, list) or not values \ + or any(not isinstance(value, str) for value in values): + raise TypeError("NativeSpatialLayout.%s data must contain float.hex values" % name) + try: + lower = tuple(float.fromhex(value) for value in data["lower"]) + upper = tuple(float.fromhex(value) for value in data["upper"]) + except ValueError: + raise ValueError("NativeSpatialLayout bounds contain invalid float.hex data") from None + result = cls( + layout_id=data["layout_id"], + coordinate_system=data["coordinate_system"], + cell_measure=data["cell_measure"], + axis_names=tuple(data["axis_names"]), + shape=tuple(data["shape"]), + lower=lower, + upper=upper, + periodicity=tuple(data["periodicity"]), + centering=data["centering"], + decomposition=data["decomposition"], + ) + if data["dimension"] != result.dimension: + raise ValueError("NativeSpatialLayout.dimension does not match shape") + if Identity.from_token(data["identity"]) != result.identity \ + or result.to_data() != dict(data): + raise ValueError("NativeSpatialLayout data does not authenticate its payload") + return result + + @classmethod + def from_geometry( + cls, + *, + layout: LayoutHandle, + geometry: NormalizedGeometry, + periodicity: Any, + centering: Any, + decomposition: Any, + ) -> NativeSpatialLayout: + if not isinstance(layout, LayoutHandle): + raise TypeError("NativeSpatialLayout requires a canonical LayoutHandle") + if type(geometry) is not NormalizedGeometry: + raise TypeError("NativeSpatialLayout requires an exact NormalizedGeometry") + return cls( + layout_id=layout.qualified_id, + coordinate_system=geometry.coordinate_system, + cell_measure=geometry.cell_measure, + axis_names=geometry.axis_names, + shape=geometry.cells, + lower=geometry.lower, + upper=geometry.upper, + periodicity=tuple(periodicity), + centering=centering, + decomposition=decomposition, + ) + + @dataclass(frozen=True, slots=True) class NormalizedLayout: """Algorithm-neutral level plan; Uniform is the one-level degenerate case.""" @@ -342,6 +499,7 @@ class NormalizedLayout: capabilities: Mapping[str, Any] requirements: Mapping[str, Any] descriptor_snapshot: Mapping[str, Any] + native_spatial_layout: NativeSpatialLayout | None def __post_init__(self) -> None: if not isinstance(self.handle, LayoutHandle): @@ -351,6 +509,23 @@ def __post_init__(self) -> None: raise TypeError("NormalizedLayout.geometry must be an exact NormalizedGeometry") object.__setattr__(self, "geometry", NormalizedGeometry.from_data( self.geometry.to_data())) + native = self.native_spatial_layout + if native is not None: + if type(native) is not NativeSpatialLayout: + raise TypeError( + "NormalizedLayout.native_spatial_layout must be an exact " + "NativeSpatialLayout or None") + if native.layout_id != self.handle.qualified_id \ + or native.coordinate_system != self.geometry.coordinate_system \ + or native.cell_measure != self.geometry.cell_measure \ + or native.axis_names != self.geometry.axis_names \ + or native.shape != self.geometry.cells \ + or native.lower != self.geometry.lower \ + or native.upper != self.geometry.upper: + raise ValueError( + "NormalizedLayout native spatial facts differ from normalized geometry") + object.__setattr__(self, "native_spatial_layout", NativeSpatialLayout.from_data( + native.to_data())) ratios = tuple(self.transition_ratios) if len(ratios) != max(0, len(self.levels) - 1) or any( isinstance(value, bool) or not isinstance(value, int) or value < 2 @@ -386,6 +561,10 @@ def to_data(self) -> dict[str, Any]: "capabilities": thaw(self.capabilities), "requirements": thaw(self.requirements), "descriptor_snapshot": thaw(self.descriptor_snapshot), + "native_spatial_layout": ( + None if self.native_spatial_layout is None + else self.native_spatial_layout.to_data() + ), } @@ -711,6 +890,7 @@ def resource_requirements(self) -> tuple[dict[str, Any], ...]: "LayoutAssignment", "LayoutHandle", "LayoutLevel", "LayoutMappingOperation", "LayoutMappingProvider", "LayoutMappingPort", "LayoutMappingRequirement", "LayoutRepresentation", "LayoutSynchronization", "LayoutPlan", "NormalizedLayout", - "NormalizedGeometry", "NormalizedGeometryProvider", "POLAR_ANNULUS_2D_COORDINATES", + "NativeSpatialLayout", "NormalizedGeometry", "NormalizedGeometryProvider", + "POLAR_ANNULUS_2D_COORDINATES", "POLAR_ANNULUS_CELL_AREA", "ResolvedLayoutMapping", ] diff --git a/python/pops/mesh/boundaries/__init__.py b/python/pops/mesh/boundaries/__init__.py index 44321531a..2befc9dac 100644 --- a/python/pops/mesh/boundaries/__init__.py +++ b/python/pops/mesh/boundaries/__init__.py @@ -30,8 +30,9 @@ ConstraintResidual, ExteriorTrace, GhostState, IncomingMultiplicity, NumericalFlux, RepresentationFlow, SignDependence, SonicPolicy) from .providers import ( - BoundaryProvider, BoundaryProviderRegistry, DirectionalTransport, Dirichlet, GhostFormula, - Inflow, Mixed, Neumann, NoFlux, Outflow, ResolvedBoundaryBinding, ResolvedBoundaryPlan) + BoundaryProvider, BoundaryProviderKind, BoundaryProviderRegistry, DirectionalTransport, + Dirichlet, GhostFormula, Inflow, Mixed, Neumann, NoFlux, Outflow, PostRiemannFlux, + ResolvedBoundaryBinding, ResolvedBoundaryPlan) from .topology import ( BoundaryHandle, BoundaryOrientation, BoundarySide, BoundaryTopology, PeriodicIdentification, PeriodicOrientation) @@ -122,8 +123,9 @@ def options(self) -> dict: "PeriodicIdentification", "PeriodicOrientation", "BoundaryDependencies", "BoundaryPort", "CharacteristicClosure", "ClosureMode", "ConstraintResidual", "ExteriorTrace", "GhostState", "IncomingMultiplicity", "NumericalFlux", "RepresentationFlow", "SignDependence", - "SonicPolicy", "BoundaryProvider", "BoundaryProviderRegistry", "DirectionalTransport", - "Dirichlet", "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", + "SonicPolicy", "BoundaryProvider", "BoundaryProviderKind", "BoundaryProviderRegistry", + "DirectionalTransport", "Dirichlet", "GhostFormula", "Inflow", "Mixed", "Neumann", + "NoFlux", "Outflow", "PostRiemannFlux", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", "BoundaryComponentBinding", "BoundaryLinearizationContribution", "BoundaryResidualContribution", diff --git a/python/pops/mesh/boundaries/compiled_plan.py b/python/pops/mesh/boundaries/compiled_plan.py index 8acd7b69c..18cefd00d 100644 --- a/python/pops/mesh/boundaries/compiled_plan.py +++ b/python/pops/mesh/boundaries/compiled_plan.py @@ -173,6 +173,7 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: """Bind scalar values through one generic evaluator, never an authoring callback.""" from pops.model import Handle, ParamHandle from pops.model._bind_expression import eval_expression_key + from pops.runtime._analytic_expression_lowering import lower_analytic_components if not isinstance(params, Mapping): raise TypeError("compiled boundary binding requires resolved BindSchema values") @@ -192,33 +193,89 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: faces = [] for face in data["faces"]: if not isinstance(face, dict) or face.get("type") not in { - "periodic", "foextrap", "dirichlet", "external"}: + "periodic", "foextrap", "dirichlet", "no_flux", "slip_wall", + "external"}: raise ValueError("compiled boundary face has no executable producer type") - if face["type"] in {"periodic", "foextrap", "external"}: + representation = face.get("representation", "conservative") + converter = face.get("converter") + if representation not in {"conservative", "primitive"}: + raise ValueError("compiled boundary face has no executable state representation") + if representation == "conservative" and converter is not None: + raise ValueError( + "compiled conservative boundary face must not invent a converter") + if representation == "primitive" and ( + face["type"] != "dirichlet" or not isinstance(converter, str) + or not converter): + raise ValueError( + "compiled primitive boundary face requires one exact fixed-state converter") + if face["type"] in { + "periodic", "foextrap", "no_flux", "slip_wall", "external"}: values = [0.0] * ncomp + analytic_programs = [] + analytic_clock = None else: expressions = face.get("values") if not isinstance(expressions, list) or len(expressions) != ncomp: raise ValueError( "compiled Dirichlet boundary must exactly cover every state component" ) - values = [] - for index, expression in enumerate(expressions): - value = eval_expression_key( - expression, - environment, - where="compiled boundary face %d component %d" - % (int(face["ordinal"]), index), + protocols = { + expression.get("protocol") + for expression in expressions + if isinstance(expression, dict) + } + if protocols == {"pops.analytic.scalar.v1"}: + clocks = set() + from pops.analytic import ScalarExpr + + analytic_expressions = [] + for expression in expressions: + analytic = ScalarExpr.from_data(expression["value"]) + analytic_expressions.append(analytic) + clocks.update(clock.qualified_id for clock in analytic.time_clocks()) + if len(clocks) > 1: + raise ValueError("compiled analytic boundary face mixes logical Clocks") + analytic_clock = next(iter(clocks), None) + lowered = lower_analytic_components( + [expression.to_data() for expression in analytic_expressions], + frame_id=data["frame_id"], + bindings=params, + time_clock_id=analytic_clock, + ) + analytic_programs = [ + {"opcodes": list(opcodes), "literals": list(literals)} + for opcodes, literals in lowered + ] + values = [0.0] * ncomp + elif protocols: + raise TypeError( + "compiled Dirichlet boundary mixes unsupported expression protocols" ) - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError("compiled boundary expression did not bind to a real scalar") - values.append(float(value)) + else: + analytic_programs = [] + analytic_clock = None + values = [] + for index, expression in enumerate(expressions): + value = eval_expression_key( + expression, + environment, + where="compiled boundary face %d component %d" + % (int(face["ordinal"]), index), + ) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError( + "compiled boundary expression did not bind to a real scalar") + values.append(float(value)) faces.append({ "ordinal": int(face["ordinal"]), "geometry": face.get("geometry"), "producer": face.get("producer"), "type": face["type"], + "representation": representation, + "converter": converter, "values": values, + "analytic_programs": analytic_programs, + "analytic_clock": analytic_clock, }) faces.sort(key=lambda row: row["ordinal"]) diff --git a/python/pops/mesh/boundaries/ghost_plan.py b/python/pops/mesh/boundaries/ghost_plan.py index dccb2b06d..433f819bf 100644 --- a/python/pops/mesh/boundaries/ghost_plan.py +++ b/python/pops/mesh/boundaries/ghost_plan.py @@ -10,7 +10,7 @@ from .ghost_plan_types import ( BoundaryLinearizationContribution, BoundaryResidualContribution, CornerPolicy, GhostCoverageManifest, GhostRegion, InterfaceTraceOperation, MultiBlockInterface) -from .providers import BoundaryProvider +from .providers import BoundaryProvider, BoundaryProviderKind from .topology import BoundaryTopology, PeriodicIdentification if TYPE_CHECKING: @@ -133,10 +133,31 @@ def CoarseFineInterpolation(*, handle: Handle, protocol: Handle, interpolation: def PhysicalGhost(*, handle: Handle, protocol: Handle, provider: BoundaryProvider, + flux_provider: BoundaryProvider | None = None, dependencies: tuple[Handle, ...] = ()) -> GhostProducer: if not isinstance(provider, BoundaryProvider): raise TypeError("PhysicalGhost.provider must be a BoundaryProvider") - return GhostProducer(handle, protocol, dependencies, boundary_providers=(provider,)) + providers = (provider,) + if flux_provider is not None: + if not isinstance(flux_provider, BoundaryProvider) or \ + flux_provider.kind is not BoundaryProviderKind.POST_RIEMANN_FLUX: + raise TypeError( + "PhysicalGhost.flux_provider must be a PostRiemannFlux BoundaryProvider") + if any(output.port_type == "numerical_flux" for output in provider.outputs): + raise ValueError( + "PhysicalGhost primary provider must produce the exterior/ghost trace before " + "a post-Riemann flux transformation") + primary_boundaries = {output.boundary for output in provider.outputs} + flux_boundaries = {output.boundary for output in flux_provider.outputs} + primary_subjects = {output.subject for output in provider.outputs} + flux_subjects = {output.subject for output in flux_provider.outputs} + if (len(primary_boundaries) != 1 or flux_boundaries != primary_boundaries or + len(primary_subjects) != 1 or flux_subjects != primary_subjects): + raise ValueError( + "PhysicalGhost trace and post-Riemann providers must own the same exact face and " + "state") + providers += (flux_provider,) + return GhostProducer(handle, protocol, dependencies, boundary_providers=providers) def InterfaceGhost(*, handle: Handle, protocol: Handle, interface: MultiBlockInterface, @@ -581,6 +602,32 @@ def compile_boundary_data(self) -> dict[str, Any]: "explicit corner resolver Handle(s) require qualified GhostBoundary " "components: %s" % sorted(row.qualified_id for row in missing) ) + flux_providers = [ + (production.region, provider) for production in self.productions + for provider in production.producer.boundary_providers + if provider.kind is BoundaryProviderKind.POST_RIEMANN_FLUX + ] + if flux_providers: + invalid = [ + provider for region, provider in flux_providers + if len(provider.outputs) != 1 or provider.outputs[0].subject != region.subject + ] + if invalid: + raise ValueError( + "post-Riemann NumericalFlux provider must transform its production region's " + "exact state: %s" + % sorted(row.qualified_id for row in invalid) + ) + missing = [ + provider.handle for _, provider in flux_providers + if provider.handle not in self._binding_map() + ] + if missing: + raise NotImplementedError( + "post-Riemann NumericalFlux provider Handle(s) require qualified " + "BoundaryFlux components: %s" + % sorted(row.qualified_id for row in missing) + ) closures = [ operator for production in self.productions for operator in production.producer.operators diff --git a/python/pops/mesh/boundaries/ports.py b/python/pops/mesh/boundaries/ports.py index 10c933a4b..478a6ecfc 100644 --- a/python/pops/mesh/boundaries/ports.py +++ b/python/pops/mesh/boundaries/ports.py @@ -148,7 +148,7 @@ def __post_init__(self) -> None: (name, expected.__name__)) rows = _unique_handles( self.characteristics, where="CharacteristicClosure.characteristics", - kinds=frozenset(("state", "field"))) + kinds=frozenset(("state", "field", "boundary_eigenstructure"))) if self.mode is ClosureMode.NONE and rows: raise ValueError("ClosureMode.NONE cannot carry characteristic data") if self.mode is ClosureMode.NONE and ( diff --git a/python/pops/mesh/boundaries/providers.py b/python/pops/mesh/boundaries/providers.py index b944c968b..4568d72eb 100644 --- a/python/pops/mesh/boundaries/providers.py +++ b/python/pops/mesh/boundaries/providers.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum import hashlib import json from typing import TYPE_CHECKING, Any @@ -16,6 +17,36 @@ _SCHEMA_VERSION = 1 +_PROVIDER_SCHEMA_VERSION = 2 + + +class BoundaryProviderKind(Enum): + """Exact immutable law selected by one boundary provider specification.""" + + INFLOW = "inflow" + OUTFLOW = "outflow" + DIRECTIONAL_TRANSPORT = "directional_transport" + MIXED = "mixed" + GHOST_FORMULA = "ghost_formula" + DIRICHLET = "dirichlet" + NEUMANN = "neumann" + NO_FLUX = "no_flux" + POST_RIEMANN_FLUX = "post_riemann_flux" + CONSTRAINT_RESIDUAL = "constraint_residual" + + +_OUTPUT_CONTRACTS: dict[BoundaryProviderKind, type | tuple[type, ...]] = { + BoundaryProviderKind.INFLOW: (ExteriorTrace, GhostState), + BoundaryProviderKind.OUTFLOW: (ExteriorTrace, GhostState), + BoundaryProviderKind.DIRECTIONAL_TRANSPORT: (ExteriorTrace, GhostState), + BoundaryProviderKind.MIXED: ConstraintResidual, + BoundaryProviderKind.GHOST_FORMULA: GhostState, + BoundaryProviderKind.DIRICHLET: ExteriorTrace, + BoundaryProviderKind.NEUMANN: ConstraintResidual, + BoundaryProviderKind.NO_FLUX: NumericalFlux, + BoundaryProviderKind.POST_RIEMANN_FLUX: NumericalFlux, + BoundaryProviderKind.CONSTRAINT_RESIDUAL: ConstraintResidual, +} def _handle(value: Any, *, where: str, kind: str) -> Handle: @@ -79,9 +110,17 @@ class BoundaryProvider: handle: Handle outputs: tuple[BoundaryPort, ...] dependencies: BoundaryDependencies + kind: BoundaryProviderKind def __post_init__(self) -> None: - _handle(self.handle, where="BoundaryProvider.handle", kind="boundary_provider") + if not isinstance(self.kind, BoundaryProviderKind): + raise TypeError("BoundaryProvider.kind must be a BoundaryProviderKind") + handle_kind = ( + "boundary_flux_provider" + if self.kind is BoundaryProviderKind.POST_RIEMANN_FLUX + else "boundary_provider" + ) + _handle(self.handle, where="BoundaryProvider.handle", kind=handle_kind) if not isinstance(self.outputs, tuple) or not self.outputs: raise TypeError("BoundaryProvider.outputs must be a non-empty tuple") if any(not isinstance(row, BoundaryPort) for row in self.outputs): @@ -90,6 +129,25 @@ def __post_init__(self) -> None: raise ValueError("BoundaryProvider contains double output ports") if not isinstance(self.dependencies, BoundaryDependencies): raise TypeError("BoundaryProvider.dependencies must be explicit") + allowed = _OUTPUT_CONTRACTS[self.kind] + if any(not isinstance(row, allowed) for row in self.outputs): + allowed_names = (allowed.__name__ if isinstance(allowed, type) else + "/".join(row.__name__ for row in allowed)) + raise TypeError( + "BoundaryProvider kind %r requires typed %s outputs" + % (self.kind.value, allowed_names) + ) + if self.kind is BoundaryProviderKind.DIRECTIONAL_TRANSPORT and \ + self.dependencies.characteristic.mode is not ClosureMode.DIRECTIONAL: + raise ValueError( + "directional_transport provider requires explicit directional characteristic " + "closure" + ) + if self.kind is not BoundaryProviderKind.DIRECTIONAL_TRANSPORT and \ + self.dependencies.characteristic.mode is ClosureMode.DIRECTIONAL: + raise ValueError( + "directional characteristic closure requires a directional_transport provider" + ) target = self.dependencies.representation.target if any(row.representation != target for row in self.outputs): raise ValueError("provider output representation must match RepresentationFlow.target") @@ -101,7 +159,8 @@ def qualified_id(self) -> str: return self.handle.qualified_id def canonical_identity(self) -> dict[str, Any]: - return {"schema_version": _SCHEMA_VERSION, "provider_type": "boundary", + return {"schema_version": _PROVIDER_SCHEMA_VERSION, "provider_type": "boundary", + "provider_kind": self.kind.value, "handle": self.handle.canonical_identity(), "outputs": [row.canonical_identity() for row in self.outputs], "dependencies": self.dependencies.canonical_identity()} @@ -110,7 +169,7 @@ def inspect(self) -> dict[str, Any]: return {"report_type": "boundary_provider", **self.canonical_identity()} -def _factory(name: str, handle: Any, outputs: Any, dependencies: Any, +def _factory(name: str, kind: BoundaryProviderKind, handle: Any, outputs: Any, dependencies: Any, allowed: type | tuple[type, ...], *, directional: bool = False) -> BoundaryProvider: if not isinstance(outputs, tuple) or not outputs or any( not isinstance(row, allowed) for row in outputs): @@ -121,50 +180,77 @@ def _factory(name: str, handle: Any, outputs: Any, dependencies: Any, raise TypeError("%s dependencies must be BoundaryDependencies" % name) if directional and dependencies.characteristic.mode is not ClosureMode.DIRECTIONAL: raise ValueError("DirectionalTransport requires explicit directional characteristic closure") - return BoundaryProvider(handle, outputs, dependencies) + return BoundaryProvider(handle, outputs, dependencies, kind) def Inflow(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Inflow", handle, outputs, dependencies, (ExteriorTrace, GhostState)) + return _factory( + "Inflow", BoundaryProviderKind.INFLOW, handle, outputs, dependencies, + (ExteriorTrace, GhostState)) def Outflow(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Outflow", handle, outputs, dependencies, (ExteriorTrace, GhostState)) + return _factory( + "Outflow", BoundaryProviderKind.OUTFLOW, handle, outputs, dependencies, + (ExteriorTrace, GhostState)) def DirectionalTransport(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("DirectionalTransport", handle, outputs, dependencies, + return _factory("DirectionalTransport", BoundaryProviderKind.DIRECTIONAL_TRANSPORT, + handle, outputs, dependencies, (ExteriorTrace, GhostState), directional=True) def Mixed(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Mixed", handle, outputs, dependencies, ConstraintResidual) + return _factory( + "Mixed", BoundaryProviderKind.MIXED, handle, outputs, dependencies, ConstraintResidual) def GhostFormula(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("GhostFormula", handle, outputs, dependencies, GhostState) + return _factory( + "GhostFormula", BoundaryProviderKind.GHOST_FORMULA, handle, outputs, dependencies, + GhostState) def Dirichlet(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Dirichlet", handle, outputs, dependencies, ExteriorTrace) + return _factory( + "Dirichlet", BoundaryProviderKind.DIRICHLET, handle, outputs, dependencies, + ExteriorTrace) def Neumann(*, handle: Any, outputs: tuple[BoundaryPort, ...], dependencies: BoundaryDependencies) -> BoundaryProvider: - return _factory("Neumann", handle, outputs, dependencies, ConstraintResidual) + return _factory( + "Neumann", BoundaryProviderKind.NEUMANN, handle, outputs, dependencies, + ConstraintResidual) def NoFlux(*, handle: Any, output: NumericalFlux, dependencies: BoundaryDependencies) -> BoundaryProvider: if not isinstance(output, NumericalFlux): raise TypeError("NoFlux satisfies NumericalFlux only") - return BoundaryProvider(handle, (output,), dependencies) + return BoundaryProvider(handle, (output,), dependencies, BoundaryProviderKind.NO_FLUX) + + +def PostRiemannFlux(*, handle: Any, output: NumericalFlux, + dependencies: BoundaryDependencies) -> BoundaryProvider: + """Bind one exact native transformation of an already evaluated outward flux. + + The provider owns neither reconstruction nor the Riemann solve. Its + ``boundary_flux_provider`` Handle resolves only to the typed + ``BoundaryFlux.transform_faces`` ABI, so it cannot be mistaken for a ghost + producer or for the shared-interface ``NumericalFlux.evaluate_faces`` route. + """ + if not isinstance(output, NumericalFlux): + raise TypeError("PostRiemannFlux satisfies NumericalFlux only") + return BoundaryProvider( + handle, (output,), dependencies, BoundaryProviderKind.POST_RIEMANN_FLUX) @dataclass(frozen=True, slots=True) @@ -273,7 +359,8 @@ def resolve(self, topology: Any, needs: Any) -> ResolvedBoundaryPlan: __all__ = [ - "BoundaryProvider", "BoundaryProviderRegistry", "DirectionalTransport", "Dirichlet", - "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", + "BoundaryProvider", "BoundaryProviderKind", "BoundaryProviderRegistry", "DirectionalTransport", + "Dirichlet", + "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", "PostRiemannFlux", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", ] diff --git a/python/pops/mesh/grid.py b/python/pops/mesh/grid.py index a2226fbe2..05dd45b24 100644 --- a/python/pops/mesh/grid.py +++ b/python/pops/mesh/grid.py @@ -216,6 +216,23 @@ def normalized_geometry(self) -> NormalizedGeometry: frame_id=self.frame.canonical_id, ) + def native_spatial_data(self) -> dict[str, Any]: + """Exact topology and base decomposition consumed by native layout normalization.""" + periodic_indices = {axis.index for axis in self.topology.periodic_axes} + return { + "schema_version": 1, + "periodicity": [index in periodic_indices for index in range(len(self.cells))], + "centering": "cell", + "decomposition": { + "schema_version": 1, + "kind": "single_box", + "boxes": [{ + "lower": [0 for _ in self.cells], + "upper_exclusive": list(self.cells), + }], + }, + } + def validate(self, context: Any = None) -> bool: del context return True diff --git a/python/pops/mesh/layout_plan.py b/python/pops/mesh/layout_plan.py index 0de7e5305..834188ce4 100644 --- a/python/pops/mesh/layout_plan.py +++ b/python/pops/mesh/layout_plan.py @@ -18,6 +18,7 @@ LayoutPlan, LayoutRepresentation, LayoutSynchronization, + NativeSpatialLayout, NormalizedGeometry, NormalizedGeometryProvider, NormalizedLayout, @@ -89,6 +90,43 @@ def _descriptor_geometry(descriptor: Any) -> NormalizedGeometry: return NormalizedGeometry.from_data(first_data) +def _descriptor_native_spatial_layout( + descriptor: Any, + *, + handle: LayoutHandle, + geometry: NormalizedGeometry, +) -> NativeSpatialLayout | None: + """Capture an optional native specialization without rediscovering geometry. + + Extension layouts may remain algorithm-neutral and omit this protocol. Such a plan is still + inspectable, but the production resolve gate refuses it before compilation. A provider that + opts in supplies only topology/storage/decomposition facts; shape and bounds always come from + the already-authenticated ``NormalizedGeometry``. + """ + projection = getattr(descriptor, "native_spatial_data", None) + if projection is None: + return None + if not callable(projection): + raise TypeError("layout descriptor native_spatial_data must be callable") + first = json_data(projection(), where="layout descriptor native_spatial_data()") + second = json_data(projection(), where="layout descriptor native_spatial_data()") + if first != second: + raise ValueError("layout descriptor native_spatial_data() must be deterministic") + required = {"schema_version", "periodicity", "centering", "decomposition"} + if not isinstance(first, dict) or set(first) != required: + raise TypeError( + "layout descriptor native_spatial_data() must expose the exact schema-v1 shape") + if first["schema_version"] != 1: + raise ValueError("layout descriptor native_spatial_data() uses an unsupported schema") + return NativeSpatialLayout.from_geometry( + layout=handle, + geometry=geometry, + periodicity=first["periodicity"], + centering=first["centering"], + decomposition=first["decomposition"], + ) + + def normalize_layout(handle: LayoutHandle, descriptor: Any, *, handle_resolver: Any = None) \ -> NormalizedLayout: """Project any layout-descriptor implementation onto one common hierarchy representation.""" @@ -104,6 +142,8 @@ def normalize_layout(handle: LayoutHandle, descriptor: Any, *, handle_resolver: requirements = _descriptor_map(descriptor, "requirements") snapshot = _descriptor_snapshot(descriptor, handle_resolver=handle_resolver) geometry = _descriptor_geometry(descriptor) + native_spatial_layout = _descriptor_native_spatial_layout( + descriptor, handle=handle, geometry=geometry) count = capabilities.get("max_levels", capabilities.get("levels", 1)) adaptive = capabilities.get("supports_amr", False) if isinstance(count, bool) or not isinstance(count, int) or count < 1: @@ -138,7 +178,7 @@ def normalize_layout(handle: LayoutHandle, descriptor: Any, *, handle_resolver: transition_ratios=ratios, levels=levels, geometry=geometry, options=options, capabilities=capabilities, requirements=requirements, - descriptor_snapshot=snapshot) + descriptor_snapshot=snapshot, native_spatial_layout=native_spatial_layout) class LayoutPlanBuilder: @@ -319,6 +359,6 @@ def normalize_layout_plan(descriptor: Any, *, owner: Any, local_id: str = "defau "LayoutAssignment", "LayoutHandle", "LayoutLevel", "LayoutMappingOperation", "LayoutMappingProvider", "LayoutMappingPort", "LayoutMappingRequirement", "LayoutRepresentation", "LayoutSynchronization", "LayoutPlan", "LayoutPlanBuilder", - "NormalizedGeometry", "NormalizedGeometryProvider", "NormalizedLayout", + "NativeSpatialLayout", "NormalizedGeometry", "NormalizedGeometryProvider", "NormalizedLayout", "ResolvedLayoutMapping", "normalize_layout", "normalize_layout_plan", ] diff --git a/python/pops/mesh/polar.py b/python/pops/mesh/polar.py index 35f5c22b4..0d21992b0 100644 --- a/python/pops/mesh/polar.py +++ b/python/pops/mesh/polar.py @@ -99,6 +99,27 @@ def normalized_geometry(self) -> NormalizedGeometry: cells=(self.nr, self.ntheta), ) + def native_spatial_data(self) -> dict[str, Any]: + """Exact annular periodicity and authored azimuthal-band decomposition.""" + band = self.ntheta // self.theta_boxes + return { + "schema_version": 1, + "periodicity": [False, True], + "centering": "cell", + "decomposition": { + "schema_version": 1, + "kind": "axis_bands", + "axis": 1, + "boxes": [ + { + "lower": [0, index * band], + "upper_exclusive": [self.nr, (index + 1) * band], + } + for index in range(self.theta_boxes) + ], + }, + } + def _apply_system_config(self, config: Any) -> None: """Lower this advanced descriptor through the private native-config protocol.""" config.geometry = "polar" diff --git a/python/pops/model/_generated_component_schema.py b/python/pops/model/_generated_component_schema.py index 0b9390e80..1cc12d3d3 100644 --- a/python/pops/model/_generated_component_schema.py +++ b/python/pops/model/_generated_component_schema.py @@ -3,8 +3,8 @@ COMPONENT_CATALOG_SCHEMA_VERSION = 1 COMPONENT_MANIFEST_SCHEMA_VERSION = 2 -COMPONENT_CATALOG_SHA256 = '5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +COMPONENT_CATALOG_SHA256 = 'b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66' +COMPONENT_CATALOG_SEMANTIC_SHA256 = 'b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3' COMPONENT_INTERFACE_SPECS = ({'name': 'requirement', 'method': 'requirements', 'required_args': 0}, {'name': 'lowering', 'method': 'lower', 'required_args': 1}, {'name': 'stencil', 'method': 'stencil', 'required_args': 0}, diff --git a/python/pops/model/provider_pack.py b/python/pops/model/provider_pack.py index 16d42b251..fa2a8ba50 100644 --- a/python/pops/model/provider_pack.py +++ b/python/pops/model/provider_pack.py @@ -256,6 +256,50 @@ def select_spaces(self, *, owner_qid: str, (owner_qid, sorted(missing))) return self.select(keys) + def select_components( + self, + *, + owner_qid: str, + spaces: Iterable[tuple[str, str]], + components: Iterable[str], + ) -> ProviderPack: + """Select exact components from declared spaces without a bare-name fallback. + + Component spelling is only a filter inside the already-qualified owner/space set. A + missing component or the same spelling in two selected spaces is rejected rather than + guessed, so an operator that needs one of two homonymous fields must qualify its input + space more narrowly. + """ + _non_empty(owner_qid, "ProviderPack selection owner_qid") + requested_spaces = set(spaces) + requested_components = tuple(components) + if any(not isinstance(name, str) or not name for name in requested_components): + raise TypeError( + "ProviderPack components must contain non-empty strings" + ) + if len(set(requested_components)) != len(requested_components): + raise ValueError("ProviderPack components contains a duplicate") + candidates = [ + key for key in self + if key.owner_qid == owner_qid + and (key.space_kind, key.space_name) in requested_spaces + ] + selected = [] + for component in requested_components: + matches = [key for key in candidates if key.component == component] + if not matches: + raise MissingInputProvider( + "missing component %r in qualified provider spaces %r for owner %r" + % (component, sorted(requested_spaces), owner_qid) + ) + if len(matches) != 1: + raise MissingInputProvider( + "ambiguous component %r in qualified provider spaces %r for owner %r" + % (component, sorted(requested_spaces), owner_qid) + ) + selected.append(matches[0]) + return self.select(selected) + def to_data(self) -> dict[str, Any]: rows = [] for key in sorted(self._entries): @@ -363,7 +407,16 @@ def build_operator_provider_pack(module: Any, operator: Any) -> ProviderPack: spaces.append(("field", input_space.name)) if not spaces: return ProviderPack(capacity=full.capacity) - return full.select_spaces(owner_qid=str(module.owner_path.canonical()), spaces=spaces) + owner_qid = str(module.owner_path.canonical()) + requirements = getattr(operator, "requirements", {}) + required_components = requirements.get("aux", ()) + if required_components: + return full.select_components( + owner_qid=owner_qid, + spaces=spaces, + components=required_components, + ) + return full.select_spaces(owner_qid=owner_qid, spaces=spaces) __all__ = ["ComponentKey", "ComponentContract", "ProviderEntry", "ProviderPack", diff --git a/python/pops/moments/hierarchy.py b/python/pops/moments/hierarchy.py index ecb158cdf..49e9df167 100644 --- a/python/pops/moments/hierarchy.py +++ b/python/pops/moments/hierarchy.py @@ -282,10 +282,6 @@ def build(self, name: Any = "moments", *, frame: Any = None) -> Any: self._apply_poisson(m, registered) return m - def check(self, name: Any = "moments") -> Any: - """Alias of :meth:`build` (build + the engine's own validation on construction).""" - return self.build(name) - # --- internals ---------------------------------------------------------- def _apply_poisson(self, m: Any, registered: dict[str, Any]) -> None: """Author ``-laplacian(phi) == eps * M00`` and its gradient outputs.""" diff --git a/python/pops/numerics/reconstruction/__init__.py b/python/pops/numerics/reconstruction/__init__.py index 9f2c881d1..520f6dc09 100644 --- a/python/pops/numerics/reconstruction/__init__.py +++ b/python/pops/numerics/reconstruction/__init__.py @@ -36,6 +36,8 @@ "none": 1, "minmod": 2, "vanleer": 2, + "mc": 2, + "superbee": 2, "weno5": 3, }) @@ -48,7 +50,7 @@ #: halo a caller passes), never against this assumption -- rejecting WENO5 by default would be a #: FALSE POSITIVE that breaks a working problem. INSPECT_GHOST_DEPTH_ASSUMPTION = max( - REQUIRED_GHOST_DEPTH[token] for token in ("minmod", "vanleer") + REQUIRED_GHOST_DEPTH[token] for token in ("minmod", "vanleer", "mc", "superbee") ) @@ -124,7 +126,8 @@ def _weno5(name: str, epsilon: Any = None) -> Any: ``None`` (the default) keeps the native ``kWenoEpsilon`` literal -- the descriptor options are unchanged (omit-when-default) and the emitted stencil is bit-identical. A finite positive value - is carried in the descriptor options and threaded to the native ``Weno5::eps`` by ``add_block``. + is carried in the descriptor options and threaded to the native ``Weno5::eps`` by the + private ``add_equation`` installation seam. On AMR, descriptor availability is conditional on the resolved coarse/fine authority: it must certify order 5 and ghost depth 3. The builtin capability family selects its conservative order-5 route from that resolved requirement; an insufficient external provider is refused @@ -156,7 +159,8 @@ def _muscl(limiter: Any = None) -> Any: selected = Minmod() if limiter is None else limiter if isinstance(selected, str) or getattr(selected, "category", None) != "limiter": raise TypeError( - "MUSCL(limiter=) requires a typed limiter descriptor such as Minmod() or VanLeer()" + "MUSCL(limiter=) requires a typed limiter descriptor such as Minmod(), VanLeer(), " + "MC(), or Superbee()" ) route = authenticated_reconstruction_route(selected, require_muscl_limiter=True) return _native_reconstruction_descriptor( @@ -205,7 +209,8 @@ def required_ghost_depth(reconstruction_or_token: Any) -> Any: """The ghost depth a reconstruction NEEDS (Spec 5 sec.7 / criterion 11). Accepts an authenticated native reconstruction descriptor or a canonical lowered scheme token - (``"none"`` / ``"minmod"`` / ``"vanleer"`` / ``"weno5"``). Returns ``None`` when the + (``"none"`` / ``"minmod"`` / ``"vanleer"`` / ``"mc"`` / ``"superbee"`` / + ``"weno5"``). Returns ``None`` when the requirement is not declared/known -- the caller then does NOT reject (a missing requirement is not a known incompatibility; no false positive). """ diff --git a/python/pops/numerics/reconstruction/limiters.py b/python/pops/numerics/reconstruction/limiters.py index 8ff9f4e96..89b25135e 100644 --- a/python/pops/numerics/reconstruction/limiters.py +++ b/python/pops/numerics/reconstruction/limiters.py @@ -55,8 +55,20 @@ def VanLeer() -> Any: return _native_reconstruction_descriptor(LIMITER_VANLEER, category="limiter") -limiters = SimpleNamespace(Minmod=Minmod, VanLeer=VanLeer) +def MC() -> Any: + from pops.runtime.routes import LIMITER_MC + + return _native_reconstruction_descriptor(LIMITER_MC, category="limiter") + + +def Superbee() -> Any: + from pops.runtime.routes import LIMITER_SUPERBEE + + return _native_reconstruction_descriptor(LIMITER_SUPERBEE, category="limiter") + + +limiters = SimpleNamespace(Minmod=Minmod, VanLeer=VanLeer, MC=MC, Superbee=Superbee) # Spec 5: expose the limiters at module scope. -__all__ = ["limiters", "Minmod", "VanLeer"] +__all__ = ["limiters", "Minmod", "VanLeer", "MC", "Superbee"] diff --git a/python/pops/numerics/riemann/__init__.py b/python/pops/numerics/riemann/__init__.py index d71cffda1..4b260fba2 100644 --- a/python/pops/numerics/riemann/__init__.py +++ b/python/pops/numerics/riemann/__init__.py @@ -1,7 +1,7 @@ """pops.numerics.riemann -- the Riemann-flux brick catalog (Spec 3 / Spec 5). -Native numerical fluxes (Rusanov/HLL/HLLC/Roe) plus a ``User`` selector for an -external C++ flux brick. The capability-hook selectors (``riemann.speeds`` / +Native numerical fluxes (Rusanov/HLL/HLLC/Roe), the closed typed ``Recovery`` policy, plus a +``User`` selector for an external C++ flux brick. The capability-hook selectors (``riemann.speeds`` / ``riemann.hllc``) are attached from :mod:`pops.numerics.riemann.capabilities`. Spec 5 (sec.4 / sec.5.4) homes the discretisation descriptors in ``pops.numerics``; @@ -13,14 +13,15 @@ from types import SimpleNamespace from typing import Any -from pops.descriptors import _native, _external_descriptor -from . import waves +from pops.descriptors import BrickDescriptor, _native, _external_descriptor +from . import providers, waves +from .providers import Harten, NoEntropyFix, RiemannProviderEvidence, RoeEntropyPolicy from .waves import (WaveSpeedProvider, ExplicitPair, FromJacobian, FromPressure, Einfeldt, Davis, MaxWaveSpeed, provider_of) -def _riemann(name: Any, native_id: Any, caps: Any) -> Any: - return _native(name, native_id, name, category="riemann", caps=caps) +def _riemann(name: Any, native_id: Any, caps: Any, **options: Any) -> Any: + return _native(name, native_id, name, category="riemann", caps=caps, **options) def _scalar_upwind(*, velocity: Any) -> Any: @@ -78,6 +79,100 @@ def _hll(waves: Any = None) -> Any: return desc +_RECOVERY_NATIVE_ID = ( + "pops::PreparedRiemannRecoveryPolicy" +) +_RECOVERY_SEQUENCE = ( + ("roe", "pops::RoeFlux"), + ("hll", "pops::HLLFlux"), + ("rusanov", "pops::RusanovFlux"), +) + + +def _canonical_recovery_candidates() -> tuple[BrickDescriptor, ...]: + return ( + _riemann( + "roe", + "pops::RoeFlux", + ["physical_flux", "provider_pack", "stability_bound", "roe_dissipation"], + ), + _hll(), + _riemann( + "rusanov", + "pops::RusanovFlux", + ["physical_flux", "provider_pack", "stability_bound"], + ), + ) + + +def _recovery(*, primary: Any, fallbacks: Any) -> Any: + """Fixed fail-closed Roe -> HLL -> Rusanov recovery policy. + + The policy is deliberately a closed typed value, not a general Python list lowered into an + arbitrary C++ template. Only the one native policy instantiated by PoPS is accepted; every + mismatch is refused while authoring, before compile or bind. + """ + if not isinstance(fallbacks, tuple): + raise TypeError( + "riemann.Recovery(fallbacks=) requires a tuple of typed built-in descriptors; " + "use fallbacks=(riemann.HLL(), riemann.Rusanov())" + ) + authored = (primary, *fallbacks) + labels = ("primary", *("fallbacks[%d]" % index for index in range(len(fallbacks)))) + actual: list[tuple[str, str]] = [] + for label, candidate in zip(labels, authored, strict=True): + if not isinstance(candidate, BrickDescriptor) or candidate.category != "riemann": + raise TypeError( + "riemann.Recovery(%s) requires a typed built-in Riemann descriptor; got %s" + % (label, type(candidate).__name__) + ) + if candidate.brick_type != "native" or candidate.scheme == "user": + raise ValueError( + "riemann.Recovery(%s) refuses external/non-native descriptor %r; prepared " + "recovery candidates must be compiled device-copyable built-ins" + % (label, candidate.name) + ) + if candidate.options: + raise ValueError( + "riemann.Recovery(%s=%r) carries candidate options that the fixed native policy " + "does not transport; use the option-free built-in descriptor" + % (label, candidate.name) + ) + actual.append((str(candidate.scheme), candidate.native_id)) + + schemes = tuple(scheme for scheme, _ in actual) + duplicates = tuple(sorted({scheme for scheme in schemes if schemes.count(scheme) > 1})) + if duplicates: + raise ValueError( + "riemann.Recovery candidates must be unique; duplicates=%s" + % ",".join(duplicates) + ) + if tuple(actual) != _RECOVERY_SEQUENCE: + raise ValueError( + "riemann.Recovery supports exactly primary=Roe(), " + "fallbacks=(HLL(), Rusanov()); requested order=%s" + % " -> ".join(schemes) + ) + constructors = ("Roe", "HLL", "Rusanov") + for label, candidate, canonical, constructor in zip( + labels, authored, _canonical_recovery_candidates(), constructors, strict=True + ): + if candidate != canonical: + raise ValueError( + "riemann.Recovery(%s=%r) is not the catalog-authenticated option-free built-in; " + "construct it with riemann.%s()" + % (label, candidate.name, constructor) + ) + return _riemann( + "roe_hll_rusanov_recovery", + _RECOVERY_NATIVE_ID, + ["physical_flux", "provider_pack", "stability_bound", "wave_speeds", + "roe_dissipation"], + recovery_order=("roe", "hll", "rusanov", "reject"), + ) + + riemann = SimpleNamespace( Rusanov=lambda: _riemann( "rusanov", "pops::RusanovFlux", ["physical_flux", "provider_pack", "stability_bound"]), @@ -89,6 +184,7 @@ def _hll(waves: Any = None) -> Any: Roe=lambda: _riemann( "roe", "pops::RoeFlux", ["physical_flux", "provider_pack", "stability_bound", "roe_dissipation"]), + Recovery=_recovery, User=lambda brick_id: _external_descriptor(brick_id, expect_category="riemann"), ) @@ -100,6 +196,11 @@ def _hll(waves: Any = None) -> Any: # The typed wave-speed provider layer (ADC-552): reachable as ``riemann.waves.ExplicitPair()`` # (the real submodule exposes the factories) so ``HLL(waves=riemann.waves.ExplicitPair())`` works. riemann.waves = waves +# Exact model-side provider policies. They configure the existing generic Roe route; they do not +# select a second solver implementation. +riemann.providers = providers +riemann.Harten = Harten +riemann.NoEntropyFix = NoEntropyFix # Pre-runtime capability refusals (ADC-533): the model-aware available/validate that surface the # HLL/HLLC/Roe/Euler route refusals through the descriptor surface. They DELEGATE to the exact @@ -117,8 +218,10 @@ def _hll(waves: Any = None) -> Any: HLL = riemann.HLL HLLC = riemann.HLLC Roe = riemann.Roe +Recovery = riemann.Recovery User = riemann.User -__all__ = ["riemann", "waves", "Rusanov", "ScalarUpwind", "HLL", "HLLC", "Roe", - "User", "WaveSpeedProvider", "ExplicitPair", "FromJacobian", "FromPressure", - "Einfeldt", "Davis", "MaxWaveSpeed", "provider_of", "available", "validate"] +__all__ = ["riemann", "providers", "waves", "Rusanov", "ScalarUpwind", "HLL", "HLLC", "Roe", + "Recovery", "User", "WaveSpeedProvider", "ExplicitPair", "FromJacobian", "FromPressure", + "Einfeldt", "Davis", "MaxWaveSpeed", "provider_of", "Harten", "NoEntropyFix", + "RoeEntropyPolicy", "RiemannProviderEvidence", "available", "validate"] diff --git a/python/pops/numerics/riemann/availability.py b/python/pops/numerics/riemann/availability.py index 9f77f3a2e..21d623f50 100644 --- a/python/pops/numerics/riemann/availability.py +++ b/python/pops/numerics/riemann/availability.py @@ -22,6 +22,53 @@ from pops.descriptors import Availability + +def _layout_of(context: Any) -> Any: + if context is None: + return None + if isinstance(context, dict): + return context.get("layout", context.get("mesh", context.get("geometry"))) + for attribute in ("layout", "mesh", "geometry"): + value = getattr(context, attribute, None) + if value is not None: + return value + return context + + +def _is_polar(context: Any) -> bool: + layout = _layout_of(context) + if layout is None: + return False + if isinstance(layout, str): + return layout.lower() in {"polar", "polar_mesh", "annular_polar"} + if type(layout).__name__ == "PolarMesh": + return True + capabilities = getattr(layout, "capabilities", None) + if callable(capabilities): + values = capabilities() + data = getattr(values, "values", values) + if hasattr(data, "get") and data.get("geometry") == "polar": + return True + return False + + +def _validate_layout(flux: Any, context: Any) -> None: + if not _is_polar(context): + return + scheme = str(getattr(flux, "scheme", "")) + from pops.runtime.routes import resolve + + try: + route = resolve("riemann", scheme) + except ValueError: + return # External routes own their declared layout contract. + if not route.metadata.get("polar_ok", False): + raise ValueError( + "validate: Riemann flux %r is unavailable on annular polar geometry " + "(catalog polar_ok=false); no fallback or candidate substitution" % scheme + ) + + def _model_of(context: Any) -> Any: """Extract the compiled / authoring model from a validate/available @p context, or ``None``. @@ -54,6 +101,7 @@ def flux_validate(flux: Any, context: Any = None) -> bool: Returns ``True`` when the flux is usable; re-raises the predicate's ``ValueError`` otherwise. """ + _validate_layout(flux, context) model = _model_of(context) if model is None: return True @@ -78,7 +126,9 @@ def flux_available(flux: Any, context: Any = None) -> Any: except ValueError as err: from pops.numerics.riemann._contract import riemann_capability_contract from pops.runtime.routes import riemann_missing_capabilities - missing = riemann_missing_capabilities(riemann_capability_contract(flux), model) + missing = [] if model is None else riemann_missing_capabilities( + riemann_capability_contract(flux), model + ) alternatives = ["pops.numerics.riemann.Rusanov()"] return Availability.no(str(err), missing=missing, alternatives=alternatives) return Availability.yes() diff --git a/python/pops/numerics/riemann/providers.py b/python/pops/numerics/riemann/providers.py new file mode 100644 index 000000000..eb114a149 --- /dev/null +++ b/python/pops/numerics/riemann/providers.py @@ -0,0 +1,289 @@ +"""Exact immutable provider evidence for the generic HLLC/Roe pipeline. + +The native routes remain :class:`pops::HLLCFlux` and :class:`pops::RoeFlux`. +This module records *which* model-side provider satisfies those routes and the +typed entropy policy used by Roe. The evidence survives model compilation so +runtime availability and inspection never infer a provider from a truthy flag. +""" +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from decimal import Decimal +from fractions import Fraction +from typing import Any + +from pops.identity.scalar import scalar_literal + + +HLLC_FLUID_ROLES = "fluid_roles_v1" +ROE_FLUID_ROLES = "fluid_roles_v1" +ROE_DIRECT_ACTION = "direct_action_v1" +ROE_FLUX_JACOBIAN = "flux_jacobian_v1" + +ENTROPY_HARTEN = "harten_v1" +ENTROPY_NONE = "none" +ENTROPY_PROVIDER_OWNED = "provider_owned" + + +def _exact_positive_delta(value: Any, *, where: str) -> Any: + try: + literal = scalar_literal(value) + except (TypeError, ValueError, OverflowError) as exc: + raise type(exc)("%s: %s" % (where, exc)) from exc + if literal.unit is not None or literal.target is not None: + raise TypeError("%s cannot carry a unit or target annotation" % where) + try: + exact = literal.to_python() + except TypeError as exc: + raise TypeError( + "%s requires an exact int, Fraction, Decimal, or finite float" % where + ) from exc + if not exact > 0: + raise ValueError("%s must be strictly positive (got %r)" % (where, exact)) + try: + lowered = float(exact) + except (TypeError, ValueError, OverflowError) as exc: + raise OverflowError("%s cannot be represented by native pops::Real" % where) from exc + if not math.isfinite(lowered) or not lowered > 0.0: + raise OverflowError("%s underflows or overflows the positive pops::Real range" % where) + return exact + + +def _delta_token(value: Any) -> str: + return json.dumps( + scalar_literal(value).to_data(), sort_keys=True, separators=(",", ":") + ) + + +def _delta_from_token(token: Any) -> Any: + if not isinstance(token, str) or not token: + raise ValueError("Roe Harten entropy evidence requires a canonical scalar token") + try: + data = json.loads(token) + except (TypeError, ValueError) as exc: + raise ValueError("Roe entropy delta is not canonical scalar JSON") from exc + if not isinstance(data, dict): + raise ValueError("Roe entropy delta must be canonical scalar JSON") + kind = data.get("kind") + try: + if kind == "integer" and set(data) == {"kind", "value"}: + value: Any = int(data["value"]) + elif kind == "rational" and set(data) == {"kind", "numerator", "denominator"}: + value = Fraction(int(data["numerator"]), int(data["denominator"])) + elif kind == "decimal" and set(data) == {"kind", "value"}: + value = Decimal(data["value"]) + elif kind == "binary64" and set(data) == {"kind", "value"}: + value = float.fromhex(data["value"]) + else: + raise ValueError + except (TypeError, ValueError, ZeroDivisionError) as exc: + raise ValueError("Roe entropy delta has an unsupported scalar encoding") from exc + value = _exact_positive_delta(value, where="compiled Roe entropy delta") + if _delta_token(value) != token: + raise ValueError("Roe entropy delta token is not canonical") + return value + + +@dataclass(frozen=True, slots=True) +class RoeEntropyPolicy: + """Typed entropy correction selected by a Roe model-side provider.""" + + kind: str + delta: Any = None + __pops_ir_immutable__ = True + + def __post_init__(self) -> None: + if self.kind == ENTROPY_HARTEN: + if self.delta is None: + raise ValueError("Harten entropy policy requires delta") + object.__setattr__( + self, + "delta", + _exact_positive_delta(self.delta, where="Harten.delta"), + ) + return + if self.kind == ENTROPY_NONE: + if self.delta is not None: + raise ValueError("NoEntropyFix cannot carry delta") + return + raise ValueError("unknown Roe entropy policy %r" % (self.kind,)) + + @property + def delta_token(self) -> str | None: + return _delta_token(self.delta) if self.kind == ENTROPY_HARTEN else None + + def to_data(self) -> dict[str, Any]: + data: dict[str, Any] = {"kind": self.kind} + if self.delta_token is not None: + data["delta"] = json.loads(self.delta_token) + return data + + +def Harten(delta: Any = 0.1) -> RoeEntropyPolicy: + """Harten's quadratic entropy correction with an exact positive ``delta``.""" + + return RoeEntropyPolicy(ENTROPY_HARTEN, delta) + + +def NoEntropyFix() -> RoeEntropyPolicy: + """Use the unmodified absolute eigenvalue / matrix absolute value.""" + + return RoeEntropyPolicy(ENTROPY_NONE) + + +def require_entropy_policy(value: Any, *, default: RoeEntropyPolicy, where: str) -> RoeEntropyPolicy: + """Normalize an optional policy while refusing untyped scalar magic.""" + + selected = default if value is None else value + if type(selected) is not RoeEntropyPolicy: + raise TypeError( + "%s requires riemann.Harten(delta) or riemann.NoEntropyFix(), got %s" + % (where, type(selected).__name__) + ) + return selected + + +@dataclass(frozen=True, slots=True) +class RiemannProviderEvidence: + """Detached exact evidence for the model-side HLLC and Roe providers.""" + + hllc_provider: str | None = None + roe_provider: str | None = None + roe_entropy_policy: str | None = None + roe_entropy_delta: str | None = None + + def __post_init__(self) -> None: + if self.hllc_provider not in (None, HLLC_FLUID_ROLES): + raise ValueError("unknown HLLC provider %r" % (self.hllc_provider,)) + if self.roe_provider not in ( + None, + ROE_FLUID_ROLES, + ROE_DIRECT_ACTION, + ROE_FLUX_JACOBIAN, + ): + raise ValueError("unknown Roe provider %r" % (self.roe_provider,)) + if self.roe_provider is None: + if self.roe_entropy_policy is not None or self.roe_entropy_delta is not None: + raise ValueError("Roe entropy evidence requires an exact Roe provider") + return + if self.roe_provider == ROE_DIRECT_ACTION: + if self.roe_entropy_policy != ENTROPY_PROVIDER_OWNED: + raise ValueError("direct-action Roe requires provider_owned entropy evidence") + if self.roe_entropy_delta is not None: + raise ValueError("direct-action Roe cannot carry a framework entropy delta") + return + if self.roe_entropy_policy == ENTROPY_HARTEN: + _delta_from_token(self.roe_entropy_delta) + return + if self.roe_entropy_policy == ENTROPY_NONE: + if self.roe_entropy_delta is not None: + raise ValueError("Roe entropy policy 'none' cannot carry delta") + return + raise ValueError( + "Roe provider %r requires exact harten_v1 or none entropy evidence" + % self.roe_provider + ) + + +def _authoring_model(model: Any) -> Any: + inner = getattr(model, "_dsl", model) + inner = getattr(inner, "_m", inner) + if hasattr(inner, "_roe") or hasattr(inner, "_hllc"): + return inner + return None + + +def authoring_provider_evidence(model: Any) -> RiemannProviderEvidence: + """Derive exact provider evidence from one authoring model, without inference.""" + + inner = _authoring_model(model) + if inner is None: + return RiemannProviderEvidence() + hllc_provider = HLLC_FLUID_ROLES if bool(getattr(inner, "_hllc", False)) else None + providers = ( + bool(getattr(inner, "_roe", False)), + getattr(inner, "_roe_rows", None) is not None, + getattr(inner, "_roe_jacobian", None) is not None, + ) + if sum(providers) > 1: + raise ValueError("model declares competing Roe providers") + policy = getattr(inner, "_roe_entropy_policy", None) + if providers[0]: + if type(policy) is not RoeEntropyPolicy: + raise ValueError("fluid-role Roe is missing its typed entropy policy") + return RiemannProviderEvidence( + hllc_provider, + ROE_FLUID_ROLES, + policy.kind, + policy.delta_token, + ) + if providers[1]: + if policy is not None: + raise ValueError("direct-action Roe cannot carry a framework entropy policy") + return RiemannProviderEvidence( + hllc_provider, + ROE_DIRECT_ACTION, + ENTROPY_PROVIDER_OWNED, + None, + ) + if providers[2]: + if type(policy) is not RoeEntropyPolicy: + raise ValueError("flux-Jacobian Roe is missing its typed entropy policy") + stored_delta = inner._roe_jacobian.get("entropy_fix") + expected_delta = policy.delta if policy.kind == ENTROPY_HARTEN else None + if stored_delta != expected_delta: + raise ValueError("flux-Jacobian Roe entropy policy disagrees with emitted delta") + return RiemannProviderEvidence( + hllc_provider, + ROE_FLUX_JACOBIAN, + policy.kind, + policy.delta_token, + ) + if policy is not None: + raise ValueError("Roe entropy policy exists without a Roe provider") + return RiemannProviderEvidence(hllc_provider=hllc_provider) + + +def compiled_provider_evidence(model: Any) -> RiemannProviderEvidence: + """Read and validate detached evidence, including legacy-flag parity.""" + + evidence = RiemannProviderEvidence( + getattr(model, "hllc_provider", None), + getattr(model, "roe_provider", None), + getattr(model, "roe_entropy_policy", None), + getattr(model, "roe_entropy_delta", None), + ) + if bool(getattr(model, "has_hllc", False)) != (evidence.hllc_provider is not None): + raise ValueError("CompiledModel has_hllc disagrees with exact HLLC provider evidence") + if bool(getattr(model, "has_roe", False)) != (evidence.roe_provider is not None): + raise ValueError("CompiledModel has_roe disagrees with exact Roe provider evidence") + return evidence + + +def provider_evidence_of(model: Any) -> RiemannProviderEvidence: + """Return exact authoring or detached provider evidence; never guess from booleans.""" + + if all(hasattr(model, name) for name in ("hllc_provider", "roe_provider")): + return compiled_provider_evidence(model) + return authoring_provider_evidence(model) + + +__all__ = [ + "ENTROPY_HARTEN", + "ENTROPY_NONE", + "ENTROPY_PROVIDER_OWNED", + "HLLC_FLUID_ROLES", + "ROE_DIRECT_ACTION", + "ROE_FLUID_ROLES", + "ROE_FLUX_JACOBIAN", + "Harten", + "NoEntropyFix", + "RiemannProviderEvidence", + "RoeEntropyPolicy", + "authoring_provider_evidence", + "compiled_provider_evidence", + "provider_evidence_of", + "require_entropy_policy", +] diff --git a/python/pops/output/_balance_due_contract.py b/python/pops/output/_balance_due_contract.py new file mode 100644 index 000000000..847d7fbe2 --- /dev/null +++ b/python/pops/output/_balance_due_contract.py @@ -0,0 +1,13 @@ +"""Compatibility aliases for the core balance due contract.""" + +from pops._balance_due_contract import ( + BalanceDueConsumer, + BalanceDueContract, + BalanceDueRoute, +) + +__all__ = [ + "BalanceDueConsumer", + "BalanceDueContract", + "BalanceDueRoute", +] diff --git a/python/pops/output/_catalyst_backend.py b/python/pops/output/_catalyst_backend.py index 5d676b45c..5dc654850 100644 --- a/python/pops/output/_catalyst_backend.py +++ b/python/pops/output/_catalyst_backend.py @@ -5,6 +5,7 @@ The native runtime currently supplies rank-2 cell-centered fields. Live visualization accepts a serial frame or collective rank-local frames on an authenticated duplicated MPI observer lane. """ + from __future__ import annotations import importlib @@ -20,7 +21,12 @@ ) from pops.output._consumer_contracts import ParallelMode from pops.output.data import FieldPayload, LevelGeometry, _field_family_identity -from pops.output.observers import ObserverFrame, ObserverReceipt, ObserverRun +from pops.output.observers import ( + ObserverFrame, + ObserverReceipt, + ObserverRun, + ObserverWorkerCollectiveLost, +) from pops.output._writers.paraview import _field_display_names, _field_families @@ -43,15 +49,15 @@ def _piece_for_box(field: FieldPayload, box_index: int) -> Any: if len(rows) != 1: raise ValueError( "Catalyst complete snapshot requires exactly one field piece for geometry box %d" - % box_index) + % box_index + ) return rows[0] def _block_name(geometry: LevelGeometry) -> str: """Name one logical PDC block identically on every MPI rank.""" - return "layout_%s_level_%04d" % ( - geometry.layout_identity.hexdigest[:16], geometry.level) + return "layout_%s_level_%04d" % (geometry.layout_identity.hexdigest[:16], geometry.level) class CatalystPythonProvider: @@ -89,7 +95,8 @@ def _modules(self) -> tuple[Any, Any]: except (ImportError, ModuleNotFoundError) as error: raise RuntimeError( "Catalyst live visualization requires the optional catalyst Python module " - "built against the selected ParaView installation") from error + "built against the selected ParaView installation" + ) from error if conduit is None: errors = [] for module_name in ("catalyst_conduit", "conduit"): @@ -101,24 +108,31 @@ def _modules(self) -> tuple[Any, Any]: if conduit is None: raise RuntimeError( "Catalyst live visualization requires catalyst_conduit (ParaView builds) " - "or an external conduit Python module") from errors[-1] + "or an external conduit Python module" + ) from errors[-1] if not callable(getattr(conduit, "Node", None)): raise RuntimeError("Conduit Python module does not expose Node") missing = [ - name for name in ("initialize", "execute", "finalize", "about") + name + for name in ("initialize", "execute", "finalize", "about") if not callable(getattr(catalyst, name, None)) ] if missing: raise RuntimeError( "Catalyst Python module does not expose callable lifecycle methods: %s" - % ", ".join(missing)) + % ", ".join(missing) + ) return catalyst, conduit def open_session( - self, configuration: Mapping[str, Any], execution_context: Any, + self, + configuration: Mapping[str, Any], + execution_context: Any, ) -> _CatalystPythonSession: - if not isinstance(configuration, Mapping) \ - or configuration.get("observer_kind") != "catalyst": + if ( + not isinstance(configuration, Mapping) + or configuration.get("observer_kind") != "catalyst" + ): raise TypeError("Catalyst provider received an invalid observer configuration") pipeline = configuration.get("pipeline") if not isinstance(pipeline, str) or not pipeline: @@ -133,29 +147,41 @@ def open_session( if current_digest != expected_digest: raise RuntimeError("Catalyst pipeline changed after its declaration was authenticated") implementation = configuration.get("implementation") - if not isinstance(implementation, str) or not implementation \ - or implementation.strip() != implementation: + if ( + not isinstance(implementation, str) + or not implementation + or implementation.strip() != implementation + ): raise TypeError("Catalyst configuration requires a canonical implementation name") search_paths = configuration.get("search_paths") args = configuration.get("args") - if not isinstance(search_paths, (tuple, list)) \ - or any(not isinstance(value, str) or not value for value in search_paths): + if not isinstance(search_paths, (tuple, list)) or any( + not isinstance(value, str) or not value for value in search_paths + ): raise TypeError("Catalyst configuration search_paths must be a list of strings") - if not isinstance(args, (tuple, list)) \ - or any(not isinstance(value, str) or not value for value in args): + if not isinstance(args, (tuple, list)) or any( + not isinstance(value, str) or not value for value in args + ): raise TypeError("Catalyst configuration args must be a list of strings") inherited_async = os.environ.get("CATALYST_ASYNC_ENABLED") - if inherited_async is not None \ - and inherited_async.strip().lower() not in {"", "0", "false", "off", "no"}: + if inherited_async is not None and inherited_async.strip().lower() not in { + "", + "0", + "false", + "off", + "no", + }: raise RuntimeError( "PoPS owns the post-commit worker and requires Catalyst internal async to be " - "disabled; unset CATALYST_ASYNC_ENABLED or set it to 0") + "disabled; unset CATALYST_ASYNC_ENABLED or set it to 0" + ) prefer_environment = os.environ.get("CATALYST_IMPLEMENTATION_PREFER_ENV") if prefer_environment: raise RuntimeError( "PoPS authenticates catalyst_load/implementation and rejects " "CATALYST_IMPLEMENTATION_PREFER_ENV; unset it instead of overriding the " - "declaration") + "declaration" + ) communicator = getattr(execution_context, "communicator", None) communicator_id = getattr(communicator, "identity", None) worker_communicator = configuration.get("_pops_worker_communicator") @@ -167,15 +193,18 @@ def open_session( world = require_world(getattr(communicator, "handle", None)) lane = require_communicator(worker_communicator, allow_world=False) if int(world.rank) != int(lane.rank) or int(world.size) != int(lane.size): - raise ValueError( - "Catalyst worker lane topology differs from MPI_COMM_WORLD") + raise ValueError("Catalyst worker lane topology differs from MPI_COMM_WORLD") else: raise ValueError( "Catalyst requires either serial execution or an exact duplicated " - "MPI_COMM_WORLD observer lane") + "MPI_COMM_WORLD observer lane" + ) catalyst, conduit = self._modules() return _CatalystPythonSession( - catalyst, conduit, path, self._channel, + catalyst, + conduit, + path, + self._channel, pipeline_sha256=expected_digest, implementation=implementation, search_paths=tuple(search_paths), @@ -239,26 +268,44 @@ def _agree_local_phase(self, phase: str, error: BaseException | None) -> None: from pops._native_collectives import allgather_value, rank, size rendered = None if error is None else "%s: %s" % (type(error).__name__, error) - rows = allgather_value(self._worker_communicator, { - "rank": rank(self._worker_communicator), - "error": rendered, - }) - if len(rows) != size(self._worker_communicator) or any( + try: + owner = rank(self._worker_communicator) + peers = size(self._worker_communicator) + rows = allgather_value( + self._worker_communicator, + { + "rank": owner, + "error": rendered, + }, + ) + except BaseException as collective_error: + raise ObserverWorkerCollectiveLost( + "Catalyst %s lost its worker collective: %s: %s" + % (phase, type(collective_error).__name__, collective_error) + ) from collective_error + if ( + not isinstance(rows, (tuple, list)) + or len(rows) != peers + or any( not isinstance(row, dict) or set(row) != {"rank", "error"} or row["rank"] != owner or (row["error"] is not None and not isinstance(row["error"], str)) - for owner, row in enumerate(rows)): - raise RuntimeError( - "Catalyst %s returned malformed rank evidence" % phase) + for owner, row in enumerate(rows) + ) + ): + raise ObserverWorkerCollectiveLost( + "Catalyst %s returned malformed worker-lane evidence" % phase + ) failures = [ "rank %d: %s" % (owner, row["error"]) - for owner, row in enumerate(rows) if row["error"] is not None + for owner, row in enumerate(rows) + if row["error"] is not None ] if failures: collective = RuntimeError( - "Catalyst %s failed collectively: %s" - % (phase, "; ".join(failures))) + "Catalyst %s failed collectively: %s" % (phase, "; ".join(failures)) + ) if error is not None: raise collective from error raise collective @@ -270,25 +317,42 @@ def _agree_exact_value(self, phase: str, value: Mapping[str, Any]) -> None: return from pops._native_collectives import allgather_value, rank, size - rows = allgather_value(self._worker_communicator, { - "rank": rank(self._worker_communicator), - "value": dict(value), - }) - if len(rows) != size(self._worker_communicator) or any( + try: + owner = rank(self._worker_communicator) + peers = size(self._worker_communicator) + rows = allgather_value( + self._worker_communicator, + { + "rank": owner, + "value": dict(value), + }, + ) + except BaseException as collective_error: + raise ObserverWorkerCollectiveLost( + "Catalyst %s lost its worker collective: %s: %s" + % (phase, type(collective_error).__name__, collective_error) + ) from collective_error + if ( + not isinstance(rows, (tuple, list)) + or len(rows) != peers + or any( not isinstance(row, dict) or set(row) != {"rank", "value"} or row["rank"] != owner or not isinstance(row["value"], dict) - for owner, row in enumerate(rows)): - raise RuntimeError("Catalyst %s returned malformed rank evidence" % phase) + for owner, row in enumerate(rows) + ) + ): + raise ObserverWorkerCollectiveLost( + "Catalyst %s returned malformed worker-lane evidence" % phase + ) canonical = rows[0]["value"] - divergent = [ - owner for owner, row in enumerate(rows) if row["value"] != canonical - ] + divergent = [owner for owner, row in enumerate(rows) if row["value"] != canonical] if divergent: raise RuntimeError( "Catalyst %s differs across ranks: %s" - % (phase, ", ".join(str(owner) for owner in divergent))) + % (phase, ", ".join(str(owner) for owner in divergent)) + ) def initialize(self, run: ObserverRun) -> None: node = None @@ -296,10 +360,10 @@ def initialize(self, run: ObserverRun) -> None: try: if self._initialized or self._finalized: raise RuntimeError("Catalyst observer session cannot be initialized twice") - if hashlib.sha256(self._pipeline.read_bytes()).hexdigest() \ - != self._pipeline_sha256: + if hashlib.sha256(self._pipeline.read_bytes()).hexdigest() != self._pipeline_sha256: raise RuntimeError( - "Catalyst pipeline changed between session authentication and initialize") + "Catalyst pipeline changed between session authentication and initialize" + ) node = self._node() node["catalyst_load/implementation"] = self._implementation if self._search_paths: @@ -312,29 +376,29 @@ def initialize(self, run: ObserverRun) -> None: # Catalyst's environment default. node["catalyst/async/enabled"] = 0 if self._worker_communicator is not None: - node["catalyst/mpi_comm"] = int( - self._worker_communicator.fortran_handle) + node["catalyst/mpi_comm"] = int(self._worker_communicator.fortran_handle) node["catalyst/pops/run_identity"] = run.run_identity.token for index, identity in enumerate(run.recovery_run_identities): - node[ - "catalyst/pops/recovery_run_identities/%06d" % index - ] = identity.token + node["catalyst/pops/recovery_run_identities/%06d" % index] = identity.token except BaseException as error: local_error = error self._agree_local_phase("initialize", local_error) if node is None: # collective agreement cannot clear a local construction failure raise RuntimeError("Catalyst initialize lost its local node authority") - self._agree_exact_value("initialize authority", { - "args": list(self._args), - "channel": self._channel, - "implementation": self._implementation, - "pipeline_sha256": self._pipeline_sha256, - "recovery_run_identities": [ - identity.token for identity in run.recovery_run_identities - ], - "run_identity": run.run_identity.token, - "search_paths": list(self._search_paths), - }) + self._agree_exact_value( + "initialize authority", + { + "args": list(self._args), + "channel": self._channel, + "implementation": self._implementation, + "pipeline_sha256": self._pipeline_sha256, + "recovery_run_identities": [ + identity.token for identity in run.recovery_run_identities + ], + "run_identity": run.run_identity.token, + "search_paths": list(self._search_paths), + }, + ) # Catalyst may allocate process-global state and then raise. Mark entry before the call so # the queue's partial-initialize abort can still invoke finalize exactly once. self._initialize_entered = True @@ -354,7 +418,8 @@ def initialize(self, run: ObserverRun) -> None: if reported != self._implementation: raise RuntimeError( "Catalyst loaded implementation %r instead of requested %r" - % (reported, self._implementation)) + % (reported, self._implementation) + ) if not isinstance(version, str) or not version: raise RuntimeError("Catalyst about() returned no implementation version") implementation_evidence = { @@ -369,25 +434,28 @@ def initialize(self, run: ObserverRun) -> None: self._agree_local_phase("implementation authentication", about_error) if implementation_evidence is None: raise RuntimeError("Catalyst implementation authentication lost its evidence") - self._agree_exact_value( - "implementation evidence", implementation_evidence) + self._agree_exact_value("implementation evidence", implementation_evidence) self._implementation_evidence = implementation_evidence self._accepted_run_identities = frozenset(run.accepted_run_identities) self._initialized = True @staticmethod def _geometry_fields( - frame: ObserverFrame, geometry: LevelGeometry, + frame: ObserverFrame, + geometry: LevelGeometry, ) -> tuple[FieldPayload, ...]: selected = frame.snapshot.select(frame.request) fields = tuple( - field for field in selected - if (field.key.layout_identity.token, field.key.level) == geometry.key) + field + for field in selected + if (field.key.layout_identity.token, field.key.level) == geometry.key + ) if not fields: raise ValueError("Catalyst selected geometry has no field payload") if any(field.centering != "cell" for field in fields): raise NotImplementedError( - "Catalyst Python provider currently proves cell-centered fields only") + "Catalyst Python provider currently proves cell-centered fields only" + ) return fields def _add_domain( @@ -414,43 +482,53 @@ def _add_domain( root[base + "/coordsets/%s/dims/i" % coordset] = ihi - ilo + 1 root[base + "/coordsets/%s/dims/j" % coordset] = jhi - jlo + 1 root[base + "/coordsets/%s/origin/x" % coordset] = ( - geometry.origin[0] + ilo * geometry.spacing[0]) + geometry.origin[0] + ilo * geometry.spacing[0] + ) root[base + "/coordsets/%s/origin/y" % coordset] = ( - geometry.origin[1] + jlo * geometry.spacing[1]) + geometry.origin[1] + jlo * geometry.spacing[1] + ) root[base + "/coordsets/%s/spacing/dx" % coordset] = geometry.spacing[0] root[base + "/coordsets/%s/spacing/dy" % coordset] = geometry.spacing[1] root[base + "/topologies/%s/type" % topology] = "uniform" root[base + "/topologies/%s/coordset" % topology] = coordset elif geometry.coordinate_system == POLAR_ANNULUS_2D_COORDINATES: - radial = geometry.origin[0] + np.arange( - ilo, ihi + 1, dtype=np.float64) * geometry.spacing[0] - theta = geometry.origin[1] + np.arange( - jlo, jhi + 1, dtype=np.float64) * geometry.spacing[1] + radial = ( + geometry.origin[0] + np.arange(ilo, ihi + 1, dtype=np.float64) * geometry.spacing[0] + ) + theta = ( + geometry.origin[1] + np.arange(jlo, jhi + 1, dtype=np.float64) * geometry.spacing[1] + ) theta_grid, radial_grid = np.meshgrid(theta, radial, indexing="ij") root[base + "/coordsets/%s/type" % coordset] = "explicit" root[base + "/coordsets/%s/values/x" % coordset] = np.ascontiguousarray( - radial_grid * np.cos(theta_grid)).reshape(-1) + radial_grid * np.cos(theta_grid) + ).reshape(-1) root[base + "/coordsets/%s/values/y" % coordset] = np.ascontiguousarray( - radial_grid * np.sin(theta_grid)).reshape(-1) + radial_grid * np.sin(theta_grid) + ).reshape(-1) ni = ihi - ilo nj = jhi - jlo lower_left = np.arange(nj * ni, dtype=np.int64).reshape(nj, ni) lower_left += np.arange(nj, dtype=np.int64)[:, None] - connectivity = np.stack(( - lower_left, - lower_left + 1, - lower_left + ni + 2, - lower_left + ni + 1, - ), axis=-1) + connectivity = np.stack( + ( + lower_left, + lower_left + 1, + lower_left + ni + 2, + lower_left + ni + 1, + ), + axis=-1, + ) root[base + "/topologies/%s/type" % topology] = "unstructured" root[base + "/topologies/%s/coordset" % topology] = coordset root[base + "/topologies/%s/elements/shape" % topology] = "quad" - root[base + "/topologies/%s/elements/connectivity" % topology] = \ - np.ascontiguousarray(connectivity).reshape(-1) + root[base + "/topologies/%s/elements/connectivity" % topology] = np.ascontiguousarray( + connectivity + ).reshape(-1) else: raise NotImplementedError( - "Catalyst has no proved coordinate mapping for %s" - % geometry.coordinate_system) + "Catalyst has no proved coordinate mapping for %s" % geometry.coordinate_system + ) root[base + "/state/level"] = geometry.level root[base + "/state/cycle"] = frame.macro_step root[base + "/state/time"] = frame.physical_time @@ -463,8 +541,7 @@ def cell_field( component_names: tuple[str, ...] = (), ) -> str: nonlocal field_slot - internal_name = "array_%06d_partition_%06d" % ( - field_slot, partition_index) + internal_name = "array_%06d_partition_%06d" % (field_slot, partition_index) field_slot += 1 prefix = base + "/fields/" + internal_name root[prefix + "/association"] = "element" @@ -473,7 +550,8 @@ def cell_field( if len(component_names) > 1: for index, component in enumerate(component_names): root[prefix + "/values/" + component] = np.ascontiguousarray( - values[index]).reshape(-1) + values[index] + ).reshape(-1) else: root[prefix + "/values"] = np.ascontiguousarray(values).reshape(-1) return internal_name @@ -491,10 +569,7 @@ def cell_field( # VTK_REFINED_CELL=8; this hides covered coarse cells in ParaView without deleting their # scientific values from the live Blueprint domain. ghost_field = cell_field("vtkGhostType", coverage * np.uint8(8)) - root[ - base - + "/state/metadata/vtk_fields/%s/attribute_type" % ghost_field - ] = "Ghosts" + root[base + "/state/metadata/vtk_fields/%s/attribute_type" % ghost_field] = "Ghosts" cell_field("pops_cell_volume", geometry.cell_volumes[jlo:jhi, ilo:ihi]) names: set[str] = set() @@ -530,7 +605,8 @@ def _add_empty_domain( raise NotImplementedError("Catalyst Python provider currently proves rank-2 meshes") if geometry.coordinate_system != CARTESIAN_2D_COORDINATES: raise NotImplementedError( - "collective Catalyst zero-cell peers currently prove Cartesian 2D only") + "collective Catalyst zero-cell peers currently prove Cartesian 2D only" + ) base = "catalyst/channels/%s/data/%s" % (self._channel, domain_name) coordset = "coords_%06d" % box_index topology = "mesh_%06d" % box_index @@ -568,9 +644,7 @@ def empty_cell_field( empty_cell_field("pops_level", np.int32) empty_cell_field("pops_coverage", np.uint8) ghost_field = empty_cell_field("vtkGhostType", np.uint8) - root[ - base + "/state/metadata/vtk_fields/%s/attribute_type" % ghost_field - ] = "Ghosts" + root[base + "/state/metadata/vtk_fields/%s/attribute_type" % ghost_field] = "Ghosts" empty_cell_field("pops_cell_volume", np.float64) names: set[str] = set() @@ -594,20 +668,23 @@ def _prepare_execute_node(self, frame: ObserverFrame) -> Any: if self._execution_failed: raise RuntimeError("Catalyst observer session is poisoned after an execute failure") if frame.snapshot.provenance.run_identity not in self._accepted_run_identities: - raise ValueError( - "Catalyst frame is outside the active/recovery run authority") + raise ValueError("Catalyst frame is outside the active/recovery run authority") if self._worker_communicator is None: - if frame.request.parallel_mode is not ParallelMode.SERIAL \ - or frame.request.rank != 0 or frame.request.size != 1: + if ( + frame.request.parallel_mode is not ParallelMode.SERIAL + or frame.request.rank != 0 + or frame.request.size != 1 + ): raise ValueError("SERIAL Catalyst received a distributed frame") else: from pops._native_collectives import rank, size - if frame.request.parallel_mode is not ParallelMode.COLLECTIVE \ - or frame.request.rank != rank(self._worker_communicator) \ - or frame.request.size != size(self._worker_communicator): - raise ValueError( - "COLLECTIVE Catalyst requires its exact worker MPI lane topology") + if ( + frame.request.parallel_mode is not ParallelMode.COLLECTIVE + or frame.request.rank != rank(self._worker_communicator) + or frame.request.size != size(self._worker_communicator) + ): + raise ValueError("COLLECTIVE Catalyst requires its exact worker MPI lane topology") node = self._node() node["catalyst/state/timestep"] = frame.macro_step node["catalyst/state/time"] = frame.physical_time @@ -621,16 +698,13 @@ def _prepare_execute_node(self, frame: ObserverFrame) -> Any: families = _field_families(selected_fields) names = _field_display_names(families) display_names = { - family: name - for name, (family, _members) in zip(names, families, strict=True) + family: name for name, (family, _members) in zip(names, families, strict=True) } - geometry_keys = sorted({ - (field.key.layout_identity.token, field.key.level) - for field in selected_fields - }) + geometry_keys = sorted( + {(field.key.layout_identity.token, field.key.level) for field in selected_fields} + ) geometries = [ - geometry for geometry in frame.snapshot.geometries - if geometry.key in geometry_keys + geometry for geometry in frame.snapshot.geometries if geometry.key in geometry_keys ] if not geometries: raise ValueError("Catalyst frame has no selected geometry") @@ -638,14 +712,12 @@ def _prepare_execute_node(self, frame: ObserverFrame) -> Any: for layout_ordinal, geometry in enumerate(geometries): block_name = _block_name(geometry) fields = self._geometry_fields(frame, geometry) - local_boxes = { - piece.global_box_index for field in fields for piece in field.pieces - } + local_boxes = {piece.global_box_index for field in fields for piece in field.pieces} if any( - {piece.global_box_index for piece in field.pieces} != local_boxes - for field in fields): - raise ValueError( - "Catalyst fields disagree on the local geometry-box ownership set") + {piece.global_box_index for piece in field.pieces} != local_boxes + for field in fields + ): + raise ValueError("Catalyst fields disagree on the local geometry-box ownership set") for box_index in range(len(geometry.boxes)): # ParaView's multimesh protocol defines every data child as one Blueprint mesh. # A global AMR box is that indivisible block; its qualified name stays unique and @@ -654,15 +726,19 @@ def _prepare_execute_node(self, frame: ObserverFrame) -> Any: populated_blocks.append(domain_name) if box_index in local_boxes: self._add_domain( - node, frame, geometry, layout_ordinal, box_index, + node, + frame, + geometry, + layout_ordinal, + box_index, box_index, domain_name, - display_names) + display_names, + ) else: self._add_empty_domain( - node, frame, geometry, box_index, - domain_name, - display_names) + node, frame, geometry, box_index, domain_name, display_names + ) blueprint = getattr(self._conduit, "blueprint", None) mesh = getattr(blueprint, "mesh", None) @@ -670,12 +746,12 @@ def _prepare_execute_node(self, frame: ObserverFrame) -> Any: if callable(verify): for block_name in populated_blocks: info = self._node() - domain = node[ - "catalyst/channels/%s/data/%s" % (self._channel, block_name)] + domain = node["catalyst/channels/%s/data/%s" % (self._channel, block_name)] if verify(domain, info) is not True: raise ValueError( "Catalyst Conduit Blueprint verification failed for block %s: %s" - % (block_name, info)) + % (block_name, info) + ) return node def execute(self, frame: ObserverFrame) -> ObserverReceipt: @@ -742,8 +818,11 @@ def finalize(self) -> None: return None def abort(self) -> None: - if self._initialize_entered and not self._finalized \ - and not self._finalize_attempted: + if self._finalized: + return None + if self._finalize_attempted: + raise RuntimeError("Catalyst observer abort cannot retry failed finalization") + if self._initialize_entered: node = None local_error = None try: diff --git a/python/pops/output/_checkpoint_collective.py b/python/pops/output/_checkpoint_collective.py index 832524cee..39f2fd5ad 100644 --- a/python/pops/output/_checkpoint_collective.py +++ b/python/pops/output/_checkpoint_collective.py @@ -39,6 +39,15 @@ def distributed(self) -> bool: return self.communicator is not None +@dataclass(frozen=True, slots=True) +class RootAttempt: + """One root producer outcome with transport failure kept as a separate state.""" + + value: Any = None + producer_error: BaseException | None = None + transport_error: BaseException | None = None + + class InMemoryCheckpoint(Mapping[str, Any]): """Closed, object-free NPZ payload used by every restart rank. @@ -236,6 +245,63 @@ def root_value( return envelope["value"] +def root_attempt( + topology: CheckpointTopology, + phase: str, + producer: Callable[[], Any], +) -> RootAttempt: + """Run one root producer without conflating its failure with broadcast transport. + + Callers that own rank-zero filesystem state can safely decide whether another collective is + legal: a producer failure means the first transport completed, while ``transport_error`` means + only rank zero may perform local compensation. + """ + if not isinstance(phase, str) or not phase: + raise TypeError("checkpoint phase must be non-empty text") + if not callable(producer): + raise TypeError("checkpoint root producer must be callable") + envelope = None + local_error = None + if topology.rank == 0: + try: + envelope = {"value": producer(), "error": None} + except BaseException as error: + local_error = error + envelope = { + "value": None, + "error": None if not topology.distributed else _error_record(error), + } + if not topology.distributed: + if local_error is not None: + return RootAttempt(producer_error=local_error) + try: + encode_value(envelope) + except BaseException as error: + return RootAttempt(transport_error=error) + else: + try: + envelope = broadcast_value(topology.communicator, envelope, root=0) + except BaseException as error: + return RootAttempt(producer_error=local_error, transport_error=error) + try: + if not isinstance(envelope, Mapping) or set(envelope) != {"value", "error"}: + raise RuntimeError("checkpoint %s broadcast returned an invalid envelope" % phase) + if envelope["error"] is not None: + record = _validated_error_record(envelope["error"], phase=phase) + try: + _raise_collective_failure(phase, ((0, record),)) + except BaseException as error: + return RootAttempt( + producer_error=( + local_error if topology.rank == 0 and local_error is not None else error + ) + ) + raise AssertionError("checkpoint producer failure reconstruction returned") + except BaseException as error: + return RootAttempt(transport_error=error) + return RootAttempt(value=envelope["value"]) + + def root_effect( topology: CheckpointTopology, phase: str, @@ -705,6 +771,7 @@ def restore_checkpoint_path( __all__ = [ "CheckpointTopology", "InMemoryCheckpoint", + "RootAttempt", "canonical_checkpoint_path", "checkpoint_topology", "collective_checkpoint_capture", @@ -716,5 +783,6 @@ def restore_checkpoint_path( "restore_checkpoint_payload", "root_effect", "root_bytes", + "root_attempt", "root_value", ] diff --git a/python/pops/output/_consumer_authoring.py b/python/pops/output/_consumer_authoring.py index 308dd0893..cd17b68a6 100644 --- a/python/pops/output/_consumer_authoring.py +++ b/python/pops/output/_consumer_authoring.py @@ -20,6 +20,7 @@ ParallelMode, _FAILURE_ACTIONS, _console_provider_data, + _is_async_scientific_observer, _observer_provider_data, ) @@ -120,6 +121,10 @@ def __post_init__(self) -> None: if operation_data["parallel_mode"] != self.parallel_mode.value: raise ValueError( "Monitor authoring parallel mode differs from its operation provider") + if rows and not _is_async_scientific_observer(operation_data): + raise ValueError( + "only AsyncScientificOutput monitor nodes can embed diagnostic providers" + ) elif self.kind is ConsumerKind.DIAGNOSTIC: if self.output_format is not None or self.operation is None: raise ValueError("Diagnostic authoring requires only its console provider") @@ -350,7 +355,10 @@ def resolve(self, resolver: Any, layout_plan: Any, *, owner: Any) -> ConsumerMan async_format.get("selection_contract") if isinstance(async_format, dict) else None ) - selected_layouts = {quantity.layout_id for quantity in quantities} + selected_layouts = { + quantity.layout_id + for quantity in (*quantities, *diagnostic_quantities) + } if isinstance(async_format, dict) and selection_contract is not None \ and selection_contract["layout_cardinality"] == "single" \ and len(selected_layouts) > 1: diff --git a/python/pops/output/_consumer_contracts.py b/python/pops/output/_consumer_contracts.py index cfe4260a5..733dfcb33 100644 --- a/python/pops/output/_consumer_contracts.py +++ b/python/pops/output/_consumer_contracts.py @@ -55,6 +55,29 @@ def _nonnegative_binary64_hex(value: Any, where: str) -> str: return number.hex() +def _finite_binary64_hex(value: Any, where: str) -> str: + """Normalize a signed finite binary64 value for identity-bearing manifests.""" + if isinstance(value, bool): + raise TypeError("%s must be a finite number" % where) + if isinstance(value, str): + try: + number = float.fromhex(value) + except (OverflowError, ValueError) as exc: + raise TypeError("%s must be a canonical float.hex() string" % where) from exc + if number.hex() != value: + raise ValueError("%s must be a canonical float.hex() string" % where) + elif isinstance(value, (int, float)): + try: + number = float(value) + except OverflowError as exc: + raise ValueError("%s must be a finite number" % where) from exc + else: + raise TypeError("%s must be a finite number" % where) + if not math.isfinite(number): + raise ValueError("%s must be a finite number" % where) + return number.hex() + + def _exact_handle(value: Any, kind: str | None, where: str) -> Handle: if not isinstance(value, Handle) or not value.is_resolved: raise TypeError("%s must be a canonical Handle" % where) @@ -136,6 +159,19 @@ def _observer_provider_data(value: Any, *, where: str) -> Mapping[str, Any]: return freeze_data(first, "%s.consumer_data" % where) +def _is_async_scientific_observer(operation_data: Any) -> bool: + """Authenticate the one monitor provider allowed to carry scientific diagnostics.""" + if not isinstance(operation_data, Mapping): + return False + observer = operation_data.get("observer") + return ( + isinstance(observer, Mapping) + and observer.get("observer_kind") == "async_scientific_output" + and observer.get("provider_id") + == "pops.output.async-scientific-writer.v1" + ) + + def _console_provider_data(value: Any, *, where: str) -> Mapping[str, Any]: """Authenticate the Python-only renderer of a rank-zero diagnostic consumer.""" if getattr(value, "__pops_ir_immutable__", False) is not True: @@ -291,6 +327,7 @@ def to_data(self) -> dict[str, Any]: _DIAGNOSTIC_REDUCTIONS = frozenset({ "sum", "abs_sum", "sum_sq", "min", "max", "abs_max", "step_change_l2", + "accepted_balance", }) _DIAGNOSTIC_TRANSFORMS = frozenset({"identity", "sqrt"}) _DIAGNOSTIC_COLLECTIVES = { @@ -301,6 +338,9 @@ def to_data(self) -> dict[str, Any]: "max": "global_max", "abs_max": "global_max", "step_change_l2": "global_sum", + # The five Program scalars were already reduced while executing the native + # accepted attempt. Reading its mailbox adds no second consumer collective. + "accepted_balance": None, } @@ -309,8 +349,8 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: if not isinstance(value, Mapping) or set(value) != { "schema_version", "role", "operations", "conservation"}: raise TypeError("DiagnosticQuantity.execution has an unknown schema") - if value["schema_version"] != 1: - raise ValueError("DiagnosticQuantity.execution schema_version must be 1") + if value["schema_version"] != 2: + raise ValueError("DiagnosticQuantity.execution schema_version must be 2") role = value["role"] if role is not None: _text(role, "DiagnosticQuantity.execution.role") @@ -320,11 +360,24 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: normalized = [] for index, operation in enumerate(operations): where = "DiagnosticQuantity.execution.operations[%d]" % index - if not isinstance(operation, Mapping) or set(operation) != { - "name", "reduction", "transform", "metric_weighted"}: + if not isinstance(operation, Mapping): + raise TypeError("%s has an unknown schema" % where) + reduction = operation.get("reduction") + expected = { + "name", + "reduction", + "transform", + "metric_weighted", + "coefficient", + } + if reduction == "accepted_balance": + expected.add("balance_route") + if "automatic_terms" in operation: + expected.add("automatic_terms") + expected.add("balance_component") + if set(operation) != expected: raise TypeError("%s has an unknown schema" % where) name = _text(operation["name"], "%s.name" % where) - reduction = operation["reduction"] if reduction not in _DIAGNOSTIC_REDUCTIONS: raise ValueError("%s.reduction is not a supported native reduction" % where) transform = operation["transform"] @@ -335,17 +388,67 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: raise TypeError("%s.metric_weighted must be an exact bool" % where) if weighted and reduction not in {"sum", "abs_sum", "sum_sq"}: raise ValueError("only additive diagnostic reductions may be metric-weighted") - normalized.append({ + coefficient = _finite_binary64_hex( + operation["coefficient"], "%s.coefficient" % where) + if float.fromhex(coefficient) == 0.0: + raise ValueError("%s.coefficient must be nonzero" % where) + row = { "name": name, "reduction": reduction, "transform": transform, "metric_weighted": weighted, - }) + "coefficient": coefficient, + } + if reduction == "accepted_balance": + if transform != "identity" or weighted or float.fromhex(coefficient) != 1.0: + raise ValueError( + "accepted balance evidence cannot apply a scalar transform, metric weight, " + "or coefficient" + ) + route = Identity.from_token(operation["balance_route"]) + if route.domain != "balance-ledger-route" or route.schema_version != 1: + raise ValueError( + "accepted balance route must use the version-1 balance-ledger-route identity" + ) + row["balance_route"] = route.token + automatic_terms = operation.get("automatic_terms", ()) + if not isinstance(automatic_terms, (tuple, list)): + raise TypeError("%s.automatic_terms must be a sequence" % where) + automatic_terms = tuple(automatic_terms) + if automatic_terms != tuple(sorted(set(automatic_terms))): + raise ValueError( + "%s.automatic_terms must be sorted and unique" % where + ) + unsupported = set(automatic_terms).difference({"reflux", "projection"}) + if unsupported: + raise ValueError( + "%s.automatic_terms names an unavailable native producer" % where + ) + if automatic_terms: + row["automatic_terms"] = list(automatic_terms) + component = operation["balance_component"] + if type(component) is not int or component < 0: + raise TypeError( + "%s.balance_component must be a non-negative int" % where + ) + row["balance_component"] = component + normalized.append(row) if len({row["name"] for row in normalized}) != len(normalized): raise ValueError("DiagnosticQuantity execution operation names must be unique") + has_accepted_balance = any( + row["reduction"] == "accepted_balance" for row in normalized + ) + if has_accepted_balance and len(normalized) != 1: + raise ValueError( + "accepted balance evidence must be the sole diagnostic execution operation" + ) conservation = value["conservation"] normalized_conservation = None if conservation is not None: + if has_accepted_balance: + raise ValueError( + "accepted open-domain balance evidence cannot declare an invariant tolerance" + ) if not isinstance(conservation, Mapping) or set(conservation) != {"tolerance"}: raise TypeError("DiagnosticQuantity.execution.conservation has an unknown schema") tolerance = _nonnegative_binary64_hex( @@ -354,7 +457,7 @@ def _diagnostic_execution(value: Any) -> Mapping[str, Any]: raise ValueError("a conservation check requires exactly one scalar operation") normalized_conservation = {"tolerance": tolerance} return freeze_data({ - "schema_version": 1, + "schema_version": 2, "role": role, "operations": normalized, "conservation": normalized_conservation, @@ -367,6 +470,7 @@ def diagnostic_collective_operations(execution: Any) -> tuple[str, ...]: return tuple(sorted({ _DIAGNOSTIC_COLLECTIVES[operation["reduction"]] for operation in canonical["operations"] + if _DIAGNOSTIC_COLLECTIVES[operation["reduction"]] is not None })) @@ -519,10 +623,16 @@ def __post_init__(self) -> None: "descriptor": first, "references": [value.canonical_identity() for value in resolved_references], }, "%s.consumer_data" % where)) + async_scientific_monitor = ( + self.kind is ConsumerKind.MONITOR + and _is_async_scientific_observer(operation_data) + ) if diagnostic_rows and self.kind not in { - ConsumerKind.DIAGNOSTIC, ConsumerKind.SCIENTIFIC_OUTPUT}: + ConsumerKind.DIAGNOSTIC, ConsumerKind.SCIENTIFIC_OUTPUT + } and not async_scientific_monitor: raise ValueError( - "only ConsoleMonitor or ScientificOutput can embed diagnostic providers") + "only ConsoleMonitor, ScientificOutput, or AsyncScientificOutput " + "can embed diagnostic providers") object.__setattr__(self, "diagnostics_data", tuple(diagnostic_rows)) if not isinstance(self.diagnostic_quantities, tuple) or any( type(value) is not DiagnosticQuantity @@ -539,9 +649,21 @@ def __post_init__(self) -> None: raise ValueError( "ConsumerManifest must lower every diagnostic descriptor exactly once") if diagnostic_quantities and self.kind not in { - ConsumerKind.DIAGNOSTIC, ConsumerKind.SCIENTIFIC_OUTPUT}: + ConsumerKind.DIAGNOSTIC, ConsumerKind.SCIENTIFIC_OUTPUT + } and not async_scientific_monitor: + raise ValueError( + "only ConsoleMonitor, ScientificOutput, or AsyncScientificOutput " + "can carry diagnostic quantities") + has_accepted_balance = any( + operation["reduction"] == "accepted_balance" + for quantity in diagnostic_quantities + for operation in quantity.execution["operations"] + ) + if has_accepted_balance and self.schedule.consumer_may_fire_at_start(): raise ValueError( - "only ConsoleMonitor or ScientificOutput can carry diagnostic quantities") + "Balance schedule cannot fire at_start: accepted balance evidence exists " + "only after a native step attempt" + ) object.__setattr__(self, "diagnostic_quantities", diagnostic_quantities) if not isinstance(self.dependencies, tuple): raise TypeError("ConsumerManifest.dependencies must be a tuple") diff --git a/python/pops/output/_restart_provider.py b/python/pops/output/_restart_provider.py index 5519ca821..ee262d165 100644 --- a/python/pops/output/_restart_provider.py +++ b/python/pops/output/_restart_provider.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -import tempfile +import stat from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -16,6 +16,514 @@ ) +def _owner(value: os.stat_result) -> tuple[int, int]: + return int(value.st_dev), int(value.st_ino) + + +def _validate_owner(value: Any, *, where: str) -> tuple[int, int]: + if ( + type(value) not in {list, tuple} + or len(value) != 2 + or any(type(item) is not int or item < 0 for item in value) + ): + raise TypeError("%s must be exact opaque inode evidence" % where) + return int(value[0]), int(value[1]) + + +def _raise_cleanup_failures(message: str, failures: list[BaseException]) -> None: + if failures: + raise RuntimeError( + message + + ": " + + "; ".join("%s: %s" % (type(error).__name__, error) for error in failures) + ) + + +class _CheckpointTransportFailure(RuntimeError): + """A broken control transport after which no second collective is legal.""" + + +class _CheckpointEntryAuthority: + """One exact directory entry plus the retained descriptor of its inode on rank zero.""" + + __slots__ = ("name", "owner", "_descriptor") + + def __init__(self, name: str, owner: tuple[int, int], descriptor: int | None) -> None: + if not isinstance(name, str) or not name or "/" in name or "\x00" in name: + raise ValueError("checkpoint entry authority requires one local name") + self.name = name + self.owner = _validate_owner(owner, where="checkpoint entry owner") + self._descriptor = descriptor + if descriptor is not None: + retained = os.fstat(descriptor) + if not stat.S_ISREG(retained.st_mode) or _owner(retained) != self.owner: + raise RuntimeError("checkpoint entry descriptor differs from its inode authority") + + @property + def is_open(self) -> bool: + return self._descriptor is not None + + def fileno(self) -> int: + if self._descriptor is None: + raise RuntimeError("this checkpoint peer has no rank-zero entry descriptor") + return self._descriptor + + def duplicate(self) -> int: + return os.dup(self.fileno()) + + def transfer(self, name: str) -> _CheckpointEntryAuthority: + descriptor = self.fileno() + transferred = _CheckpointEntryAuthority(name, self.owner, descriptor) + self._descriptor = None + return transferred + + def close(self) -> None: + descriptor = self._descriptor + if descriptor is None: + return + self._descriptor = None + os.close(descriptor) + + +class _CheckpointTransactionReceipt: + """Private mkdirat/openat namespace retained from capture through publication.""" + + __slots__ = ( + "parent", + "directory_name", + "owner", + "_directory_fd", + "_parent_fd", + "_native_entry", + ) + + _NATIVE_NAME = "native.npz" + + def __init__( + self, + parent: Any, + directory_name: str, + owner: tuple[int, int], + directory_fd: int | None, + parent_fd: int | None, + native_entry: _CheckpointEntryAuthority, + ) -> None: + if ( + not isinstance(directory_name, str) + or not directory_name.startswith(".pops-restart-transaction.") + or "/" in directory_name + ): + raise ValueError("checkpoint transaction requires one private directory name") + if (directory_fd is None) != (parent_fd is None): + raise ValueError("checkpoint transaction descriptors must be retained together") + self.parent = Path(parent) + self.directory_name = directory_name + self.owner = _validate_owner(owner, where="checkpoint transaction owner") + self._directory_fd = directory_fd + self._parent_fd = parent_fd + self._native_entry = native_entry + if directory_fd is not None: + self.authenticate_directory_at() + + @staticmethod + def _directory_flags() -> int: + return os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + + @property + def directory(self) -> Path: + return self.parent / self.directory_name + + @property + def staging_path(self) -> Path: + return self.directory / self._NATIVE_NAME + + @property + def has_root_descriptor(self) -> bool: + return self._directory_fd is not None + + @classmethod + def created(cls, parent: Path) -> _CheckpointTransactionReceipt: + parent.mkdir(parents=True, exist_ok=True) + parent_fd = os.open(parent, cls._directory_flags()) + directory_name = "" + directory_fd: int | None = None + transaction: _CheckpointTransactionReceipt | None = None + try: + for _attempt in range(32): + candidate = ".pops-restart-transaction.%s" % os.urandom(16).hex() + try: + os.mkdir(candidate, 0o700, dir_fd=parent_fd) + except FileExistsError: + continue + directory_name = candidate + break + if not directory_name: + raise RuntimeError("checkpoint could not allocate a private transaction directory") + directory_fd = os.open(directory_name, cls._directory_flags(), dir_fd=parent_fd) + transaction = cls( + parent, + directory_name, + _owner(os.fstat(directory_fd)), + directory_fd, + parent_fd, + _CheckpointEntryAuthority(cls._NATIVE_NAME, (0, 0), None), + ) + transaction._native_entry = transaction.created_at(cls._NATIVE_NAME) + return transaction + except BaseException as error: + failures = [] + if transaction is not None: + try: + transaction.cleanup_owned() + except BaseException as cleanup_error: + failures.append(cleanup_error) + else: + if directory_fd is not None: + try: + os.close(directory_fd) + except BaseException as cleanup_error: + failures.append(cleanup_error) + try: + os.close(parent_fd) + except BaseException as cleanup_error: + failures.append(cleanup_error) + if failures: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note( + "checkpoint transaction construction cleanup also failed: " + + "; ".join(str(item) for item in failures) + ) + raise + + @classmethod + def observed(cls, data: Any) -> _CheckpointTransactionReceipt: + if not isinstance(data, dict) or set(data) != { + "parent", + "directory_name", + "directory_owner", + "staging_name", + "staging_owner", + }: + raise RuntimeError("rank zero returned invalid checkpoint transaction evidence") + if data["staging_name"] != cls._NATIVE_NAME: + raise RuntimeError("rank zero returned a different native staging name") + # Device/inode values are opaque transport scalars on peers; they are never compared with + # a rank-local mount. + native = _CheckpointEntryAuthority( + data["staging_name"], + _validate_owner(data["staging_owner"], where="native staging evidence"), + None, + ) + return cls( + data["parent"], + data["directory_name"], + _validate_owner(data["directory_owner"], where="transaction evidence"), + None, + None, + native, + ) + + def to_data(self) -> dict[str, Any]: + if self.has_root_descriptor: + self.authenticate_directory_at() + if self._native_entry.is_open: + self.authenticate_entry_at(self._native_entry) + return { + "parent": str(self.parent), + "directory_name": self.directory_name, + "directory_owner": list(self.owner), + "staging_name": self._native_entry.name, + "staging_owner": list(self._native_entry.owner), + } + + def directory_fileno(self) -> int: + if self._directory_fd is None: + raise RuntimeError("rank zero lacks the checkpoint transaction directory descriptor") + return self._directory_fd + + def authenticate_directory_at(self) -> None: + directory_fd = self.directory_fileno() + if self._parent_fd is None: + raise RuntimeError("checkpoint transaction parent descriptor is unavailable") + retained = os.fstat(directory_fd) + named = os.stat(self.directory_name, dir_fd=self._parent_fd, follow_symlinks=False) + parent = os.fstat(self._parent_fd) + if ( + not stat.S_ISDIR(retained.st_mode) + or stat.S_IMODE(retained.st_mode) & 0o077 + or _owner(retained) != self.owner + or _owner(named) != self.owner + or int(retained.st_dev) != int(parent.st_dev) + ): + raise RuntimeError("checkpoint private transaction directory authority changed") + + def created_at(self, name: str) -> _CheckpointEntryAuthority: + self.authenticate_directory_at() + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(name, flags, 0o600, dir_fd=self.directory_fileno()) + try: + return _CheckpointEntryAuthority(name, _owner(os.fstat(descriptor)), descriptor) + except BaseException: + os.close(descriptor) + raise + + def create_unique_at(self, *, suffix: str) -> _CheckpointEntryAuthority: + for _attempt in range(32): + name = ".native.npz.%s%s" % (os.urandom(12).hex(), suffix) + try: + return self.created_at(name) + except FileExistsError: + continue + raise RuntimeError("checkpoint could not allocate a unique transaction staging entry") + + def open_candidate_at(self, name: str) -> _CheckpointEntryAuthority: + self.authenticate_directory_at() + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(name, flags, dir_fd=self.directory_fileno()) + try: + return _CheckpointEntryAuthority(name, _owner(os.fstat(descriptor)), descriptor) + except BaseException: + os.close(descriptor) + raise + + def authenticate_entry_at(self, entry: _CheckpointEntryAuthority) -> None: + self.authenticate_directory_at() + retained = os.fstat(entry.fileno()) + named = os.stat(entry.name, dir_fd=self.directory_fileno(), follow_symlinks=False) + if ( + not stat.S_ISREG(retained.st_mode) + or not stat.S_ISREG(named.st_mode) + or _owner(retained) != entry.owner + or _owner(named) != entry.owner + ): + raise RuntimeError( + "checkpoint transaction entry was replaced before ownership acquisition" + ) + + def rename_no_replace_at( + self, source: _CheckpointEntryAuthority, destination_name: str + ) -> _CheckpointEntryAuthority: + self.authenticate_entry_at(source) + from ._writers.common import _rename_no_replace + + _rename_no_replace( + source.name, + destination_name, + src_dir_fd=self.directory_fileno(), + dst_dir_fd=self.directory_fileno(), + ) + # Keep the same object/fd in the caller's cleanup ledger until post-rename + # authentication succeeds. If that check fails, cleanup still knows the new entry name. + source.name = destination_name + self.authenticate_entry_at(source) + return source + + def quarantine_entry_at( + self, + entry: _CheckpointEntryAuthority, + *, + phase: str, + close_entry: bool = True, + ) -> None: + from ._writers.common import _StagedOutputFile + + try: + _StagedOutputFile._quarantine_owned_path( + self.directory / entry.name, + entry.owner, + replaced_message=( + "checkpoint %s refuses to delete replaced transaction entry %s" + % (phase, entry.name) + ), + directory_fd=self.directory_fileno(), + ) + finally: + if close_entry: + entry.close() + + def take_native_entry(self) -> _CheckpointEntryAuthority: + entry = self._native_entry + self._native_entry = _CheckpointEntryAuthority(self._NATIVE_NAME, entry.owner, None) + return entry + + def cleanup_empty(self) -> None: + directory_fd = self._directory_fd + parent_fd = self._parent_fd + if directory_fd is None: + return + cleanup_name = ".pops-restart-cleanup-%s" % os.urandom(16).hex() + moved = False + primary = None + try: + self.authenticate_directory_at() + if os.listdir(directory_fd): + raise RuntimeError( + "checkpoint private transaction directory is not empty; retained as %s" + % self.directory_name + ) + if parent_fd is None: + raise RuntimeError("checkpoint transaction parent descriptor is unavailable") + from ._writers.common import _rename_no_replace + + _rename_no_replace( + self.directory_name, + cleanup_name, + src_dir_fd=parent_fd, + dst_dir_fd=parent_fd, + ) + moved = True + detached = os.stat(cleanup_name, dir_fd=parent_fd, follow_symlinks=False) + if _owner(detached) != self.owner: + try: + _rename_no_replace( + cleanup_name, + self.directory_name, + src_dir_fd=parent_fd, + dst_dir_fd=parent_fd, + ) + except BaseException as restore_error: + raise RuntimeError( + "checkpoint transaction directory was substituted; replacement " + "retained as %s because restoration failed" % cleanup_name + ) from restore_error + else: + moved = False + raise RuntimeError("checkpoint transaction directory was substituted and restored") + os.rmdir(cleanup_name, dir_fd=parent_fd) + moved = False + except BaseException as error: + primary = error + if moved: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("checkpoint transaction retained as %s" % cleanup_name) + raise + finally: + failures = self.close_descriptors() + if failures: + message = "checkpoint transaction descriptor cleanup also failed: " + "; ".join( + str(error) for error in failures + ) + if primary is not None: + add_note = getattr(primary, "add_note", None) + if callable(add_note): + add_note(message) + else: + raise RuntimeError(message) + + def close_descriptors(self) -> list[BaseException]: + failures = [] + for attribute in ("_directory_fd", "_parent_fd"): + descriptor = getattr(self, attribute) + if descriptor is None: + continue + setattr(self, attribute, None) + try: + os.close(descriptor) + except BaseException as error: + failures.append(error) + return failures + + def close(self) -> None: + failures = [] + native = self._native_entry + self._native_entry = _CheckpointEntryAuthority(self._NATIVE_NAME, native.owner, None) + if native.is_open: + try: + native.close() + except BaseException as error: + failures.append(error) + failures.extend(self.close_descriptors()) + _raise_cleanup_failures( + "checkpoint transaction descriptor cleanup failed", + failures, + ) + + def cleanup_owned(self) -> None: + failures = [] + native = self._native_entry + self._native_entry = _CheckpointEntryAuthority(self._NATIVE_NAME, native.owner, None) + if native.is_open: + try: + self.quarantine_entry_at(native, phase="transaction construction cleanup") + except BaseException as error: + failures.append(error) + try: + self.cleanup_empty() + except BaseException as error: + failures.append(error) + _raise_cleanup_failures("checkpoint transaction cleanup failed", failures) + + +class _CheckpointPayloadProof: + """Exact resealed inode handoff; only rank zero retains its open descriptor.""" + + __slots__ = ("transaction", "entry") + + def __init__( + self, + transaction: _CheckpointTransactionReceipt, + entry: _CheckpointEntryAuthority, + ) -> None: + self.transaction = transaction + self.entry = entry + if transaction.has_root_descriptor: + transaction.authenticate_entry_at(entry) + + @property + def path(self) -> Path: + return self.transaction.directory / self.entry.name + + @property + def owner(self) -> tuple[int, int]: + return self.entry.owner + + def to_data(self) -> dict[str, Any]: + if self.transaction.has_root_descriptor: + # The collective handoff is evidence for this still-open inode, not for whichever + # object a later lexical lookup might find under the same name. + self.transaction.authenticate_entry_at(self.entry) + return { + "path": str(self.path), + "entry_name": self.entry.name, + "entry_owner": list(self.entry.owner), + "directory_name": self.transaction.directory_name, + "directory_owner": list(self.transaction.owner), + } + + @classmethod + def observed( + cls, transaction: _CheckpointTransactionReceipt, data: Any + ) -> _CheckpointPayloadProof: + if not isinstance(data, dict) or set(data) != { + "path", + "entry_name", + "entry_owner", + "directory_name", + "directory_owner", + }: + raise RuntimeError("rank zero returned invalid checkpoint payload proof") + if ( + data["path"] != str(transaction.directory / data["entry_name"]) + or data["directory_name"] != transaction.directory_name + or _validate_owner(data["directory_owner"], where="payload proof transaction owner") + != transaction.owner + ): + raise RuntimeError("checkpoint payload proof differs from its transaction receipt") + return cls( + transaction, + _CheckpointEntryAuthority( + data["entry_name"], + _validate_owner(data["entry_owner"], where="payload proof entry owner"), + None, + ), + ) + + def close(self) -> None: + self.entry.close() + + def _recorded_hierarchy() -> Any: from .restart import RestoreRecordedHierarchy @@ -30,226 +538,489 @@ class ReopenedRestart: class _RestartSnapshot: - """One collectively captured file whose publication is still compensatable.""" + """One exact resealed-fd handoff whose publication remains compensatable.""" __slots__ = ( "_runtime", "_topology", - "_staging", - "_staging_inode", + "_proof", + "_staging_owned", "_published_target", - "_published_inode", + "_published_entry", + "_published_parent_fd", "_discarded", ) - @staticmethod - def _inode(path: Path) -> tuple[int, int]: - status = path.stat(follow_symlinks=False) - return int(status.st_dev), int(status.st_ino) - - @classmethod - def _unlink_owned( - cls, - path: Path, - inode: tuple[int, int], - *, - phase: str, - ) -> None: - try: - current = cls._inode(path) - except FileNotFoundError: - return - if current != inode: - raise RuntimeError("checkpoint %s refuses to delete replaced path %s" % (phase, path)) - path.unlink() - def __init__(self, runtime: Any, directory: Any) -> None: + from ._checkpoint_collective import root_attempt + self._runtime = runtime self._topology = checkpoint_topology(runtime) + self._proof: _CheckpointPayloadProof | None = None + self._staging_owned = False + self._published_target: Path | None = None + self._published_entry: _CheckpointEntryAuthority | None = None + self._published_parent_fd: int | None = None + self._discarded = False local_directory = Path(os.path.abspath(os.path.normpath(os.fspath(directory)))) + created_transaction: _CheckpointTransactionReceipt | None = None + + def choose_transaction() -> dict[str, Any]: + nonlocal created_transaction + created_transaction = _CheckpointTransactionReceipt.created(local_directory) + return created_transaction.to_data() - def choose_staging() -> dict[str, str]: - local_directory.mkdir(parents=True, exist_ok=True) - fd, name = tempfile.mkstemp( - prefix=".pops-restart-snapshot.", suffix=".npz", dir=local_directory + attempt = root_attempt(self._topology, "staging selection", choose_transaction) + if attempt.transport_error is not None: + error = _CheckpointTransportFailure( + "checkpoint transport failed during staging selection: %s" % attempt.transport_error ) - os.close(fd) - os.unlink(name) - return {"directory": str(local_directory), "staging": name} + if attempt.producer_error is not None: + error.add_note("rank-zero producer also failed: %s" % attempt.producer_error) + if self._topology.rank == 0 and created_transaction is not None: + try: + created_transaction.cleanup_owned() + except BaseException as cleanup_error: + error.add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise error from attempt.transport_error + if attempt.producer_error is not None: + if self._topology.rank == 0 and created_transaction is not None: + try: + created_transaction.cleanup_owned() + except BaseException as cleanup_error: + add_note = getattr(attempt.producer_error, "add_note", None) + if callable(add_note): + add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise attempt.producer_error - selected = root_value(self._topology, "staging selection", choose_staging) selection_error = None + transaction = None try: - if not isinstance(selected, dict) or set(selected) != {"directory", "staging"}: - raise RuntimeError("rank zero returned an invalid checkpoint staging selection") - if str(local_directory) != selected["directory"]: + if self._topology.rank == 0: + transaction = created_transaction + if transaction is None or transaction.to_data() != attempt.value: + raise RuntimeError( + "rank-zero transaction receipt differs from its collective evidence" + ) + else: + transaction = _CheckpointTransactionReceipt.observed(attempt.value) + if transaction.parent != local_directory: raise ValueError( "checkpoint staging directory differs across ranks: local %s, rank-0 %s" - % (local_directory, selected["directory"]) + % (local_directory, transaction.parent) ) - staging = canonical_checkpoint_path(selected["staging"]) - if staging.parent != local_directory: - raise ValueError("checkpoint staging path escaped its authenticated directory") except BaseException as error: selection_error = error - staging = ( - Path(selected.get("staging", ".invalid-checkpoint.npz")) - if isinstance(selected, dict) - else Path(".invalid-checkpoint.npz") - ) - consensus(self._topology, "staging agreement", error=selection_error) - self._staging = staging - self._staging_inode: tuple[int, int] | None = None - self._published_target: Path | None = None - self._published_inode: tuple[int, int] | None = None - self._discarded = False + try: + consensus(self._topology, "staging agreement", error=selection_error) + except BaseException as error: + if self._topology.rank == 0 and created_transaction is not None: + try: + created_transaction.cleanup_owned() + except BaseException as cleanup_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise + if transaction is None: + raise RuntimeError("checkpoint staging selection returned no transaction receipt") - # Every rank enters the exact native capture with the same staging path. The RuntimeInstance - # performs a consensus after native collection and after rank-zero envelope sealing. try: - produced = Path(runtime._checkpoint_payload(self._staging)) - except BaseException: - # Capture providers are required to publish their private staging path only after a - # complete sealed payload exists. On failure there is therefore no owned final inode - # to remove here. Blindly unlinking the lexical name would risk deleting a concurrent - # replacement for which this transaction has no ownership proof. + proof = runtime._checkpoint_payload( + transaction.staging_path, + transaction_receipt=transaction, + ) + except _CheckpointTransportFailure: self._discarded = True raise - exact_error = None - if produced != self._staging: - exact_error = RuntimeError( - "restart provider did not capture the exact shared staged snapshot" - ) - consensus( - self._topology, - "staged snapshot identity", - error=exact_error, - value=str(produced), - ) - staged_inode = root_value( - self._topology, - "staged snapshot inode", - lambda: list(self._inode(self._staging)), - ) - if not isinstance(staged_inode, list) or len(staged_inode) != 2: - raise RuntimeError("rank zero returned an invalid staged checkpoint inode") - self._staging_inode = (int(staged_inode[0]), int(staged_inode[1])) + except BaseException as error: + if self._topology.rank == 0: + try: + transaction.cleanup_owned() + except BaseException as cleanup_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + self._discarded = True + raise + if type(proof) is not _CheckpointPayloadProof or proof.transaction is not transaction: + error = RuntimeError("RuntimeInstance returned no exact checkpoint payload proof") + if self._topology.rank == 0: + failures = [] + if type(proof) is _CheckpointPayloadProof: + try: + proof.close() + except BaseException as cleanup_error: + failures.append(cleanup_error) + try: + transaction.cleanup_owned() + except BaseException as cleanup_error: + failures.append(cleanup_error) + if failures: + error.add_note( + "rank-zero checkpoint cleanup also failed: " + + "; ".join(str(item) for item in failures) + ) + self._discarded = True + raise error + self._proof = proof + self._staging_owned = True @property def path(self) -> Path: - return self._staging + if self._proof is None: + raise RuntimeError("restart snapshot has no checkpoint payload proof") + return self._proof.path + + @staticmethod + def _target_directory_flags() -> int: + return os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + + def _close_published_root(self) -> list[BaseException]: + failures = [] + entry = self._published_entry + self._published_entry = None + if entry is not None: + try: + entry.close() + except BaseException as error: + failures.append(error) + descriptor = self._published_parent_fd + self._published_parent_fd = None + if descriptor is not None: + try: + os.close(descriptor) + except BaseException as error: + failures.append(error) + return failures + + def _quarantine_published_root(self, *, phase: str) -> None: + entry = self._published_entry + descriptor = self._published_parent_fd + target = self._published_target + self._published_entry = None + self._published_parent_fd = None + self._published_target = None + if entry is None or descriptor is None or target is None: + failures = [] + if entry is not None: + try: + entry.close() + except BaseException as error: + failures.append(error) + if descriptor is not None: + try: + os.close(descriptor) + except BaseException as error: + failures.append(error) + _raise_cleanup_failures("published checkpoint authority cleanup failed", failures) + return + from ._writers.common import _StagedOutputFile + + primary: BaseException | None = None + try: + _StagedOutputFile._quarantine_owned_path( + target, + entry.owner, + replaced_message=( + "checkpoint %s refuses to delete replaced target %s" % (phase, target) + ), + directory_fd=descriptor, + ) + except BaseException as error: + primary = error + finally: + failures = [] + try: + entry.close() + except BaseException as error: + failures.append(error) + try: + os.close(descriptor) + except BaseException as error: + failures.append(error) + if primary is not None: + if failures: + add_note = getattr(primary, "add_note", None) + if callable(add_note): + add_note( + "published checkpoint descriptor cleanup also failed: " + + "; ".join(str(error) for error in failures) + ) + raise primary + _raise_cleanup_failures("published checkpoint descriptor cleanup failed", failures) + + def _cleanup_root(self, *, include_published: bool) -> None: + failures = [] + proof = self._proof + if self._staging_owned and proof is not None: + self._staging_owned = False + try: + proof.transaction.quarantine_entry_at( + proof.entry, + phase="snapshot staging cleanup", + ) + except BaseException as error: + failures.append(error) + elif proof is not None: + try: + proof.close() + except BaseException as error: + failures.append(error) + if include_published and self._published_target is not None: + try: + self._quarantine_published_root(phase="snapshot rollback") + except BaseException as error: + failures.append(error) + if proof is not None: + try: + proof.transaction.cleanup_empty() + except BaseException as error: + failures.append(error) + if include_published: + failures.extend(self._close_published_root()) + _raise_cleanup_failures("checkpoint snapshot cleanup failed", failures) def publish(self, target: Any) -> Path: + from ._checkpoint_collective import root_attempt + if self._discarded: raise RuntimeError("discarded restart snapshot cannot be published") + if self._proof is None: + raise RuntimeError("restart snapshot has no checkpoint payload proof") local_target = canonical_checkpoint_path(target) - selected_target = Path( - root_value(self._topology, "target selection", lambda: str(local_target)) - ) target_error = None - if local_target != selected_target: - target_error = ValueError( - "checkpoint target differs across ranks: local %s, rank-0 %s" - % (local_target, selected_target) + try: + rows = consensus( + self._topology, + "target agreement", + value=str(local_target), ) - if self._published_target is not None and self._published_target != selected_target: - target_error = ValueError("restart snapshot was already published to another target") - consensus(self._topology, "target agreement", error=target_error) + if any(row["value"] != str(local_target) for row in rows): + raise ValueError("checkpoint target differs across ranks") + if self._published_target is not None and self._published_target != local_target: + raise ValueError("restart snapshot was already published to another target") + except BaseException as error: + target_error = error + if target_error is not None: + if self._topology.rank == 0: + try: + self._cleanup_root(include_published=True) + except BaseException as cleanup_error: + add_note = getattr(target_error, "add_note", None) + if callable(add_note): + add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + self._discarded = True + raise target_error if self._published_target is not None: return self._published_target def publish_root() -> dict[str, Any]: - selected_target.parent.mkdir(parents=True, exist_ok=True) + proof = self._proof + if proof is None or not self._staging_owned: + raise RuntimeError("restart snapshot has no owned staging proof") + local_target.parent.mkdir(parents=True, exist_ok=True) + parent_fd = os.open(local_target.parent, self._target_directory_flags()) linked = False - if self._staging_inode is None: - raise RuntimeError("restart snapshot has no authenticated staging inode") + primary: BaseException | None = None + + def authenticate_target_at() -> None: + named = os.stat(local_target.name, dir_fd=parent_fd, follow_symlinks=False) + retained = os.fstat(proof.entry.fileno()) + if ( + not stat.S_ISREG(named.st_mode) + or _owner(named) != proof.owner + or _owner(retained) != proof.owner + ): + raise RuntimeError("checkpoint publication differs from its retained proof") + try: - # Staging and target are deliberately in the same directory. A hard link is an - # atomic no-clobber publication: unlike exists()+replace(), a competing creator can - # never be overwritten between the collision check and the namespace mutation. - os.link(self._staging, selected_target) + proof.transaction.authenticate_entry_at(proof.entry) + os.link( + proof.entry.name, + local_target.name, + src_dir_fd=proof.transaction.directory_fileno(), + dst_dir_fd=parent_fd, + follow_symlinks=False, + ) linked = True - if self._inode(selected_target) != self._staging_inode: - raise RuntimeError("checkpoint hard link does not retain the staging inode") - self._runtime._inspect_checkpoint_file(selected_target) - self._unlink_owned( - self._staging, self._staging_inode, phase="successful staging cleanup" + authenticate_target_at() + proof.transaction.quarantine_entry_at( + proof.entry, + phase="successful staging cleanup", + close_entry=False, ) + self._staging_owned = False + proof.transaction.cleanup_empty() + # Re-authenticate immediately before handing the still-open inode to the + # compensatable published state; publication never reopens the target by path. + authenticate_target_at() + self._published_entry = proof.entry.transfer(local_target.name) + self._published_parent_fd = parent_fd + parent_fd = -1 + self._published_target = local_target except FileExistsError as error: - raise FileExistsError( - "checkpoint target collision: %s" % selected_target - ) from error + primary = FileExistsError("checkpoint target collision: %s" % local_target) + raise primary from error except BaseException as error: - cleanup_error = None + primary = error if linked: try: - # This transaction created this exact link. Staging remains as the durable - # owner until authentication succeeds, so cleanup cannot delete a peer's file. - self._unlink_owned( - selected_target, - self._staging_inode, - phase="failed publication cleanup", + from ._writers.common import _StagedOutputFile + + _StagedOutputFile._quarantine_owned_path( + local_target, + proof.owner, + replaced_message=( + "checkpoint failed publication refuses replaced target %s" + % local_target + ), + directory_fd=parent_fd, ) - except BaseException as caught: - cleanup_error = caught - add_note = getattr(error, "add_note", None) - if cleanup_error is not None and callable(add_note): - add_note("failed checkpoint publication cleanup: %s" % cleanup_error) + except BaseException as cleanup_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("failed checkpoint publication cleanup: %s" % cleanup_error) raise + finally: + if parent_fd >= 0: + try: + os.close(parent_fd) + except BaseException as close_error: + if primary is None: + raise + add_note = getattr(primary, "add_note", None) + if callable(add_note): + add_note( + "checkpoint publication descriptor cleanup also failed: %s" + % close_error + ) return { - "target": str(selected_target), - "device": self._staging_inode[0], - "inode": self._staging_inode[1], + "target": str(local_target), + "entry_owner": list(self._published_entry.owner), } - publication = root_value(self._topology, "publication", publish_root) - if not isinstance(publication, dict) or set(publication) != { - "target", - "device", - "inode", - }: - raise RuntimeError("checkpoint publication returned invalid ownership evidence") - published = Path(publication["target"]) - if published != selected_target: - raise RuntimeError("checkpoint publication returned a different target") - self._published_target = published - self._published_inode = (int(publication["device"]), int(publication["inode"])) - return published + attempt = root_attempt(self._topology, "publication", publish_root) + if attempt.transport_error is not None: + error = _CheckpointTransportFailure( + "checkpoint transport failed during publication: %s" % attempt.transport_error + ) + if attempt.producer_error is not None: + error.add_note("rank-zero producer also failed: %s" % attempt.producer_error) + if self._topology.rank == 0: + try: + self._cleanup_root(include_published=True) + except BaseException as cleanup_error: + error.add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + self._discarded = True + raise error from attempt.transport_error + if attempt.producer_error is not None: + error = attempt.producer_error + cleanup = root_attempt( + self._topology, + "failed publication cleanup", + lambda: self._cleanup_root(include_published=True), + ) + cleanup_errors = tuple( + item + for item in (cleanup.producer_error, cleanup.transport_error) + if item is not None + ) + if cleanup_errors: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note( + "checkpoint publication cleanup also failed: " + + "; ".join(str(item) for item in cleanup_errors) + ) + self._discarded = True + raise error + publication = attempt.value + publication_error = None + owner = None + try: + if not isinstance(publication, dict) or set(publication) != { + "target", + "entry_owner", + }: + raise RuntimeError("checkpoint publication returned invalid ownership evidence") + if Path(publication["target"]) != local_target: + raise RuntimeError("checkpoint publication returned a different target") + owner = _validate_owner(publication["entry_owner"], where="published checkpoint owner") + except BaseException as error: + publication_error = error + try: + consensus( + self._topology, + "publication ownership proof", + error=publication_error, + value=publication, + ) + except BaseException as error: + if self._topology.rank == 0: + try: + self._cleanup_root(include_published=True) + except BaseException as cleanup_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + self._discarded = True + raise + if owner is None: + raise RuntimeError("checkpoint publication proof validation returned no owner") + if self._topology.rank != 0: + self._published_target = local_target + self._published_entry = _CheckpointEntryAuthority(local_target.name, owner, None) + return local_target + + def _finish_cleanup(self, *, phase: str, include_published: bool) -> None: + from ._checkpoint_collective import root_attempt + + attempt = root_attempt( + self._topology, + phase, + lambda: self._cleanup_root(include_published=include_published), + ) + self._discarded = True + if attempt.transport_error is not None: + error = _CheckpointTransportFailure( + "checkpoint transport failed during %s: %s" % (phase, attempt.transport_error) + ) + if attempt.producer_error is not None: + error.add_note("rank-zero cleanup also failed: %s" % attempt.producer_error) + raise error from attempt.transport_error + if attempt.producer_error is not None: + raise attempt.producer_error def discard(self) -> None: if self._discarded or self._published_target is not None: return - - def discard_root() -> None: - if self._staging_inode is None: - raise RuntimeError("restart snapshot has no authenticated staging inode") - self._unlink_owned(self._staging, self._staging_inode, phase="snapshot discard") - - root_value(self._topology, "discard", discard_root) - self._discarded = True + self._finish_cleanup(phase="discard", include_published=False) def rollback(self) -> None: if self._discarded: return + self._finish_cleanup(phase="rollback", include_published=True) - def rollback_root() -> None: - if self._staging_inode is not None: - self._unlink_owned( - self._staging, self._staging_inode, phase="rollback staging cleanup" - ) - if self._published_target is not None: - if self._published_inode is None: - raise RuntimeError("published checkpoint has no ownership evidence") - self._unlink_owned( - self._published_target, - self._published_inode, - phase="rollback publication cleanup", - ) + def finalize(self) -> None: + failures = [] + if self._proof is not None: + try: + self._proof.close() + except BaseException as error: + failures.append(error) + try: + self._proof.transaction.close() + except BaseException as error: + failures.append(error) + failures.extend(self._close_published_root()) + _raise_cleanup_failures("checkpoint snapshot finalization failed", failures) - root_value(self._topology, "rollback", rollback_root) - self._published_target = None - self._published_inode = None - self._discarded = True + def __del__(self) -> None: + try: + self.finalize() + except BaseException: + pass @dataclass(frozen=True, slots=True) diff --git a/python/pops/output/_writers/common.py b/python/pops/output/_writers/common.py index b30efe6cb..cf817838f 100644 --- a/python/pops/output/_writers/common.py +++ b/python/pops/output/_writers/common.py @@ -841,6 +841,7 @@ def _quarantine_owned_path( expected_owner: tuple[int, int] | None, *, replaced_message: str, + directory_fd: int | None = None, ) -> None: """Atomically detach one path, then delete only from a private quarantine. @@ -855,7 +856,11 @@ def _quarantine_owned_path( directory_flags = ( os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) ) - parent_fd = os.open(path.parent, directory_flags) + parent_fd = ( + os.open(path.parent, directory_flags) + if directory_fd is None + else os.dup(directory_fd) + ) quarantine_name = "" quarantine_fd: int | None = None retain_quarantine = False diff --git a/python/pops/output/_writers/hdf5.py b/python/pops/output/_writers/hdf5.py index 0e5c88609..6356eed6b 100644 --- a/python/pops/output/_writers/hdf5.py +++ b/python/pops/output/_writers/hdf5.py @@ -177,7 +177,7 @@ def _parallel_snapshot_data( if request.parallel_mode is not ParallelMode.COLLECTIVE: raise ValueError( "a resolved communicator is valid only for HDF5 COLLECTIVE output") - require_communicator(communicator) + require_communicator(communicator, allow_world=False) if request.rank != rank(communicator): raise ValueError("collective HDF5 request rank differs from its native communicator") native, capability = _require_native_parallel_hdf5() diff --git a/python/pops/output/formats.py b/python/pops/output/formats.py index f59f3ecd8..2e7f5b291 100644 --- a/python/pops/output/formats.py +++ b/python/pops/output/formats.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from typing import Any, ClassVar -import warnings from pops.descriptors import Descriptor from pops.descriptors_report import RequirementSet @@ -404,7 +403,6 @@ def __init__( preset: ParaViewPreset | None = None, placement: Any = None, state: Any = _DEFAULT_PARAVIEW_STATE, - series: Any = _UNSET_PARAVIEW_OPTION, ) -> None: selected_mode = _mode( mode, @@ -420,21 +418,6 @@ def __init__( raise ValueError("ParaView.compression must be None or an integer from 0 to 9") if collection is not _UNSET_PARAVIEW_OPTION and type(collection) is not bool: raise TypeError("ParaView.collection must be an exact bool") - if series is not _UNSET_PARAVIEW_OPTION: - if series is not None and type(series) is not bool: - raise TypeError("ParaView.series must be an exact bool or None") - warnings.warn( - "ParaView(series=...) is deprecated; use collection=... for the standard " - "PVD collection", - DeprecationWarning, - stacklevel=2, - ) - legacy_collection = ( - selected_mode is not ParallelMode.PER_RANK if series is None else series) - if collection is not _UNSET_PARAVIEW_OPTION \ - and collection is not legacy_collection: - raise ValueError("ParaView.collection and deprecated series disagree") - collection = legacy_collection if collection is _UNSET_PARAVIEW_OPTION: collection = True from .paraview_state import MaterializedPVSM, PortableState diff --git a/python/pops/output/observers.py b/python/pops/output/observers.py index dafa47e80..3a7e64478 100644 --- a/python/pops/output/observers.py +++ b/python/pops/output/observers.py @@ -9,6 +9,7 @@ shipped optional Python backend by default, while extensions and tests can inject another provider with the same four-method session protocol. """ + from __future__ import annotations from collections.abc import Mapping @@ -22,7 +23,11 @@ from pops.identity import Identity, canonical_bytes, make_identity from pops.model import Handle from pops.output.data import ( - ArrayPiece, FieldPayload, LevelGeometry, OutputRequest, OutputSnapshot, + ArrayPiece, + FieldPayload, + LevelGeometry, + OutputRequest, + OutputSnapshot, ) from pops.time import Schedule @@ -69,19 +74,24 @@ def _collective_semantic_data(value: Any, *, where: str) -> list[Any]: if isinstance(value, bytes): return ["bytes", value.hex()] if isinstance(value, (list, tuple)): - return ["list", [ - _collective_semantic_data(item, where="%s[%d]" % (where, index)) - for index, item in enumerate(value) - ]] + return [ + "list", + [ + _collective_semantic_data(item, where="%s[%d]" % (where, index)) + for index, item in enumerate(value) + ], + ] if isinstance(value, Mapping): rows = [] for key in sorted(value): if not isinstance(key, str) or not key: raise TypeError("%s requires non-empty string keys" % where) - rows.append([ - key, - _collective_semantic_data(value[key], where="%s.%s" % (where, key)), - ]) + rows.append( + [ + key, + _collective_semantic_data(value[key], where="%s.%s" % (where, key)), + ] + ) return ["map", rows] raise TypeError("%s contains unsupported %s" % (where, type(value).__name__)) @@ -117,15 +127,18 @@ def _semantic_data_from_collective(node: Any, *, where: str) -> Any: result = {} previous = None for index, row in enumerate(node[1]): - if not isinstance(row, list) or len(row) != 2 \ - or not isinstance(row[0], str) or not row[0]: + if ( + not isinstance(row, list) + or len(row) != 2 + or not isinstance(row[0], str) + or not row[0] + ): raise TypeError("%s map row %d is invalid" % (where, index)) key = row[0] if previous is not None and key <= previous: raise ValueError("%s map keys are not canonical" % where) previous = key - result[key] = _semantic_data_from_collective( - row[1], where="%s.%s" % (where, key)) + result[key] = _semantic_data_from_collective(row[1], where="%s.%s" % (where, key)) return result raise ValueError("%s has an unsupported collective semantic tag" % where) @@ -145,11 +158,11 @@ def __post_init__(self) -> None: metadata = _canonical_mapping(dict(self.metadata), "ObserverRun.metadata") recovery = tuple(self.recovery_run_identities) if any(type(item) is not Identity or item.domain != "run" for item in recovery): - raise TypeError( - "ObserverRun.recovery_run_identities must contain exact run Identities") + raise TypeError("ObserverRun.recovery_run_identities must contain exact run Identities") if self.run_identity in recovery or len(set(recovery)) != len(recovery): raise ValueError( - "ObserverRun recovery identities must be unique and exclude the active run") + "ObserverRun recovery identities must be unique and exclude the active run" + ) recovery = tuple(sorted(recovery, key=lambda item: item.token)) object.__setattr__(self, "metadata", metadata) object.__setattr__(self, "recovery_run_identities", recovery) @@ -165,9 +178,7 @@ def to_data(self) -> dict[str, Any]: return { "run_identity": self.run_identity.to_data(), "metadata": thaw_data(self.metadata), - "recovery_run_identities": [ - item.to_data() for item in self.recovery_run_identities - ], + "recovery_run_identities": [item.to_data() for item in self.recovery_run_identities], } @@ -192,8 +203,11 @@ def __post_init__(self) -> None: # OutputSnapshot/ArrayPiece own read-only copies of field data. Hashing the canonical # projection both authenticates the callback and makes accidental frame substitution # visible to the completion receipt. - object.__setattr__(self, "identity", make_identity( - "post-commit-observer-frame", self.snapshot.to_data(self.request))) + object.__setattr__( + self, + "identity", + make_identity("post-commit-observer-frame", self.snapshot.to_data(self.request)), + ) @property def physical_time(self) -> float: @@ -209,39 +223,47 @@ def detach_observer_frame(frame: ObserverFrame) -> ObserverFrame: if type(frame) is not ObserverFrame: raise TypeError("detach_observer_frame requires an exact ObserverFrame") - geometries = tuple(LevelGeometry( - geometry.layout_identity, - geometry.layout_kind, - geometry.level, - geometry.origin, - geometry.spacing, - geometry.cell_shape, - geometry.boxes, - geometry.coverage, - geometry.cell_volumes, - coordinate_system=geometry.coordinate_system, - cell_measure=geometry.cell_measure, - axis_names=geometry.axis_names, - ) for geometry in frame.snapshot.geometries) + geometries = tuple( + LevelGeometry( + geometry.layout_identity, + geometry.layout_kind, + geometry.level, + geometry.origin, + geometry.spacing, + geometry.cell_shape, + geometry.boxes, + geometry.coverage, + geometry.cell_volumes, + coordinate_system=geometry.coordinate_system, + cell_measure=geometry.cell_measure, + axis_names=geometry.axis_names, + ) + for geometry in frame.snapshot.geometries + ) fields = [] for field_value in frame.snapshot.fields: - pieces = tuple(ArrayPiece( - piece.lower, - piece.upper, - piece.values, - piece.global_box_index, - piece.owner_rank, - piece.replicated, - ) for piece in field_value.pieces) - fields.append(FieldPayload( - field_value.key, - field_value.centering, - field_value.units, - field_value.component_names, - field_value.global_shape, - pieces, - dtype=field_value.array_dtype, - )) + pieces = tuple( + ArrayPiece( + piece.lower, + piece.upper, + piece.values, + piece.global_box_index, + piece.owner_rank, + piece.replicated, + ) + for piece in field_value.pieces + ) + fields.append( + FieldPayload( + field_value.key, + field_value.centering, + field_value.units, + field_value.component_names, + field_value.global_shape, + pieces, + dtype=field_value.array_dtype, + ) + ) snapshot = OutputSnapshot( frame.snapshot.clock, frame.snapshot.provenance, @@ -266,13 +288,17 @@ class ObserverReceipt: detail: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - if type(self.frame_identity) is not Identity \ - or self.frame_identity.domain != "post-commit-observer-frame": + if ( + type(self.frame_identity) is not Identity + or self.frame_identity.domain != "post-commit-observer-frame" + ): raise TypeError("ObserverReceipt.frame_identity has the wrong identity domain") - object.__setattr__(self, "provider_id", _text( - self.provider_id, "ObserverReceipt.provider_id")) - object.__setattr__(self, "detail", _canonical_mapping( - dict(self.detail), "ObserverReceipt.detail")) + object.__setattr__( + self, "provider_id", _text(self.provider_id, "ObserverReceipt.provider_id") + ) + object.__setattr__( + self, "detail", _canonical_mapping(dict(self.detail), "ObserverReceipt.detail") + ) def to_data(self) -> dict[str, Any]: return { @@ -288,13 +314,17 @@ def to_collective_data(self) -> dict[str, Any]: "frame_identity": self.frame_identity.token, "provider_id": self.provider_id, "detail": _collective_semantic_data( - thaw_data(self.detail), where="ObserverReceipt.detail"), + thaw_data(self.detail), where="ObserverReceipt.detail" + ), } @classmethod def from_data(cls, data: Any) -> ObserverReceipt: if not isinstance(data, Mapping) or set(data) != { - "frame_identity", "provider_id", "detail"}: + "frame_identity", + "provider_id", + "detail", + }: raise TypeError("ObserverReceipt data has an unsupported schema") result = cls( Identity.from_data(data["frame_identity"]), @@ -308,19 +338,30 @@ def from_data(cls, data: Any) -> ObserverReceipt: @classmethod def from_collective_data(cls, data: Any) -> ObserverReceipt: if not isinstance(data, Mapping) or set(data) != { - "frame_identity", "provider_id", "detail"}: + "frame_identity", + "provider_id", + "detail", + }: raise TypeError("ObserverReceipt collective data has an unsupported schema") result = cls( Identity.from_token(data["frame_identity"]), data["provider_id"], - _semantic_data_from_collective( - data["detail"], where="ObserverReceipt.detail"), + _semantic_data_from_collective(data["detail"], where="ObserverReceipt.detail"), ) if result.to_collective_data() != dict(data): raise ValueError("ObserverReceipt collective data is not canonical") return result +class ObserverWorkerCollectiveLost(RuntimeError): + """Signal that an observer provider lost rank-complete proof on its worker lane. + + A session raises this only after transport failure or malformed evidence from one of its own + collectives. The owning runtime must seal the lane immediately; attempting another agreement + on the same communicator is unsafe. + """ + + class ObserverSession(Protocol): """Dedicated session owned by one post-commit delivery worker. @@ -347,13 +388,21 @@ class ObserverProvider(Protocol): def consumer_data(self) -> dict[str, Any]: ... def open_session( - self, configuration: Mapping[str, Any], execution_context: Any, + self, + configuration: Mapping[str, Any], + execution_context: Any, ) -> ObserverSession: ... -_SESSION_AUTHORITY_KEYS = frozenset({ - "schema_version", "provider_id", "delivery", "threading", "worker_mpi", -}) +_SESSION_AUTHORITY_KEYS = frozenset( + { + "schema_version", + "provider_id", + "delivery", + "threading", + "worker_mpi", + } +) def authenticate_observer_session(session: Any) -> dict[str, Any]: @@ -372,12 +421,12 @@ def authenticate_observer_session(session: Any) -> dict[str, Any]: raise ValueError("observer session must declare irreversible post_commit delivery") if first["threading"] not in {"dedicated_serial", "dedicated_collective"}: raise ValueError( - "observer session threading must be dedicated_serial or dedicated_collective") + "observer session threading must be dedicated_serial or dedicated_collective" + ) if type(first["worker_mpi"]) is not bool: raise TypeError("observer session worker_mpi must be an exact bool") if first["worker_mpi"] != (first["threading"] == "dedicated_collective"): - raise ValueError( - "observer session worker_mpi and threading authority disagree") + raise ValueError("observer session worker_mpi and threading authority disagree") canonical_bytes(first) return dict(first) @@ -393,8 +442,13 @@ class Catalyst: __pops_ir_immutable__ = True __slots__ = ( - "_provider", "_provider_data", "pipeline", "pipeline_sha256", "implementation", - "search_paths", "args", + "_provider", + "_provider_data", + "pipeline", + "pipeline_sha256", + "implementation", + "search_paths", + "args", ) def __init__( @@ -416,8 +470,7 @@ def __init__( data_method = getattr(provider, "consumer_data", None) open_method = getattr(provider, "open_session", None) if not callable(data_method) or not callable(open_method): - raise TypeError( - "Catalyst provider must implement consumer_data() and open_session()") + raise TypeError("Catalyst provider must implement consumer_data() and open_session()") first, second = data_method(), data_method() if type(first) is not dict or type(second) is not dict or first != second: raise TypeError("Catalyst provider consumer_data() must be deterministic") @@ -426,16 +479,17 @@ def __init__( _text(first.get("provider_id"), "Catalyst provider_id") canonical_bytes(first) object.__setattr__(self, "_provider", provider) - object.__setattr__(self, "_provider_data", _canonical_mapping( - first, "Catalyst provider data")) + object.__setattr__( + self, "_provider_data", _canonical_mapping(first, "Catalyst provider data") + ) pipeline_path = Path(_text(pipeline, "Catalyst.pipeline")).expanduser().resolve() if not pipeline_path.is_file(): raise FileNotFoundError("Catalyst pipeline does not exist: %s" % pipeline_path) object.__setattr__(self, "pipeline", pipeline_path.as_posix()) - object.__setattr__(self, "pipeline_sha256", hashlib.sha256( - pipeline_path.read_bytes()).hexdigest()) - object.__setattr__(self, "implementation", _text( - implementation, "Catalyst.implementation")) + object.__setattr__( + self, "pipeline_sha256", hashlib.sha256(pipeline_path.read_bytes()).hexdigest() + ) + object.__setattr__(self, "implementation", _text(implementation, "Catalyst.implementation")) paths = tuple(search_paths) if any(not isinstance(value, str) for value in paths): raise TypeError("Catalyst.search_paths must contain path strings") @@ -444,12 +498,12 @@ def __init__( for value in paths ) if any(not value.is_dir() for value in resolved_paths): - raise NotADirectoryError( - "Catalyst.search_paths must contain existing directories") + raise NotADirectoryError("Catalyst.search_paths must contain existing directories") if len(set(resolved_paths)) != len(resolved_paths): raise ValueError("Catalyst.search_paths must be unique") - object.__setattr__(self, "search_paths", tuple( - value.as_posix() for value in resolved_paths)) + object.__setattr__( + self, "search_paths", tuple(value.as_posix() for value in resolved_paths) + ) script_args = tuple(args) if any(not isinstance(value, str) for value in script_args): raise TypeError("Catalyst.args must contain strings") @@ -477,7 +531,9 @@ def open_session(self, execution_context: Any) -> ObserverSession: return self._open_session(self.consumer_data(), execution_context) def open_runtime_session( - self, runtime_configuration: Mapping[str, Any], execution_context: Any, + self, + runtime_configuration: Mapping[str, Any], + execution_context: Any, ) -> ObserverSession: if not isinstance(runtime_configuration, Mapping): raise TypeError("Catalyst runtime configuration must be a mapping") @@ -491,7 +547,9 @@ def open_runtime_session( return self._open_session(configuration, execution_context) def _open_session( - self, configuration: Mapping[str, Any], execution_context: Any, + self, + configuration: Mapping[str, Any], + execution_context: Any, ) -> ObserverSession: current = self._provider.consumer_data() if type(current) is not dict or current != thaw_data(self._provider_data): @@ -555,8 +613,7 @@ def __post_init__(self) -> None: from ._durable_journal import DurableJournal if self.durability is not None and type(self.durability) is not DurableJournal: - raise TypeError( - "live observer durability must be an exact DurableJournal or None") + raise TypeError("live observer durability must be an exact DurableJournal or None") first, second = self.observer.consumer_data(), self.observer.consumer_data() if type(first) is not dict or type(second) is not dict or first != second: raise TypeError("live observer consumer_data() must return one deterministic dict") @@ -565,30 +622,36 @@ def __post_init__(self) -> None: if observer_kind == "catalyst": if self.parallel_mode not in (ParallelMode.SERIAL, ParallelMode.COLLECTIVE): raise ValueError( - "Catalyst live visualization supports only SERIAL or COLLECTIVE mode") - elif observer_kind != "async_scientific_output" \ - and self.parallel_mode is not ParallelMode.SERIAL: - raise ValueError( - "this live observer supports only ParallelMode.SERIAL") + "Catalyst live visualization supports only SERIAL or COLLECTIVE mode" + ) + elif ( + observer_kind != "async_scientific_output" + and self.parallel_mode is not ParallelMode.SERIAL + ): + raise ValueError("this live observer supports only ParallelMode.SERIAL") expected_provider = first.get("provider_id") if first.get("observer_kind") == "catalyst": provider = first.get("provider") if not isinstance(provider, Mapping): raise TypeError( - "Catalyst observer data must carry its authenticated provider mapping") + "Catalyst observer data must carry its authenticated provider mapping" + ) expected_provider = provider.get("provider_id") - expected_provider = _text( - expected_provider, "live observer session provider_id") - object.__setattr__(self, "_observer_data", _canonical_mapping( - first, "live observer consumer_data")) + expected_provider = _text(expected_provider, "live observer session provider_id") + object.__setattr__( + self, "_observer_data", _canonical_mapping(first, "live observer consumer_data") + ) object.__setattr__(self, "_session_provider_id", expected_provider) def _authenticate_observer(self) -> None: first, second = self.observer.consumer_data(), self.observer.consumer_data() - if type(first) is not dict or type(second) is not dict or first != second \ - or first != thaw_data(self._observer_data): - raise RuntimeError( - "live observer changed after its declaration was authenticated") + if ( + type(first) is not dict + or type(second) is not dict + or first != second + or first != thaw_data(self._observer_data) + ): + raise RuntimeError("live observer changed after its declaration was authenticated") def consumer_data(self) -> dict[str, Any]: return { @@ -598,8 +661,7 @@ def consumer_data(self) -> dict[str, Any]: "queue_capacity": self.queue_capacity, "max_attempts": self.max_attempts, "on_failure": self.on_failure.to_data(), - "durability": ( - None if self.durability is None else self.durability.to_data()), + "durability": (None if self.durability is None else self.durability.to_data()), "observer": thaw_data(self._observer_data), } @@ -608,8 +670,7 @@ def _authenticate_session(self, session: Any) -> ObserverSession: if authority["provider_id"] != self._session_provider_id: raise ValueError( "live observer session provider_id differs from its authenticated manifest: " - "%r != %r" - % (authority["provider_id"], self._session_provider_id) + "%r != %r" % (authority["provider_id"], self._session_provider_id) ) return cast(ObserverSession, session) @@ -619,13 +680,16 @@ def open_session(self, execution_context: Any) -> ObserverSession: return self._authenticate_session(session) def open_runtime_session( - self, runtime_configuration: Mapping[str, Any], execution_context: Any, + self, + runtime_configuration: Mapping[str, Any], + execution_context: Any, ) -> ObserverSession: self._authenticate_observer() provider = getattr(self.observer, "open_runtime_session", None) session = ( provider(runtime_configuration, execution_context) - if callable(provider) else self.observer.open_session(execution_context) + if callable(provider) + else self.observer.open_session(execution_context) ) return self._authenticate_session(session) @@ -655,15 +719,18 @@ class _AsyncScientificWriterObserver: def __init__(self, format_provider: Any) -> None: from .provider import consumer_format_data - data = consumer_format_data( - format_provider, where="AsyncScientificOutput.format") + data = consumer_format_data(format_provider, where="AsyncScientificOutput.format") if data["provider_id"] == "pops.output.external-writer.v1": raise ValueError( "AsyncScientificOutput does not accept ExternalWriter: installed native Writers " - "have no dedicated post-commit worker session route") + "have no dedicated post-commit worker session route" + ) object.__setattr__(self, "_format", format_provider) - object.__setattr__(self, "_format_data", _canonical_mapping( - data, "AsyncScientificOutput.format.consumer_data")) + object.__setattr__( + self, + "_format_data", + _canonical_mapping(data, "AsyncScientificOutput.format.consumer_data"), + ) def __setattr__(self, name: str, value: Any) -> None: del name, value @@ -680,11 +747,11 @@ def consumer_data(self) -> dict[str, Any]: def _authenticate_format(self) -> None: from .provider import consumer_format_data - current = consumer_format_data( - self._format, where="AsyncScientificOutput.format") + current = consumer_format_data(self._format, where="AsyncScientificOutput.format") if current != thaw_data(self._format_data): raise RuntimeError( - "AsyncScientificOutput format changed after its declaration was authenticated") + "AsyncScientificOutput format changed after its declaration was authenticated" + ) def preflight(self, execution_context: Any) -> dict[str, Any]: self._authenticate_format() @@ -692,7 +759,8 @@ def preflight(self, execution_context: Any) -> dict[str, Any]: callback = getattr(writer, "preflight", None) if not callable(callback) or not callable(getattr(writer, "prepare_session", None)): raise TypeError( - "AsyncScientificOutput writer must implement preflight() and prepare_session()") + "AsyncScientificOutput writer must implement preflight() and prepare_session()" + ) result = callback(execution_context) if type(result) is not dict: raise TypeError("AsyncScientificOutput writer preflight() must return an exact dict") @@ -706,7 +774,9 @@ def preopen_session(self, execution_context: Any) -> None: return None def open_runtime_session( - self, configuration: Mapping[str, Any], execution_context: Any, + self, + configuration: Mapping[str, Any], + execution_context: Any, ) -> ObserverSession: self._authenticate_format() return _AsyncScientificWriterSession( @@ -718,8 +788,7 @@ def open_runtime_session( def open_session(self, execution_context: Any) -> ObserverSession: del execution_context - raise RuntimeError( - "AsyncScientificOutput requires its run-time target configuration") + raise RuntimeError("AsyncScientificOutput requires its run-time target configuration") class _AsyncScientificWriterSession: @@ -734,8 +803,11 @@ def __init__( ) -> None: expected = {"target_uri", "output_root", "consumer_id"} allowed = expected | {"worker_communicator"} - if not isinstance(configuration, Mapping) or not expected.issubset(configuration) \ - or not set(configuration).issubset(allowed): + if ( + not isinstance(configuration, Mapping) + or not expected.issubset(configuration) + or not set(configuration).issubset(allowed) + ): raise TypeError("async scientific writer runtime configuration is not exact") target_uri = _text(configuration["target_uri"], "async output target_uri") output_root = configuration["output_root"] @@ -745,8 +817,7 @@ def __init__( self._format_data = dict(format_data) self._target_uri = target_uri self._output_root = output_root - self._consumer_id = _text( - configuration["consumer_id"], "async output consumer_id") + self._consumer_id = _text(configuration["consumer_id"], "async output consumer_id") self._communicator = configuration.get("worker_communicator") self._execution_context = execution_context self._initialized = False @@ -793,36 +864,47 @@ def _target(self, frame: ObserverFrame) -> Path: ) def _phase_evidence( - self, phase: str, error: BaseException | None, state: str, + self, + phase: str, + error: BaseException | None, + state: str, ) -> tuple[BaseException | None, tuple[str, ...]]: rendered = None if error is None else "%s: %s" % (type(error).__name__, error) if self._communicator is None: return error, (state,) from pops._native_collectives import allgather_value, rank, size - rows = allgather_value(self._communicator, { - "rank": rank(self._communicator), - "error": rendered, - "state": state, - }) + rows = allgather_value( + self._communicator, + { + "rank": rank(self._communicator), + "error": rendered, + "state": state, + }, + ) if len(rows) != size(self._communicator) or any( - not isinstance(row, dict) - or set(row) != {"rank", "error", "state"} - or row["rank"] != owner - or (row["error"] is not None and not isinstance(row["error"], str)) - or not isinstance(row["state"], str) - for owner, row in enumerate(rows)): + not isinstance(row, dict) + or set(row) != {"rank", "error", "state"} + or row["rank"] != owner + or (row["error"] is not None and not isinstance(row["error"], str)) + or not isinstance(row["state"], str) + for owner, row in enumerate(rows) + ): return RuntimeError( - "async scientific writer %s returned malformed rank evidence" % phase), () + "async scientific writer %s returned malformed rank evidence" % phase + ), () failures = [ "rank %d: %s" % (owner, row["error"]) - for owner, row in enumerate(rows) if row["error"] is not None + for owner, row in enumerate(rows) + if row["error"] is not None ] states = tuple(row["state"] for row in rows) return ( - None if not failures else RuntimeError( - "async scientific writer %s failed collectively: %s" - % (phase, "; ".join(failures))), + None + if not failures + else RuntimeError( + "async scientific writer %s failed collectively: %s" % (phase, "; ".join(failures)) + ), states, ) @@ -837,13 +919,13 @@ def _prepare_output_session(self, frame: ObserverFrame) -> tuple[Any, Path, Any] raise TypeError("async scientific writer requires an exact ObserverFrame") if frame.snapshot.provenance.run_identity not in self._accepted_run_identities: raise ValueError( - "async scientific output frame is outside the active/recovery run authority") + "async scientific output frame is outside the active/recovery run authority" + ) mode = ParallelMode(self._format_data["parallel_mode"]) if frame.request.parallel_mode is not mode: raise ValueError("async scientific output frame mode differs from its format") if mode is ParallelMode.SERIAL: - if (frame.request.rank, frame.request.size) != (0, 1) \ - or self._communicator is not None: + if (frame.request.rank, frame.request.size) != (0, 1) or self._communicator is not None: raise ValueError("SERIAL async scientific output has invalid topology") elif mode is ParallelMode.ROOT: if frame.request.rank != 0 or self._communicator is not None: @@ -851,13 +933,15 @@ def _prepare_output_session(self, frame: ObserverFrame) -> tuple[Any, Path, Any] else: from pops._native_collectives import rank, size - if self._communicator is None \ - or frame.request.rank != rank(self._communicator) \ - or frame.request.size != size(self._communicator): + if ( + self._communicator is None + or frame.request.rank != rank(self._communicator) + or frame.request.size != size(self._communicator) + ): raise ValueError( - "distributed async scientific output requires its exact worker MPI lane") - current = consumer_format_data( - self._format, where="AsyncScientificOutput.format") + "distributed async scientific output requires its exact worker MPI lane" + ) + current = consumer_format_data(self._format, where="AsyncScientificOutput.format") if current != self._format_data: raise RuntimeError("async scientific output format changed during the run") writer = self._format.writer() @@ -866,7 +950,8 @@ def _prepare_output_session(self, frame: ObserverFrame) -> tuple[Any, Path, Any] raise TypeError("async scientific writer preflight contract changed during the run") target = self._target(frame) session = writer.prepare_session( - frame.snapshot, frame.request, target, communicator=self._communicator) + frame.snapshot, frame.request, target, communicator=self._communicator + ) authority = authenticate_writer_session(session) writer_format = getattr(writer, "format", None) if not isinstance(writer_format, str) or not writer_format: @@ -886,8 +971,8 @@ def _prepare_output_session(self, frame: ObserverFrame) -> tuple[Any, Path, Any] } if mismatches: raise ValueError( - "async writer session authority differs from its exact request: %r" - % mismatches) + "async writer session authority differs from its exact request: %r" % mismatches + ) return mode, target, session def execute(self, frame: ObserverFrame) -> ObserverReceipt: @@ -902,12 +987,10 @@ def execute(self, frame: ObserverFrame) -> ObserverReceipt: mode, target, session = self._prepare_output_session(frame) except BaseException as error: preparation_error = error - target_state = ( - "missing" if target is None - else target.expanduser().resolve().as_posix() - ) + target_state = "missing" if target is None else target.expanduser().resolve().as_posix() failure, states = self._phase_evidence( - "session preparation", preparation_error, + "session preparation", + preparation_error, target_state, ) if failure is not None: @@ -915,15 +998,16 @@ def execute(self, frame: ObserverFrame) -> ObserverReceipt: if session is None or target is None: raise RuntimeError("async writer preparation lost its local session authority") if mode is ParallelMode.COLLECTIVE and len(set(states)) != 1: - mismatch = RuntimeError( - "COLLECTIVE async writer ranks resolved different target paths") + mismatch = RuntimeError("COLLECTIVE async writer ranks resolved different target paths") try: if session.abort_prepare() is not None: raise TypeError("scientific writer abort_prepare() must return None") except BaseException as cleanup_error: - _add_exception_note(mismatch, + _add_exception_note( + mismatch, "async writer target-mismatch cleanup also failed: %s: %s" - % (type(cleanup_error).__name__, cleanup_error)) + % (type(cleanup_error).__name__, cleanup_error), + ) raise mismatch staged = False @@ -936,7 +1020,8 @@ def execute(self, frame: ObserverFrame) -> ObserverReceipt: except BaseException as error: stage_error = error failure, states = self._phase_evidence( - "stage", stage_error, "staged" if staged else "unstaged") + "stage", stage_error, "staged" if staged else "unstaged" + ) if failure is not None: cleanup_error = None # A split stage state means the backend violated its collective contract. Entering @@ -950,11 +1035,14 @@ def execute(self, frame: ObserverFrame) -> ObserverReceipt: cleanup_error = error else: cleanup_error = RuntimeError( - "writer stage state differs across ranks; collective cleanup was not entered") + "writer stage state differs across ranks; collective cleanup was not entered" + ) if cleanup_error is not None: - _add_exception_note(failure, + _add_exception_note( + failure, "async scientific writer cleanup also failed: %s: %s" - % (type(cleanup_error).__name__, cleanup_error)) + % (type(cleanup_error).__name__, cleanup_error), + ) raise failure receipt = None @@ -968,16 +1056,17 @@ def execute(self, frame: ObserverFrame) -> ObserverReceipt: if receipt.selection_identity != frame.request.publication_identity: raise ValueError("async writer receipt authenticates another selection") if receipt.format != self._format_data["format_name"]: - raise ValueError( - "async writer receipt format differs from its canonical provider") + raise ValueError("async writer receipt format differs from its canonical provider") expected_parent = target.expanduser().resolve().parent if Path(receipt.path).expanduser().resolve().parent != expected_parent: raise ValueError( - "async writer primary receipt escaped its authenticated target directory") + "async writer primary receipt escaped its authenticated target directory" + ) except BaseException as error: publish_error = error failure, _states = self._phase_evidence( - "publish", publish_error, "published" if published else "unpublished") + "publish", publish_error, "published" if published else "unpublished" + ) if failure is not None: cleanup_error = None try: @@ -986,9 +1075,11 @@ def execute(self, frame: ObserverFrame) -> ObserverReceipt: except BaseException as error: cleanup_error = error if cleanup_error is not None: - _add_exception_note(failure, + _add_exception_note( + failure, "async scientific writer rollback also failed: %s: %s" - % (type(cleanup_error).__name__, cleanup_error)) + % (type(cleanup_error).__name__, cleanup_error), + ) raise failure if type(receipt) is not OutputPublicationReceipt: raise RuntimeError("async writer publication lost its authenticated receipt") @@ -1001,13 +1092,17 @@ def execute(self, frame: ObserverFrame) -> ObserverReceipt: raise TypeError("scientific writer finalize() must return None") except BaseException as error: finalize_error = "%s: %s" % (type(error).__name__, error) - return ObserverReceipt(frame.identity, self.authority["provider_id"], { - "path": Path(receipt.path).resolve().as_posix(), - "format": receipt.format, - "output_identity": receipt.output_identity.token, - "selection_identity": receipt.selection_identity.token, - "writer_finalize_error": finalize_error, - }) + return ObserverReceipt( + frame.identity, + self.authority["provider_id"], + { + "path": Path(receipt.path).resolve().as_posix(), + "format": receipt.format, + "output_identity": receipt.output_identity.token, + "selection_identity": receipt.selection_identity.token, + "writer_finalize_error": finalize_error, + }, + ) def finalize(self) -> None: if self._finalized: @@ -1030,7 +1125,8 @@ def _relative_target(value: Any, *, where: str) -> str: if PurePosixPath(result).suffix: raise ValueError( "%s is a logical target and must not contain a file suffix; " - "the selected provider owns its extension" % where) + "the selected provider owns its extension" % where + ) return result @@ -1040,7 +1136,8 @@ class AsyncScientificOutput(Descriptor): SERIAL and gathered ROOT writers need no worker MPI. PER_RANK and COLLECTIVE writers execute on one duplicated MPI lane per consumer, isolated from numerical collectives. The default queue is process-lifetime only; a ``DurableJournal`` policy adds the explicit crash-replay - handoff. + handoff. Fields and diagnostic reductions share one exact schedule; diagnostics are reduced + before the immutable accepted snapshot is handed to the worker. """ category = "async_scientific_output" @@ -1050,7 +1147,8 @@ def __init__( *, format: Any, schedule: Any, - fields: Any, + fields: Any = (), + diagnostics: Any = (), levels: Any = None, target: Any, queue_capacity: Any = 1, @@ -1066,38 +1164,57 @@ def __init__( if type(schedule) is not Schedule: raise TypeError("AsyncScientificOutput.schedule must be an exact pops.time.Schedule") field_rows = tuple(fields) - if not field_rows: - raise ValueError("AsyncScientificOutput requires at least one field") if any(not isinstance(reference, Handle) for reference in field_rows): raise TypeError("AsyncScientificOutput fields must contain declaration Handles") if any(reference.kind not in _LIVE_FIELD_KINDS for reference in field_rows): raise TypeError("AsyncScientificOutput fields accept only state, field, or aux Handles") if len(set(field_rows)) != len(field_rows): raise ValueError("AsyncScientificOutput fields must be unique") + diagnostic_rows = tuple(diagnostics) + for index, diagnostic in enumerate(diagnostic_rows): + where = "AsyncScientificOutput diagnostics[%d]" % index + for method in ( + "declaration_references", + "resolve_references", + "consumer_data", + "freeze", + ): + if not callable(getattr(diagnostic, method, None)): + raise TypeError("%s must implement %s()" % (where, method)) + cadence = getattr(diagnostic, "cadence", None) + if cadence is not None and cadence != schedule: + raise ValueError( + "a diagnostic embedded in AsyncScientificOutput must use the same schedule" + ) + if not field_rows and not diagnostic_rows: + raise ValueError("AsyncScientificOutput requires at least one field or diagnostic") selected_levels = AllLevels() if levels is None else levels if not isinstance(selected_levels, LevelSelection): raise TypeError("AsyncScientificOutput levels must be a typed LevelSelection") - if isinstance(queue_capacity, bool) or type(queue_capacity) is not int \ - or queue_capacity < 1: + if ( + isinstance(queue_capacity, bool) + or type(queue_capacity) is not int + or queue_capacity < 1 + ): raise ValueError("AsyncScientificOutput.queue_capacity must be an integer >= 1") - if isinstance(max_attempts, bool) or type(max_attempts) is not int \ - or max_attempts < 1: + if isinstance(max_attempts, bool) or type(max_attempts) is not int or max_attempts < 1: raise ValueError("AsyncScientificOutput.max_attempts must be an integer >= 1") - if selected_mode in (ParallelMode.PER_RANK, ParallelMode.COLLECTIVE) \ - and max_attempts != 1: + if selected_mode in (ParallelMode.PER_RANK, ParallelMode.COLLECTIVE) and max_attempts != 1: raise ValueError( "MPI async scientific output requires max_attempts=1; retrying an entered " - "collective publication is not safe") + "collective publication is not safe" + ) selected_failure = RaiseOnFlush() if on_failure is None else on_failure if type(selected_failure) not in _LIVE_FAILURE_POLICIES: raise TypeError( - "AsyncScientificOutput.on_failure must be RaiseOnFlush() or ReportOnly()") + "AsyncScientificOutput.on_failure must be RaiseOnFlush() or ReportOnly()" + ) if durability is not None and type(durability) is not DurableJournal: - raise TypeError( - "AsyncScientificOutput.durability must be DurableJournal() or None") + raise TypeError("AsyncScientificOutput.durability must be DurableJournal() or None") self.format = format self.schedule = schedule self.fields = field_rows + self.diagnostics = diagnostic_rows self.levels = selected_levels self.target = _relative_target(target, where="AsyncScientificOutput.target") self.queue_capacity = queue_capacity @@ -1114,37 +1231,53 @@ def __init__( ) def declaration_references(self) -> tuple[Handle, ...]: - return self.fields + result = list(self.fields) + for index, diagnostic in enumerate(self.diagnostics): + references = diagnostic.declaration_references() + if not isinstance(references, tuple) or any( + not isinstance(reference, Handle) for reference in references + ): + raise TypeError( + "AsyncScientificOutput diagnostics[%d].declaration_references() " + "must return a tuple of Handles" % index + ) + for reference in references: + if reference not in result: + result.append(reference) + return tuple(result) def consumer_authoring(self) -> tuple[Any, ...]: from ._consumer_authoring import ConsumerAuthoringNode from ._consumer_contracts import ConsumerKind, FailRun - return (ConsumerAuthoringNode( - label="async-scientific-output-%s" % self.target.replace("/", "-"), - kind=ConsumerKind.MONITOR, - references=self.fields, - schedule=self.schedule, - target_uri=self.target, - output_format=None, - parallel_mode=self._operation.parallel_mode, - levels=self.levels, - operation=self._operation, - failure_action=FailRun(), - ),) + return ( + ConsumerAuthoringNode( + label="async-scientific-output-%s" % self.target.replace("/", "-"), + kind=ConsumerKind.MONITOR, + references=self.fields, + schedule=self.schedule, + target_uri=self.target, + output_format=None, + parallel_mode=self._operation.parallel_mode, + levels=self.levels, + operation=self._operation, + diagnostics=self.diagnostics, + failure_action=FailRun(), + ), + ) def options(self) -> dict[str, Any]: return { "format": self._operation.consumer_data()["observer"]["format"], "schedule": self.schedule.to_data(), "fields": [reference.inspect() for reference in self.fields], + "n_diagnostics": len(self.diagnostics), "levels": self.levels.to_data(), "target": self.target, "queue_capacity": self.queue_capacity, "max_attempts": self.max_attempts, "on_failure": self.on_failure.to_data(), - "durability": ( - None if self.durability is None else self.durability.to_data()), + "durability": (None if self.durability is None else self.durability.to_data()), } @@ -1176,10 +1309,12 @@ def __init__( from ._consumer_contracts import ParallelMode from ._durable_journal import DurableJournal - if not callable(getattr(observer, "consumer_data", None)) \ - or not callable(getattr(observer, "open_session", None)): + if not callable(getattr(observer, "consumer_data", None)) or not callable( + getattr(observer, "open_session", None) + ): raise TypeError( - "LiveVisualization observer must implement consumer_data() and open_session()") + "LiveVisualization observer must implement consumer_data() and open_session()" + ) first, second = observer.consumer_data(), observer.consumer_data() if type(first) is not dict or type(second) is not dict or first != second: raise TypeError("LiveVisualization observer data must be one deterministic dict") @@ -1203,31 +1338,30 @@ def __init__( raise TypeError("LiveVisualization.mode must be an exact ParallelMode") observer_kind = first.get("observer_kind") if selected_mode in (ParallelMode.ROOT, ParallelMode.PER_RANK): - raise ValueError( - "LiveVisualization supports only SERIAL or COLLECTIVE mode") + raise ValueError("LiveVisualization supports only SERIAL or COLLECTIVE mode") if selected_mode is ParallelMode.COLLECTIVE and observer_kind != "catalyst": - raise ValueError( - "COLLECTIVE LiveVisualization requires the built-in Catalyst observer") - if isinstance(queue_capacity, bool) or type(queue_capacity) is not int \ - or queue_capacity < 1: + raise ValueError("COLLECTIVE LiveVisualization requires the built-in Catalyst observer") + if ( + isinstance(queue_capacity, bool) + or type(queue_capacity) is not int + or queue_capacity < 1 + ): raise ValueError("LiveVisualization.queue_capacity must be an integer >= 1") - if isinstance(max_attempts, bool) or type(max_attempts) is not int \ - or max_attempts < 1: + if isinstance(max_attempts, bool) or type(max_attempts) is not int or max_attempts < 1: raise ValueError("LiveVisualization.max_attempts must be an integer >= 1") if selected_mode is ParallelMode.COLLECTIVE and max_attempts != 1: raise ValueError( "MPI Catalyst live visualization requires max_attempts=1; retrying an " - "entered collective is not safe") + "entered collective is not safe" + ) selected_failure = RaiseOnFlush() if on_failure is None else on_failure if type(selected_failure) not in _LIVE_FAILURE_POLICIES: - raise TypeError( - "LiveVisualization.on_failure must be RaiseOnFlush() or ReportOnly()") + raise TypeError("LiveVisualization.on_failure must be RaiseOnFlush() or ReportOnly()") if durability is not None and type(durability) is not DurableJournal: - raise TypeError( - "LiveVisualization.durability must be DurableJournal() or None") + raise TypeError("LiveVisualization.durability must be DurableJournal() or None") operation = _LiveObserverOperation( - observer, selected_mode, queue_capacity, max_attempts, selected_failure, - durability) + observer, selected_mode, queue_capacity, max_attempts, selected_failure, durability + ) operation_data = operation.consumer_data() digest = make_identity("live-visualization-declaration", operation_data).hexdigest[:16] self.observer = observer @@ -1249,18 +1383,20 @@ def consumer_authoring(self) -> tuple[Any, ...]: from ._consumer_authoring import ConsumerAuthoringNode from ._consumer_contracts import ConsumerKind, FailRun - return (ConsumerAuthoringNode( - label="live-visualization-%s" % self._target.rsplit("/", 1)[-1], - kind=ConsumerKind.MONITOR, - references=self.fields, - schedule=self.schedule, - target_uri=self._target, - output_format=None, - parallel_mode=self.mode, - levels=self.levels, - operation=self._operation, - failure_action=FailRun(), - ),) + return ( + ConsumerAuthoringNode( + label="live-visualization-%s" % self._target.rsplit("/", 1)[-1], + kind=ConsumerKind.MONITOR, + references=self.fields, + schedule=self.schedule, + target_uri=self._target, + output_format=None, + parallel_mode=self.mode, + levels=self.levels, + operation=self._operation, + failure_action=FailRun(), + ), + ) def options(self) -> dict[str, Any]: return { @@ -1272,13 +1408,23 @@ def options(self) -> dict[str, Any]: "queue_capacity": self.queue_capacity, "max_attempts": self.max_attempts, "on_failure": self.on_failure.to_data(), - "durability": ( - None if self.durability is None else self.durability.to_data()), + "durability": (None if self.durability is None else self.durability.to_data()), } __all__ = [ - "AsyncScientificOutput", "Catalyst", "LiveFailurePolicy", "LiveVisualization", "ObserverFrame", - "ObserverProvider", "ObserverReceipt", "ObserverRun", "ObserverSession", "RaiseOnFlush", - "ReportOnly", "authenticate_observer_session", "detach_observer_frame", + "AsyncScientificOutput", + "Catalyst", + "LiveFailurePolicy", + "LiveVisualization", + "ObserverFrame", + "ObserverProvider", + "ObserverReceipt", + "ObserverRun", + "ObserverSession", + "ObserverWorkerCollectiveLost", + "RaiseOnFlush", + "ReportOnly", + "authenticate_observer_session", + "detach_observer_frame", ] diff --git a/python/pops/physics/__init__.py b/python/pops/physics/__init__.py index 829ef6dbd..3ff2cb0da 100644 --- a/python/pops/physics/__init__.py +++ b/python/pops/physics/__init__.py @@ -7,6 +7,7 @@ from .board import Model from .roles import ( + Axial, ComponentRole, Density, Energy, @@ -18,6 +19,6 @@ ) __all__ = [ - "Model", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", - "Temperature", "Velocity", + "Model", "Axial", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", + "Scalar", "Temperature", "Velocity", ] diff --git a/python/pops/physics/_authoring_recovery.py b/python/pops/physics/_authoring_recovery.py new file mode 100644 index 000000000..c517863e9 --- /dev/null +++ b/python/pops/physics/_authoring_recovery.py @@ -0,0 +1,64 @@ +"""Primitive-recovery policy authoring for symbolic physical models.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pops._ir import _wrap + +if TYPE_CHECKING: + from ._model_contract import _HyperbolicModel +else: + _HyperbolicModel = object + + +class _RecoveryMixin(_HyperbolicModel): + """Declare physical constraints consumed by native variable recovery.""" + + def recovery_admissibility(self, **constraints: Any) -> None: + """Require named primitive components to satisfy symbolic predicates. + + Keys identify components of the already-declared primitive state. Values are typed + symbolic Boolean expressions over that primitive state, for example + ``rho=rho > 0`` or ``p=p >= p_floor``. The generated C++ brick reports the first failing + primitive component, and the prepared recovery chain refuses to publish that candidate. + """ + if not self.prim_state: + raise ValueError( + "recovery_admissibility: call primitive_vars(...) first so constraints have " + "a typed component layout" + ) + if not constraints: + raise ValueError("recovery_admissibility: declare at least one named constraint") + if self._recovery_admissibility: + raise ValueError( + "recovery_admissibility: policy already declared; author the complete policy " + "in one call" + ) + + primitive_names = set(self.prim_state) + unknown_components = sorted(set(constraints) - primitive_names) + if unknown_components: + raise ValueError( + "recovery_admissibility: unknown primitive components %s; declared layout is %s" + % (unknown_components, list(self.prim_state)) + ) + + prepared = {} + for component in self.prim_state: + if component not in constraints: + continue + predicate = _wrap(constraints[component]) + if not callable(getattr(predicate, "resolve_for_amr_predicate", None)): + raise TypeError( + "recovery_admissibility[%r] requires a typed symbolic Boolean expression" + % component + ) + unknown_dependencies = sorted(set(predicate.deps()) - primitive_names) + if unknown_dependencies: + raise ValueError( + "recovery_admissibility[%r] reads values outside the primitive state: %s" + % (component, unknown_dependencies) + ) + prepared[component] = predicate + + self._recovery_admissibility = prepared diff --git a/python/pops/physics/_authoring_riemann.py b/python/pops/physics/_authoring_riemann.py index 196077251..5b27aeac8 100644 --- a/python/pops/physics/_authoring_riemann.py +++ b/python/pops/physics/_authoring_riemann.py @@ -1,7 +1,7 @@ """Authoring mixin: Riemann capabilities (HLLC, Roe) and hook overrides. Methods only; the touched attributes (``_hllc`` / ``_roe`` / ``_roe_rows`` / -``_roe_jacobian`` / ``_riemann_hook_forms``) are created by +``_roe_jacobian`` / ``_roe_entropy_policy`` / ``_riemann_hook_forms``) are created by ``HyperbolicModel.__init__``. ``roe_from_jacobian`` reuses ``flux_jacobian`` (provided by the flux mixin) on ``self``. Codegen-free and ``_pops``-free at module scope: ``_roe_validate`` (a pure marker validator) is imported LAZILY @@ -69,7 +69,7 @@ def set_riemann_hooks(self, **forms: Any) -> Any: self._riemann_hook_forms[name] = form return self - def enable_roe(self) -> None: + def enable_roe(self, *, entropy_fix: Any = None) -> None: """Emits the ROE CAPABILITY (audit balance, GENERICITY_2026-06.md point 11): ``roe_dissipation(UL, AL, UR, AR, dir)`` = ``|A_roe| (UR - UL)`` GENERATED from the block's ROLES -- the core's Roe-like solver (C++ trait HasRoeDissipation, F = 1/2(FL+FR) - 1/2 d) @@ -77,7 +77,7 @@ def enable_roe(self) -> None: - roles Density/MomentumX/MomentumY + Energy: ideal-gas Roe algebra, exact TRANSCRIPTION of the canonical C++ path (sqrt(rho)-weighted averages, gamma-1 deduced from - ``p/(E - 1/2 rho |v|^2)``, Harten entropy fix on the acoustic waves); + ``p/(E - 1/2 rho |v|^2)``, with the selected typed entropy policy on the acoustic waves); - roles Density/MomentumX/MomentumY WITHOUT Energy (isothermal / pseudo-pressure): same decomposition without the energy row, LOCAL sound speed c = sqrt(p/rho) Roe-averaged (standard generalization outside ideal gas); @@ -87,6 +87,10 @@ def enable_roe(self) -> None: REQUIRES: roles Density/MomentumX/MomentumY declared + primitive 'p' (explicit error at emission otherwise). Without a call: nothing emitted, riemann='roe' stays Euler-4-var-only. + ``entropy_fix`` is a typed ``riemann.Harten(delta)`` or + ``riemann.NoEntropyFix()`` policy. Omitting it retains the historical Harten delta 0.1; + bare numeric values are refused so the compiled provider never hides a magic scalar. + EXCLUSIVE with m.roe_dissipation: the capability from the roles and the dissipation PROVIDED by the user are two providers of the SAME roe_dissipation hook -- declaring both raises (one single provider).""" @@ -96,6 +100,13 @@ def enable_roe(self) -> None: if self._roe_jacobian is not None: raise ValueError("enable_roe : roe_from_jacobian() already declared -- one single provider " "of the roe_dissipation hook") + from pops.numerics.riemann.providers import Harten, require_entropy_policy + + self._roe_entropy_policy = require_entropy_policy( + entropy_fix, + default=Harten(), + where="enable_roe.entropy_fix", + ) self._roe = True def roe_dissipation(self, x: Any, y: Any) -> None: @@ -146,8 +157,10 @@ def roe_from_jacobian(self, *, entropy_fix: Any = None) -> None: Phi_delta(lambda) = |lambda| if |lambda| >= delta = 0.5 * (lambda^2 / delta + delta) otherwise - ``delta`` is an exact, finite, strictly-positive authoring scalar and participates in the - compiled-model identity. This configured path handles a zero eigenvalue natively; a + The option is typed: pass ``riemann.Harten(delta)`` or + ``riemann.NoEntropyFix()``. ``delta`` is an exact, finite, strictly-positive authoring + scalar and participates in the compiled-model identity. This configured path handles a + zero eigenvalue natively; a complex or non-converged spectrum is refused by the generated native residual instead of being silently replaced by another Riemann solver. Without ``entropy_fix``, the native matrix absolute value uses a scale-relative zero-mode projector for a singular real @@ -178,16 +191,19 @@ def roe_from_jacobian(self, *, entropy_fix: Any = None) -> None: "spectral provider whose declared capacity covers this state." ), ) - selected_entropy_fix = None - if entropy_fix is not None: - from ._scalars import exact_physics_scalar, native_real - selected_entropy_fix = exact_physics_scalar( - entropy_fix, where="roe_from_jacobian.entropy_fix", positive=True) - lowered = native_real( - selected_entropy_fix, where="roe_from_jacobian.entropy_fix") - if not lowered > 0.0: - raise OverflowError( - "roe_from_jacobian.entropy_fix underflows the positive pops::Real range") + from pops.numerics.riemann.providers import ( + ENTROPY_HARTEN, + NoEntropyFix, + require_entropy_policy, + ) + + policy = require_entropy_policy( + entropy_fix, + default=NoEntropyFix(), + where="roe_from_jacobian.entropy_fix", + ) + selected_entropy_fix = policy.delta if policy.kind == ENTROPY_HARTEN else None + self._roe_entropy_policy = policy self._roe_jacobian = { "x": self.flux_jacobian(0), "y": self.flux_jacobian(1), diff --git a/python/pops/physics/_authoring_vars.py b/python/pops/physics/_authoring_vars.py index 1c9fbeb72..b43852982 100644 --- a/python/pops/physics/_authoring_vars.py +++ b/python/pops/physics/_authoring_vars.py @@ -15,7 +15,7 @@ from pops._ir import Var, _wrap -from .aux import AUX_CANONICAL, AUX_NAMED_MAX, aux_total_n_aux +from .aux import AUX_CANONICAL, AUX_NAMED_BASE, AUX_NAMED_MAX, aux_total_n_aux if TYPE_CHECKING: from ._model_contract import _HyperbolicModel @@ -95,6 +95,26 @@ def _aux_locals_lines(self) -> Any: for k, n in enumerate(self.aux_extra_names)] return lines + def _flux_provider_locals_lines(self) -> Any: + """C++ locals read from the exact physical-flux provider protocol. + + Unlike ``_aux_locals_lines`` this emits no field access on the global ``pops::Aux`` POD. + Both ``pops::Aux`` (for non-FV pointwise callers) and ``BoundFluxProviders`` + implement ``flux_provider()``, so generated physical laws keep one formula and + the finite-volume route consumes only its resolved model-qualified pack. + """ + lines = [ + " const pops::Real %s = a.template flux_provider<%d>();" + % (name, AUX_CANONICAL[name]) + for name in self.aux_names + ] + lines += [ + " const pops::Real %s = a.template flux_provider<%d>();" + % (name, AUX_NAMED_BASE + index) + for index, name in enumerate(self.aux_extra_names) + ] + return lines + def _reads_aux(self) -> bool: """True if a formula reads an aux field (canonical or named): drives the naming of the Aux parameter ('a' vs anonymous) so as not to trigger an unused-parameter warning.""" diff --git a/python/pops/physics/_authoring_view.py b/python/pops/physics/_authoring_view.py index 173ce2ae3..9a4a267b9 100644 --- a/python/pops/physics/_authoring_view.py +++ b/python/pops/physics/_authoring_view.py @@ -30,7 +30,23 @@ def _aux_name_set(self) -> Any: def _aux_requirements(self, exprs: Any) -> Any: """{'aux': [...]} of the aux fields the expressions read, or {} if none.""" aux_set = self._aux_name_set() - read = sorted(_dependencies(exprs) & aux_set) + dependencies = _dependencies(exprs) + pending = [name for name in dependencies if name in self.prim_defs] + expanded = set(dependencies) + visited = set() + while pending: + name = pending.pop() + if name in visited: + continue + visited.add(name) + nested = _dependencies((self.prim_defs[name],)) + expanded.update(nested) + pending.extend( + dependency + for dependency in nested + if dependency in self.prim_defs and dependency not in visited + ) + read = sorted(expanded & aux_set) return {"aux": read} if read else {} def state_space(self, name: str = "U") -> Any: @@ -85,25 +101,68 @@ def operator_registry(self, state_name: str = "U") -> Any: reg = _model.OperatorRegistry(owner=self.owner_path) state = self.state_space(state_name) fields = self.field_space() - aux_set = self._aux_name_set() def reads_fields(exprs: Any) -> bool: - return bool(_dependencies(exprs) & aux_set) + return bool(self._aux_requirements(exprs)) + + stability_exprs = [ + *self._eig.get("x", ()), + *self._eig.get("y", ()), + ] + if self._wave_speeds is not None: + stability_exprs.extend(self._wave_speeds["x"]) + stability_exprs.extend(self._wave_speeds["y"]) + if self._ws_jacobian is not None and self._ws_jacobian["rows"] is not None: + for direction in ("x", "y"): + stability_exprs.extend( + expression + for row in self._ws_jacobian["rows"][direction] + for expression in row + ) + if self._roe_rows is not None: + stability_exprs.extend(self._roe_rows["x"]) + stability_exprs.extend(self._roe_rows["y"]) + if self._roe_jacobian is not None: + for direction in ("x", "y"): + stability_exprs.extend( + expression + for row in self._roe_jacobian[direction] + for expression in row + ) # Flux divergence (grid_operator: State -> Rate(State)). if self._flux: + exprs = [ + *self._flux.get("x", ()), + *self._flux.get("y", ()), + *stability_exprs, + ] + rf = reads_fields(exprs) reg.register(_model.Operator( "flux_default", "grid_operator", - _model.Signature([state], _model.Rate(state)), + _model.Signature([state, fields] if rf else [state], + _model.Rate(state)), capabilities={"local": False, "linear": False, "produces_rate": True, "requires_ghosts": 1, "supports_device": True, - "default": True}, + "requires_fields": rf, "default": True}, + requirements=self._aux_requirements(exprs), source=None)) for nm in sorted(self._flux_terms): + term = self._flux_terms[nm] + exprs = [ + *term.get("x", ()), + *term.get("y", ()), + *stability_exprs, + ] + rf = reads_fields(exprs) reg.register(_model.Operator( - nm, "grid_operator", _model.Signature([state], _model.Rate(state)), + nm, "grid_operator", + _model.Signature([state, fields] if rf else [state], + _model.Rate(state)), capabilities={"local": False, "linear": False, "produces_rate": True, - "requires_ghosts": 1, "supports_device": True}, + "requires_ghosts": 1, "supports_device": True, + "requires_fields": rf}, + requirements=self._aux_requirements(exprs), source=None)) # Local sources (local_source: State[, Fields] -> Rate(State)). diff --git a/python/pops/physics/_coupled_abi.py b/python/pops/physics/_coupled_abi.py index bf9f38489..ae54ef485 100644 --- a/python/pops/physics/_coupled_abi.py +++ b/python/pops/physics/_coupled_abi.py @@ -5,6 +5,9 @@ ROLE_TO_CANONICAL = { + "AxialX": "axial_x", + "AxialY": "axial_y", + "AxialZ": "axial_z", "Density": "density", "MomentumX": "momentum_x", "MomentumY": "momentum_y", diff --git a/python/pops/physics/_facade.py b/python/pops/physics/_facade.py index a2ca573e2..a5d815401 100644 --- a/python/pops/physics/_facade.py +++ b/python/pops/physics/_facade.py @@ -24,7 +24,8 @@ class Model(PhysicsFreezable, _FacadeCompileMixin): """ _physics_mutators = frozenset({ - "conservative_vars", "primitive", "primitive_vars", "aux", "aux_field", + "conservative_vars", "primitive", "primitive_vars", "recovery_admissibility", "aux", + "aux_field", "conservative_from", "flux", "flux_term", "eigenvalues", "wave_speeds", "wave_speeds_from_jacobian", "stability_speed", "stability_dt", "source", "source_term", "linear_source", "rate_operator", "rate", "field_solve", @@ -94,6 +95,16 @@ def primitive_vars(self, *vars: Any, roles: Any = None, **named: Any) -> Any: self._m.set_primitive_state(*vars, roles=roles) return None + def recovery_admissibility(self, **constraints: Any) -> None: + """Declare fail-closed physical constraints for primitive recovery candidates. + + Each keyword names one component of the primitive layout and maps it to a symbolic Boolean + expression over primitive variables. Example: ``rho=rho > 0, p=p > 0``. Finite candidates + that violate a declared predicate are rejected by the native prepared recovery chain before + any solution or warm-start publication. + """ + self._m.recovery_admissibility(**constraints) + def aux(self, name: Any) -> Any: """CANONICAL auxiliary field (must be a key of AUX_CANONICAL: phi/grad_x/grad_y/B_z/T_e).""" return self._m.aux(name) @@ -340,12 +351,14 @@ def set_riemann_hooks(self, **forms: Any) -> Any: self._m.set_riemann_hooks(**forms) return self - def enable_roe(self) -> None: + def enable_roe(self, *, entropy_fix: Any = None) -> None: """Emits the ROE capability (roe_dissipation = ``|A_roe| dU`` generated from the ROLES + primitive 'p'): riemann='roe' becomes available for this model EVEN outside 4-variable Euler (without Energy: c = sqrt(p/rho) averaged Roe-style; components outside the fluid - roles = passive scalars on the entropy wave). Delegates to HyperbolicModel.enable_roe.""" - self._m.enable_roe() + roles = passive scalars on the entropy wave). ``entropy_fix`` is a typed + ``riemann.Harten(delta)`` or ``riemann.NoEntropyFix()`` policy. Delegates to + HyperbolicModel.enable_roe.""" + self._m.enable_roe(entropy_fix=entropy_fix) def roe_dissipation(self, x: Any, y: Any) -> None: """Roe dissipation PROVIDED by the user (outside the fluid roles): n_vars expressions per @@ -363,8 +376,9 @@ def flux_jacobian(self, dir: Any) -> Any: def roe_from_jacobian(self, *, entropy_fix: Any = None) -> None: """Generic moment Roe: emits roe_dissipation = ``|A| (UR-UL)`` with A the flux Jacobian at - Uavg = 1/2(UL+UR). ``entropy_fix=delta`` selects the generic Harten spectral function - ``Phi_delta(A)``; ``None`` uses the matrix absolute value with a real-singular zero-mode + Uavg = 1/2(UL+UR). ``entropy_fix=riemann.Harten(delta)`` selects the generic Harten + spectral function ``Phi_delta(A)``; ``riemann.NoEntropyFix()`` (or the omitted default) + uses the matrix absolute value with a real-singular zero-mode projector. Both refuse complex/non-converged spectra and never substitute Rusanov. Roles-free (no Density/Momentum, no 'p'): makes riemann='roe' available for a moment hierarchy. Exclusive with enable_roe / roe_dissipation. diff --git a/python/pops/physics/_facade_compile.py b/python/pops/physics/_facade_compile.py index 746de5ad1..7da22eef3 100644 --- a/python/pops/physics/_facade_compile.py +++ b/python/pops/physics/_facade_compile.py @@ -40,11 +40,27 @@ def __pops_compiler_lowering__(self) -> Any: facade=self, ) + def __pops_bind_component_provider_packs__(self, packs: Any) -> None: + """Bind the exact Module provider resolution to both native-emitter carriers.""" + from pops.codegen.component_provider_packs import ComponentProviderPacks + + if type(packs) is not ComponentProviderPacks: + raise TypeError( + "compiler provider-pack binding requires exact ComponentProviderPacks" + ) + packs.attach(self) + packs.attach(self._m) + def __pops_native_loader_source__( self, *, name: Any = None, target: str = "system", hoist_reciprocals: bool = False, ) -> str: """Emit a native package without exposing the private formula carrier.""" + from pops.codegen.component_provider_packs import resolve_component_provider_packs + + self.__pops_bind_component_provider_packs__( + resolve_component_provider_packs(self.module) + ) return self._m.emit_cpp_native_loader( name=name, target=target, hoist_reciprocals=hoist_reciprocals) @@ -100,15 +116,18 @@ def compile(self, so_path: Any = None, include: Any = None, backend: Any = "prod ) from pops.codegen.abi import _abi_key_python from pops.codegen._compile_emit import compiled_capability_flags + from pops.codegen.module_emit_riemann import has_characteristic_no_inflow_provider from pops.codegen.loader import CompiledModel from pops.codegen._compiled_model_identity import model_compile_identity from pops.codegen._backends import lower_backend + from pops.numerics.riemann.providers import authoring_provider_evidence from pops.numerics.riemann.waves import provider_of backend = lower_backend(backend) if target not in ("system", "amr_system"): raise ValueError("compile: target 'system' | 'amr_system' (got %r)" % (target,)) m = self._m + riemann_evidence = authoring_provider_evidence(self) wave_speed_provider = provider_of(self) eff_std = std if std is not None else loader_cxx_std() eff_cxx = _native_kokkos_compiler(cxx) @@ -142,6 +161,10 @@ def compile(self, so_path: Any = None, include: Any = None, backend: Any = "prod "wave_speed_provider": ( "none" if wave_speed_provider is None else wave_speed_provider.kind ), + "hllc_provider": riemann_evidence.hllc_provider or "none", + "roe_provider": riemann_evidence.roe_provider or "none", + "roe_entropy_policy": riemann_evidence.roe_entropy_policy or "none", + "roe_entropy_delta": riemann_evidence.roe_entropy_delta or "none", }, flags=[_platform_cache_key(), *_dsl_optflags(), "hoist_reciprocals=%d" % bool(hoist_reciprocals)], @@ -175,9 +198,14 @@ def compile(self, so_path: Any = None, include: Any = None, backend: Any = "prod params=self.params, caps=compiled_capability_flags(backend), abi_key=abi_key, model_hash=model_hash, definition_identity=model_compile_identity(self), - cxx=eff_cxx, std=eff_std, hllc=m._hllc, - roe=(m._roe or getattr(m, '_roe_rows', None) is not None - or getattr(m, '_roe_jacobian', None) is not None), + cxx=eff_cxx, std=eff_std, + hllc=riemann_evidence.hllc_provider is not None, + roe=riemann_evidence.roe_provider is not None, + hllc_provider=riemann_evidence.hllc_provider, + roe_provider=riemann_evidence.roe_provider, + roe_entropy_policy=riemann_evidence.roe_entropy_policy, + roe_entropy_delta=riemann_evidence.roe_entropy_delta, + characteristic_no_inflow=has_characteristic_no_inflow_provider(m), aux_extra_names=m.aux_extra_names, wave_speeds=wave_speed_provider is not None, wave_speed_provider=( diff --git a/python/pops/physics/_model.py b/python/pops/physics/_model.py index 0669ef5ab..a888b2498 100644 --- a/python/pops/physics/_model.py +++ b/python/pops/physics/_model.py @@ -22,6 +22,7 @@ from pops.model.ownership import OwnerKind, OwnerPath from ._authoring_vars import _VariablesMixin +from ._authoring_recovery import _RecoveryMixin from ._authoring_flux import _FluxMixin from ._authoring_sources import _SourceMixin from ._authoring_riemann import _RiemannMixin @@ -32,7 +33,8 @@ from ._freeze import PhysicsFreezable -class HyperbolicModel(PhysicsFreezable, _VariablesMixin, _FluxMixin, _SourceMixin, _RiemannMixin, +class HyperbolicModel(PhysicsFreezable, _VariablesMixin, _RecoveryMixin, _FluxMixin, _SourceMixin, + _RiemannMixin, _OperatorViewMixin, _EvalMixin, _RuntimeParamsMixin, _CodegenMixin): """Hyperbolic model written as FORMULAS: conservative variables, primitives (defined by expressions), flux, eigenvalues, source, elliptic contribution. cf. module docstring. @@ -42,6 +44,7 @@ class HyperbolicModel(PhysicsFreezable, _VariablesMixin, _FluxMixin, _SourceMixi _physics_mutators = frozenset({ "cons", "conservative_vars", "primitive", "aux", "aux_field", + "recovery_admissibility", "set_primitive_state", "set_conservative_from", "set_flux", "set_eigenvalues", "flux_term", "set_wave_speeds", "set_wave_speeds_from_jacobian", "set_gamma", "set_source", "set_elliptic_rhs", "elliptic_field", "source_term", "linear_source", @@ -111,6 +114,7 @@ def __init__(self, name: Any) -> None: } self.cons_names = [] self.prim_defs = {} # name -> Expr (in terms of the cons / previous prims / aux) + self._recovery_admissibility = {} # primitive component -> symbolic Boolean predicate self.aux_names = [] # CANONICAL aux fields read (phi/grad/B_z/T_e), cf. AUX_CANONICAL self.aux_extra_names = [] # NAMED aux fields (aux_field): order = index AUX_NAMED_BASE + k self._flux = {} # "x" / "y" -> list of Expr (one per conservative component) @@ -155,6 +159,8 @@ def __init__(self, name: Any) -> None: self._roe_rows = None # {"x": [Expr], "y": [Expr]}: roe_dissipation PROVIDED (outside roles) self._roe_jacobian = None # {"x"/"y": [[Expr]], "entropy_fix": exact scalar | None}: # generic dense-Jacobian Roe provider. + self._roe_entropy_policy = None # exact immutable riemann.RoeEntropyPolicy selected by + # enable_roe / roe_from_jacobian; direct rows own theirs. self.prim_state = [] # ordered names of the primitive state (Prim layout); for the codegen self.cons_from = None # list of Expr: conservative in terms of the primitives (to_conservative) self.cons_roles = None # explicit override of the conservative roles (otherwise canonical mapping) diff --git a/python/pops/physics/_model_contract.py b/python/pops/physics/_model_contract.py index 3ae804449..e39727a9e 100644 --- a/python/pops/physics/_model_contract.py +++ b/python/pops/physics/_model_contract.py @@ -32,6 +32,7 @@ class _HyperbolicModel: prim_defs: Any prim_roles: Any prim_state: Any + _recovery_admissibility: Any aux_names: Any aux_extra_names: Any gamma: Any @@ -52,6 +53,7 @@ class _HyperbolicModel: _roe: Any _roe_rows: Any _roe_jacobian: Any + _roe_entropy_policy: Any _riemann_hook_forms: Any _hllc: Any _src_freq: Any diff --git a/python/pops/physics/board.py b/python/pops/physics/board.py index 7246baf8d..f4cdf76c7 100644 --- a/python/pops/physics/board.py +++ b/python/pops/physics/board.py @@ -60,7 +60,7 @@ class Model(PhysicsFreezable, _BoardCompileMixin, _RateAuthoringMixin, _RiemannA "operator", "riemann", "invariant", "rate", "finite_volume_rate", "coupled_rate", "field_provider", "local_transform", "projection", "wave_speeds", "wave_speeds_from_jacobian", - "roe_from_jacobian", + "roe_from_jacobian", "recovery_admissibility", }) def __init__(self, name: Any, *, frame: Any = None) -> None: @@ -515,6 +515,24 @@ def primitive_state( self._dsl._invalidate_authoring_views() self._invalidate_authoring_views() + def recovery_admissibility(self, **constraints: Any) -> None: + """Declare physical constraints for native primitive-recovery candidates. + + Each keyword names a component of the model's primitive coordinate system and maps it to a + symbolic Boolean expression over that coordinate system. The single-state native route + compiles these predicates into the prepared recovery plan; multi-state recovery policies + require a species-qualified provider and are therefore rejected here. + """ + if self._multi_module is not None: + raise ValueError( + "recovery_admissibility requires a single-state model; multi-species policies " + "must be supplied by a species-qualified recovery provider" + ) + self._dsl.recovery_admissibility( + **{name: self._to_expr(predicate) for name, predicate in constraints.items()} + ) + self._invalidate_authoring_views() + def scalar(self, name: Any, expr: Any) -> Any: """Define a named derived scalar (e.g. pressure, sound speed).""" value = self._dsl.primitive(require_name(name, "scalar name"), expr) @@ -890,7 +908,7 @@ def wave_speeds_from_jacobian( self._invalidate_authoring_views() def roe_from_jacobian(self, *, entropy_fix: Any = None) -> None: - """Install the generic dense-Jacobian Roe provider, with an optional Harten fix.""" + """Install dense-Jacobian Roe with a typed Harten/NoEntropyFix policy.""" self._dsl.roe_from_jacobian(entropy_fix=entropy_fix) self._invalidate_authoring_views() @@ -1162,14 +1180,9 @@ def lower(self) -> Any: compiled = pops.compile(resolved) ``pops.compile`` captures the operator-first Module and validates ONCE internally; ``lower`` - (and its ``to_module`` alias) stay ADVANCED / inspection-only. Identical to :pyattr:`module`.""" + stays ADVANCED / inspection-only and is identical to :pyattr:`module`.""" return self.module - # Spec 5 sec.11 alias: physics.Model.to_module() == physics.Model.lower(). ADVANCED / inspection only - # (ADC-557): the standard case.block(model=m) -> pops.compile flow captures the Module itself; - # neither is REQUIRED (pops.compile does the lowering once, internally). - to_module = lower - # --- introspection --- # --- internals --- diff --git a/python/pops/physics/roles.py b/python/pops/physics/roles.py index e11494631..e0543bf4e 100644 --- a/python/pops/physics/roles.py +++ b/python/pops/physics/roles.py @@ -9,8 +9,9 @@ _ROLE_TOKEN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _RESERVED_ROLE_TOKENS = frozenset({"Custom"}) _CANONICAL_ROLE_TOKENS = frozenset({ - "Density", "Energy", "MomentumX", "MomentumY", "MomentumZ", "Pressure", "Scalar", - "Temperature", "VelocityX", "VelocityY", "VelocityZ", + "AxialX", "AxialY", "AxialZ", "Density", "Energy", "MomentumX", "MomentumY", + "MomentumZ", "Pressure", "Scalar", "Temperature", "VelocityX", "VelocityY", + "VelocityZ", }) @@ -85,6 +86,22 @@ def native_name(self) -> str: return "Velocity" + str(self.axis.name).upper() +@dataclass(frozen=True, slots=True) +class Axial(ComponentRole): + """One component of an axial (pseudo-)vector under reflection.""" + + axis: Any + + def __post_init__(self) -> None: + name = getattr(self.axis, "name", None) + if name not in ("x", "y", "z"): + raise TypeError("Axial axis must be a typed Cartesian x/y/z axis") + + @property + def native_name(self) -> str: + return "Axial" + str(self.axis.name).upper() + + @dataclass(frozen=True, slots=True) class Pressure(ComponentRole): @property @@ -107,6 +124,6 @@ def native_name(self) -> str: __all__ = [ - "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", + "Axial", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", "Temperature", "Velocity", "native_role_token", ] diff --git a/python/pops/release.py b/python/pops/release.py index 207ed714d..28cc17d8a 100644 --- a/python/pops/release.py +++ b/python/pops/release.py @@ -10,6 +10,8 @@ CAPABILITY_VOCABULARY_VERSION, CHECKPOINT_ENVELOPE_SCHEMA_VERSION, COMPONENT_CATALOG_SCHEMA_VERSION, + COMPONENT_CATALOG_SEMANTIC_SHA256, + COMPONENT_CATALOG_SHA256, COMPONENT_INTERFACE_ABI_VERSION, COMPONENT_MANIFEST_SCHEMA_VERSION, COMPONENT_REGISTRY_VERSION, @@ -61,6 +63,8 @@ def contract() -> MappingProxyType[str, Any]: "semantic_ir_version": SEMANTIC_IR_VERSION, "normalization_version": NORMALIZATION_VERSION, "component_catalog_schema_version": COMPONENT_CATALOG_SCHEMA_VERSION, + "component_catalog_sha256": COMPONENT_CATALOG_SHA256, + "component_catalog_semantic_sha256": COMPONENT_CATALOG_SEMANTIC_SHA256, "component_manifest_schema_version": COMPONENT_MANIFEST_SCHEMA_VERSION, "component_registry_version": COMPONENT_REGISTRY_VERSION, "capability_vocabulary_version": CAPABILITY_VOCABULARY_VERSION, diff --git a/python/pops/runtime/_amr_bind_lowering.py b/python/pops/runtime/_amr_bind_lowering.py index 3d51ebc5d..a409b84fd 100644 --- a/python/pops/runtime/_amr_bind_lowering.py +++ b/python/pops/runtime/_amr_bind_lowering.py @@ -44,21 +44,27 @@ def _regrid_every(data: dict[str, Any]) -> int: def _native_amr_grid_values( - data: Any, + native_layout: Any, ) -> tuple[ tuple[int, int], tuple[float, float], tuple[float, float], tuple[bool, bool] ]: - """Authenticate one Cartesian grid without collapsing its axis topology.""" - from pops.mesh.grid import CartesianGrid - - grid = CartesianGrid.from_dict(data) - periodic_axes = grid.topology.periodic_axes - periodic_indices = {axis.index for axis in periodic_axes} + """Authenticate the exact layout-derived geometry before allocating ``AmrSystemConfig``.""" + from pops.mesh import NativeSpatialLayout + from pops.mesh._layout_plan_contracts import CARTESIAN_2D_COORDINATES + + if type(native_layout) is not NativeSpatialLayout: + raise TypeError("native AMR lowering requires an exact NativeSpatialLayout") + if native_layout.dimension != 2 \ + or native_layout.coordinate_system != CARTESIAN_2D_COORDINATES \ + or native_layout.centering != "cell" \ + or native_layout.decomposition.get("kind") != "adaptive": + raise NotImplementedError( + "native AmrSystemConfig currently supports only 2D cell-centered Cartesian AMR") return ( - grid.cells, - grid.frame.lower, - grid.frame.upper, - (0 in periodic_indices, 1 in periodic_indices), + native_layout.shape, + native_layout.lower, + native_layout.upper, + native_layout.periodicity, ) @@ -134,13 +140,18 @@ def _native_load_balance_options(options: dict[str, Any]) -> dict[str, Any]: return result -def amr_config_from_layout(layout: Any, *, hierarchy: Any = None) -> Any: +def amr_config_from_layout( + layout: Any, + *, + hierarchy: Any = None, + native_layout: Any, +) -> Any: """Build ``AmrSystemConfig`` without inferring or dropping authored facts.""" from pops._bootstrap import AmrSystemConfig from pops.mesh._amr import ResolvedHierarchy data = _runtime_data(layout) - cells, lower, upper, periodicity = _native_amr_grid_values(data["grid"]) + cells, lower, upper, periodicity = _native_amr_grid_values(native_layout) lengths = (upper[0] - lower[0], upper[1] - lower[1]) if type(hierarchy) is not ResolvedHierarchy: raise TypeError("adaptive runtime requires an exact resolved hierarchy") diff --git a/python/pops/runtime/_amr_checkpoint_contract.py b/python/pops/runtime/_amr_checkpoint_contract.py index 152a0fa0a..a60cbbefc 100644 --- a/python/pops/runtime/_amr_checkpoint_contract.py +++ b/python/pops/runtime/_amr_checkpoint_contract.py @@ -8,7 +8,7 @@ from pops.identity import make_identity -_SCHEMA = 4 +_SCHEMA = 5 _GUARANTEE = "bit_identical_accepted_state" _CONTRACT_KEYS = { "schema_version", @@ -17,6 +17,7 @@ "ledger", "interface_ledger", "clocks", + "temporal_partition", "synchronization", "history_qualifications", "level_relations", @@ -39,9 +40,7 @@ def restart_topology_image(sim): """Return the compact identity of one accepted AMR hierarchy.""" levels = int(sim.n_levels()) boxes = [[int(value) for value in box] for box in sim.patch_boxes()] - owners = [ - [int(rank) for rank in sim.level_owner_ranks(level)] for level in range(levels) - ] + owners = [[int(rank) for rank in sim.level_owner_ranks(level)] for level in range(levels)] topology_identity = make_identity( "restart-topology", { @@ -94,6 +93,7 @@ def contract_for(sim): "entries": interface_flux_ledger, }, "clocks": _rows(sim.program_clock_manifest()), + "temporal_partition": _rows(sim.program_temporal_partition_manifest()), "synchronization": _rows(sim.program_sync_manifest()), "history_qualifications": _rows(sim.program_accepted_state_manifest()), "level_relations": relations, @@ -116,6 +116,25 @@ def _decode_contract(payload): return contract +def checkpoint_temporal_partition_kind(payload): + """Return the exact accepted temporal-partition kind before native restart mutation.""" + contract = _decode_contract(payload) + rows = contract["temporal_partition"] + if ( + not isinstance(rows, list) + or not rows + or not isinstance(rows[0], list) + or len(rows[0]) != 7 + or rows[0][0] != "summary" + or rows[0][1] not in {"global", "cell_local"} + ): + raise ValueError("restart: AMR temporal-partition contract has an invalid summary") + for row in rows[1:]: + if not isinstance(row, list) or len(row) != 3 or row[0] != "rung": + raise ValueError("restart: AMR temporal-partition contract has an invalid rung row") + return rows[0][1] + + def preflight_contract(sim, payload): """Authenticate shape and static provenance before the native restart transaction.""" import numpy as np @@ -159,9 +178,7 @@ def _validate_interface_ledger_against_live_hierarchy(sim, contract): blocks = int(sim.n_blocks()) for row in contract["interface_ledger"]["entries"]: if len(row) != 28: - raise ValueError( - "restart: restored AMR interface-flux audit has an invalid native row" - ) + raise ValueError("restart: restored AMR interface-flux audit has an invalid native row") coarse_level, fine_level = int(row[2]), int(row[3]) left_block, right_block = int(row[21]), int(row[22]) if ( diff --git a/python/pops/runtime/_amr_checkpoint_v3.py b/python/pops/runtime/_amr_checkpoint_v3.py index b83678cab..8969f0d8f 100644 --- a/python/pops/runtime/_amr_checkpoint_v3.py +++ b/python/pops/runtime/_amr_checkpoint_v3.py @@ -345,9 +345,7 @@ def _capture_v3(owner, sim, prepared): if prepared.local_program_state: rematerialize = getattr(sim, "rematerialize_program_accepted_state", None) if not callable(rematerialize): - raise TypeError( - "checkpoint AMR engine lacks accepted-state consensus validation" - ) + raise TypeError("checkpoint AMR engine lacks accepted-state consensus validation") # Re-materializing onto the unchanged ownership is a non-mutating validation pass. It # authenticates every rank-independent accepted field, including persistent tagging, # before any rank may seal or publish a checkpoint. @@ -447,7 +445,10 @@ def prepare_v3( """ import numpy as np from pops.output._checkpoint_collective import checkpoint_topology - from pops.runtime._amr_checkpoint_contract import preflight_contract + from pops.runtime._amr_checkpoint_contract import ( + checkpoint_temporal_partition_kind, + preflight_contract, + ) from pops.runtime._program_cadence_checkpoint import prepare_program_cadence from pops.runtime._temporal_restart import TemporalRestartState @@ -488,6 +489,17 @@ def prepare_v3( raise ValueError( "restart: RegridOnRestart requires an artifact-backed compiled AMR Program" ) + if checkpoint_temporal_partition_kind(d) == "cell_local": + if checkpoint_ranks != current_ranks: + raise ValueError( + "restart: cell-local temporal partitions require the recorded MPI cardinality " + "until ownership rematerialization is implemented" + ) + if hierarchy_mode != "restore_recorded_hierarchy": + raise ValueError( + "restart: cell-local temporal partitions require RestoreRecordedHierarchy " + "until regrid rematerialization is implemented" + ) checkpoint_levels, checkpoint_configured_levels = _checkpoint_amr_level_envelope(sim, d) from pops.runtime._amr_checkpoint_topology import recorded_rank_topology @@ -1060,9 +1072,7 @@ def apply_v3(owner, sim, prepared): "recorded accepted-contract identity", lambda: _restart_accepted_contract_identity(sim), ) - before_history_identity = _restart_history_identity( - owner, sim, phase="recorded hierarchy" - ) + before_history_identity = _restart_history_identity(owner, sim, phase="recorded hierarchy") before_integrals = _restart_composite_integrals(owner, sim, phase="recorded hierarchy") _restart_collective_phase( owner, diff --git a/python/pops/runtime/_amr_system.py b/python/pops/runtime/_amr_system.py index 1e4c3319f..045d2d903 100644 --- a/python/pops/runtime/_amr_system.py +++ b/python/pops/runtime/_amr_system.py @@ -4,7 +4,7 @@ ``_amr_system_equation`` (add_equation + named-aux), ``_amr_system_io`` (private accepted-state codec and restore transaction), ``_amr_system_program`` (compiled time-Program install / params / transaction) and ``_amr_system_install`` (the ``pops.bind`` install seam + field-solver / aux helpers) -mixins; this module composes them and keeps the constructor plus native block/coupling glue. +mixins; this module composes them and keeps the constructor plus coupling glue. """ from __future__ import annotations @@ -15,19 +15,12 @@ from pops.runtime import _threading from pops.runtime._lifecycle import ( FROZEN_STRUCTURAL as _FROZEN_STRUCTURAL, + RETIRED_NATIVE_PASSTHROUGH as _RETIRED_NATIVE_PASSTHROUGH, freeze_error as _freeze_error, guard_assembling as _guard_assembling, _LifecycleMixin, ) from pops.runtime._numeric import native_real -from pops.runtime._engine_descriptors import Spatial, Explicit -from pops.runtime.defaults import ( - NEWTON_DEFAULT_ABS_TOL, - NEWTON_DEFAULT_DAMPING, - NEWTON_DEFAULT_FD_EPS, - NEWTON_DEFAULT_MAX_ITERS, - NEWTON_DEFAULT_REL_TOL, -) from pops.runtime._amr_system_equation import _AmrSystemEquation from pops.runtime._amr_system_install import _AmrSystemInstall from pops.runtime._amr_system_io import _AmrSystemIO @@ -294,94 +287,6 @@ def coarse_total_boxes(self) -> Any: """ return self._s.coarse_total_boxes() - def add_block(self, name: Any, model: Any, spatial: Any = None, time: Any = None) -> Any: - """Installs an evolved block composed of NATIVE BRICKS on the shared AMR hierarchy. - - Low-level runtime seam. The documented PUBLIC path is the typed ``pops.Case`` assembly - resolved with ``pops.resolve(case, layout=...)``, compiled with ``pops.compile(plan)`` and - wired by ``pops.bind`` (which calls this internally); ``add_block`` stays private. - - Refined counterpart of System.add_block. Every block count uses the same AmrRuntime engine; - subsequent blocks are co-located on the shared hierarchy and contribute to the summed - system-Poisson right-hand side. - In multi-block the name indexes set_density(name) / mass(name) / density(name). The arguments - are marshaled to the C++ facade (AmrSystem::add_block), which validates the block against the model. - For a compiled DSL model (.so) or a dispatch on the model type, use add_equation. - - @param name unique name of the block. - @param model private ``ModelSpec`` engine value composed from native bricks. - @param spatial private engine adapter lowered from ``pops.numerics.FiniteVolume(...)`` - (default minmod + rusanov + conservative). The native seam accepts limiter tokens - none / minmod / vanleer / weno5, Riemann fluxes rusanov / hll / hllc / roe, and - conservative / primitive variables. This low-level WENO5 stencil route is not an AMR - availability guarantee: a resolved Case also requires an owner-qualified coarse/fine - provider certified for order 5 and ghost depth 3. The native catalogue contains that - provider and resolves it from the reconstruction requirements; no lower-order - coarse/fine fallback is permitted. - @param time private engine policy. Public authoring uses an explicit ``pops.Program`` or a - ``pops.lib.time`` factory. The installed typed Program is the sole time authority. - Until the AMR target provides a typed local implicit primitive, non-empty partial masks, - non-default Newton controls, and Newton diagnostics fail closed. The spatial runtime - never stores them or manufactures an implicit step/report. - spatial.positivity_floor > 0 (ADC-259) floors the Density-role face states AND the - coarse-fine fine ghost means to >= floor on the AMR transport (Zhang-Shu, parity with the - uniform System). Guarantee = face / ghost-state Density positivity only (order-1 fallback), - NOT updated-mean nor pressure positivity. A model without a Density role rejects it at the - first step. The COMPILED .so path carries it too now (ADC-322): a loader regenerated against - the current headers marshals the floor (add_equation on a CompiledModel, add_native_block). - """ - _guard_assembling(self, "add_block") # frozen once pops.bind completes (ADC-592) - spatial = spatial if spatial is not None else Spatial() - time = time if time is not None else Explicit() - # positivity_floor (ADC-259) IS now wired on the AMR transport (Density-role face states + - # C/F fine ghost means). Threaded to AmrSystem::add_block below; the compiled .so path carries - # it too (ADC-322, regenerated loader). The C++ side rejects it on a model without a Density role. - spatial_options: dict[str, bool | float] = { - "wave_speed_cache": bool(getattr(spatial, "wave_speed_cache", False)), - } - if getattr(spatial, "weno_epsilon", None) is not None: - spatial_options["weno_epsilon"] = native_real( - spatial.weno_epsilon, where="AmrSystem.add_block.weno_epsilon" - ) - # Forward the complete authoring request to the native contract. Cadence remains meaningful - # to Program/CFL normalization; unsupported partial masks and non-default Newton requests - # fail closed there instead of becoming inert spatial-runtime state. - self._s.add_block( - name, - model, - spatial.limiter, - spatial.flux, - spatial.recon, - time.kind, - getattr(time, "substeps", 1), - getattr(time, "stride", 1), - getattr(time, "implicit_vars", []), - getattr(time, "implicit_roles", []), - getattr(time, "newton_max_iters", NEWTON_DEFAULT_MAX_ITERS), - native_real( - getattr(time, "newton_rel_tol", NEWTON_DEFAULT_REL_TOL), - where="AmrSystem.add_block.newton_rel_tol", - ), - native_real( - getattr(time, "newton_abs_tol", NEWTON_DEFAULT_ABS_TOL), - where="AmrSystem.add_block.newton_abs_tol", - ), - native_real( - getattr(time, "newton_fd_eps", NEWTON_DEFAULT_FD_EPS), - where="AmrSystem.add_block.newton_fd_eps", - ), - native_real( - getattr(time, "newton_damping", NEWTON_DEFAULT_DAMPING), - where="AmrSystem.add_block.newton_damping", - ), - getattr(time, "newton_diagnostics", False), - native_real( - getattr(spatial, "positivity_floor", 0.0), - where="AmrSystem.add_block.positivity_floor", - ), - **spatial_options, - ) - def field(self, name: Any) -> Any: """Return the solved potential of a NAMED elliptic field as a ``(ny, nx)`` array. @@ -496,6 +401,11 @@ def program_report(self) -> Any: return build_program_report(self) def __getattr__(self, attr: Any) -> Any: + if attr in _RETIRED_NATIVE_PASSTHROUGH: + raise AttributeError( + "AmrSystem.%s is not an authoring route; declare the block with " + "pops.Case.block(...)" % attr + ) # RUNTIME FREEZE (ADC-592): once bound, refuse a native STRUCTURAL setter reached through the # passthrough (install_program / ...) with the bind-vocabulary # RuntimeError, so the bypass is closed even under a prebuilt .so whose C++ setters are not yet diff --git a/python/pops/runtime/_amr_system_contract.py b/python/pops/runtime/_amr_system_contract.py index fe26a7caa..db24af70e 100644 --- a/python/pops/runtime/_amr_system_contract.py +++ b/python/pops/runtime/_amr_system_contract.py @@ -43,7 +43,6 @@ def set_history_persistence(self, *args: Any, **kwargs: Any) -> Any: ... def last_restart_regrid_receipt(self) -> Any: ... def add_equation(self, *args: Any, **kwargs: Any) -> Any: ... - def add_block(self, *args: Any, **kwargs: Any) -> Any: ... def set_poisson(self, *args: Any, **kwargs: Any) -> Any: ... def _set_poisson_native(self, *args: Any, **kwargs: Any) -> Any: ... def set_density(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/python/pops/runtime/_amr_system_equation.py b/python/pops/runtime/_amr_system_equation.py index 14ae748ef..dc8d7745f 100644 --- a/python/pops/runtime/_amr_system_equation.py +++ b/python/pops/runtime/_amr_system_equation.py @@ -98,10 +98,11 @@ def add_equation( Dispatch: - - a private ``ModelSpec`` -> add_block (native bricks composed on the hierarchy); + - a private ``ModelSpec`` -> the native ``AmrSystem::add_block`` ABI (bricks composed on + the hierarchy); - a CompiledModel(backend='production', target='amr_system') installs a package whose loader inlines add_compiled_model(AmrSystem&), so the block runs - the SAME AMR hierarchy as add_block (conservative reflux, regrid), ZERO-COPY. + the same AMR hierarchy as the native-brick ABI (conservative reflux, regrid), ZERO-COPY. The ``time`` value carried by a block is immutable Program-authoring metadata, not an executable method in the AMR spatial runtime. The compiled ``pops.Program`` installed after @@ -110,7 +111,7 @@ def add_equation( Newton controls, or diagnostics fails closed until a typed implicit Program primitive exists. It never reaches a private backward-Euler/Newton engine. ``recon="primitive"`` and fluxes ``roe`` / ``hllc`` use the same compiled spatial dispatch as - ``add_block``. The low-level dispatch also contains the WENO5-Z stencil and its three-cell + the native-brick branch. The low-level dispatch also contains the WENO5-Z stencil and its three-cell halo, but the resolved Case route accepts it only when the owner-qualified coarse/fine provider certifies order 5 and ghost depth 3. The native catalogue resolves that provider from the reconstruction requirements and never lowers the coarse/fine interface order @@ -118,7 +119,7 @@ def add_equation( MULTIRATE CADENCE (stride) and PARTIAL IMEX MASK (implicit_vars / implicit_roles): - - private ``ModelSpec`` path: FORWARDED to ``AmrSystem::add_block``. Cadence remains part of + - private ``ModelSpec`` path: forwarded to ``AmrSystem::add_block``. Cadence remains part of Program/CFL normalization; non-empty masks and non-default Newton requests fail closed until the AMR target exposes their typed Program primitive; - CompiledModel production path (.so): explicitly REJECTED (ValueError). The flat ABI of the @@ -150,7 +151,7 @@ def add_equation( where="AmrSystem.add_equation.substeps", ) - # --- ModelSpec: native bricks composed -> add_block (existing path) --- + # --- ModelSpec: native bricks composed through the sole Python dispatch seam --- # Forward the complete authoring request to the native contract. Unsupported masks and # Newton controls are rejected there rather than retained by the spatial runtime. if isinstance(model, ModelSpec): diff --git a/python/pops/runtime/_amr_system_install.py b/python/pops/runtime/_amr_system_install.py index 46beaaac9..f3c388433 100644 --- a/python/pops/runtime/_amr_system_install.py +++ b/python/pops/runtime/_amr_system_install.py @@ -25,13 +25,16 @@ class _PreparedAmrFieldSolverInstall: """AMR native primitives consumed by provider-owned field-solver installers.""" - def __init__(self, engine: Any, field_plan: Any) -> None: + def __init__(self, engine: Any, field_plan: Any, install_plan: Any) -> None: self.engine = engine self.field_plan = field_plan + self.install_plan = install_plan self.options = field_plan.native_install_data() self.slot = self.options["provider_slot"] - def install_configured(self, binding: Any) -> None: + def _install_common_plan(self, binding: Any, provider_route: str) -> None: + if type(provider_route) is not str or not provider_route: + raise TypeError("native AMR field solver provider route must be non-empty") contract = binding.resolution.to_data()["native_contract"] routes = self.options["provider_pack"] output = self.options["output_route"] @@ -56,7 +59,7 @@ def install_configured(self, binding: Any) -> None: [route["owner_block"] for route in routes], [route["key"] for route in routes], [route["coefficient"] for route in routes], - contract["factory_route"], + provider_route, hierarchy_policy["policy_id"], hierarchy_policy["interface_version"], hierarchy_policy["option_schema"], @@ -72,10 +75,79 @@ def install_configured(self, binding: Any) -> None: topology["topology_identity"], ) - def install_component(self, _binding: Any) -> None: - raise RuntimeError( - "component field solver reached AMR after its provider policy rejected the use" + def install_configured(self, binding: Any) -> None: + contract = binding.resolution.to_data()["native_contract"] + self._install_common_plan(binding, contract["factory_route"]) + + def install_component(self, binding: Any) -> None: + if self.install_plan is None: + raise ValueError("component field providers require the authenticated InstallPlan") + component_bindings = binding.resolution.to_data()["component_bindings"] + if len(component_bindings) != 2: + raise ValueError("component field provider requires exact topology and solver bindings") + installed = [] + from pops.fields._identity import field_identity, strict_field_data + from pops.identity import canonical_bytes + + for authority in component_bindings: + component = self.install_plan.components.get(authority["component_id"]) + if component is None: + raise ValueError( + "field %r requires installed component %r" + % (self.field_plan.name, authority["component_id"]) + ) + if component.component_manifest.token != authority["component_manifest_identity"]: + raise ValueError("field component manifest identity changed before install") + if canonical_bytes(strict_field_data(component.interface.to_data())) != canonical_bytes( + strict_field_data(authority["native_interface"]) + ): + raise ValueError("field component native interface identity changed before install") + if component.native_handle is None: + raise ValueError("field components must be loaded before native installation") + installed.append(component.native_handle) + + import json + from pops.runtime._component_execution_context import component_execution_data + + nullspace = self.options["nullspace_provider"] + boundary = { + "identity": field_identity( + "field-boundary-contract", + { + "field": self.field_plan.identity.token, + "faces": self.options["boundary_faces"], + "nullspace_provider": nullspace, + "topology_identity": binding.facts.layout["topology_identity"], + }, + ).token, + "faces": self.options["boundary_faces"], + "nullspace_provider": nullspace, + "topology_identity": binding.facts.layout["topology_identity"], + } + request = binding.resolution.native_contract["options"] + exact = self.engine.register_field_solver_provider( + self.slot, + installed[0], + installed[1], + component_bindings[0], + component_bindings[1], + json.dumps(component_bindings[0]["parameters"], sort_keys=True, + separators=(",", ":"), allow_nan=False), + json.dumps(component_bindings[1]["parameters"], sort_keys=True, + separators=(",", ":"), allow_nan=False), + self.install_plan.artifact.layout_plan.qualified_id, + binding.facts.layout["topology_identity"], + json.dumps(strict_field_data(boundary), sort_keys=True, separators=(",", ":")), + request["relative_tolerance"], + request["absolute_tolerance"], + request["max_iterations"], + component_execution_data(self.install_plan.execution_context), ) + if type(exact) is not str or not exact: + raise RuntimeError("native AMR component field solver returned no exact identity") + if exact != self.slot: + raise RuntimeError("native AMR component field solver changed its provider route") + self._install_common_plan(binding, exact) class _PreparedAmrFieldNullspaceInstall: @@ -162,6 +234,12 @@ def _install_compiled(self, compiled: Any = None, *, instances: Any = None, para # required declared argument BEFORE any native mutation. Inert (reads arguments() metadata). validate_install_arguments( self, compiled, instances, params, aux, field_plans=field_plans) + if install_plan is not None: + from pops.runtime._runtime_authorities import ( + _validate_shared_interface_implicit_execution_before_install, + ) + + _validate_shared_interface_implicit_execution_before_install(install_plan) if amr_transfer is not None: self._install_bootstrap_routes(amr_transfer) @@ -180,7 +258,7 @@ def _install_compiled(self, compiled: Any = None, *, instances: Any = None, para # adding blocks and before install_program). Field identity, provider and hierarchy policy # were resolved at compile time; bind only materializes that immutable plan. for field, field_plan in field_plans.items(): - self._install_field_plan(field, field_plan) + self._install_field_plan(field, field_plan, install_plan=install_plan) # (2) INSTANCES: resolve every package first, then project complete BindSchema vectors before # installing any block. The per-instance detached CompiledModel is mandatory. @@ -436,7 +514,7 @@ def _install_bootstrap_routes(self, registry: Any) -> None: for pair in sorted(face_vectors): self._s._register_bootstrap_face_vector(pair) - def _install_field_plan(self, field: Any, field_plan: Any) -> None: + def _install_field_plan(self, field: Any, field_plan: Any, *, install_plan: Any = None) -> None: """Install the complete resolved AMR field route before native block loaders run.""" from pops.codegen.field_install import ResolvedFieldInstallPlan if not isinstance(field_plan, ResolvedFieldInstallPlan): @@ -452,7 +530,9 @@ def _install_field_plan(self, field: Any, field_plan: Any) -> None: binding = prepared_field_solver_binding_from_data(options["solver_provider"]) provider = prepared_field_solver_provider_from_identity(binding.provider) - provider.install(_PreparedAmrFieldSolverInstall(self._s, field_plan), binding) + provider.install( + _PreparedAmrFieldSolverInstall(self._s, field_plan, install_plan), binding + ) slot = options["provider_slot"] faces = options["boundary_faces"] if faces is not None: @@ -466,7 +546,9 @@ def _install_field_plan(self, field: Any, field_plan: Any) -> None: slot, [row["owner_block"] for row in dependencies["states"]], [row["component"] for row in dependencies["states"]], - [], [], []) + [row["owner_block"] for row in dependencies["fields"]], + [row["output_key"] for row in dependencies["fields"]], + [row["component"] for row in dependencies["fields"]]) self._install_field_nullspace(slot, field_plan) nonlinear = options.get("nonlinear") if nonlinear is not None: diff --git a/python/pops/runtime/_amr_system_program.py b/python/pops/runtime/_amr_system_program.py index 09d4b287d..442153a5e 100644 --- a/python/pops/runtime/_amr_system_program.py +++ b/python/pops/runtime/_amr_system_program.py @@ -41,6 +41,11 @@ def _finish_program_install(self, compiled: Any, so_path: Any, schema: Any, - (6) attach the exact typed StepTransactionPlan authored by the installed Program. """ if so_path is not None: + component = getattr(compiled, "program", None) + authored = getattr(component, "program", component) + from pops.runtime._program_cadence_install import install_program_cadence + + install_program_cadence(self, authored) self.install_program(so_path) # (5a) HISTORY-PERSISTENCE POLICIES (ADC-631, parity with the uniform step-5a): the compiled # Program records a per-ring persistence policy (Dense / Interval / Revolve) on @@ -54,8 +59,6 @@ def _finish_program_install(self, compiled: Any, so_path: Any, schema: Any, set_persistence( {name: policy for name, (_depth, policy) in persistence.items()}) self._install_program_params(compiled, schema, params) - component = getattr(compiled, "program", None) - authored = getattr(component, "program", component) self._step_strategy = getattr(authored, "_step_strategy", None) self._step_transaction_plan = ( authored.transaction_plan() if authored is not None else None) diff --git a/python/pops/runtime/_analytic_expression_lowering.py b/python/pops/runtime/_analytic_expression_lowering.py index 7cf0a96c4..5e38552c0 100644 --- a/python/pops/runtime/_analytic_expression_lowering.py +++ b/python/pops/runtime/_analytic_expression_lowering.py @@ -37,6 +37,7 @@ def lower_analytic_components( *, frame_id: str, bindings: Any = None, + time_clock_id: str | None = None, ) -> tuple[tuple[tuple[str, ...], tuple[float, ...]], ...]: """Return one validated postfix opcode/literal pair per scalar component.""" @@ -51,6 +52,7 @@ def lower_analytic_components( frame_id=frame_id, where="components[%d]" % index, bindings=bindings, + time_clock_id=time_clock_id, ) for index, expression in enumerate(components) ) @@ -62,6 +64,7 @@ def _lower_expression( frame_id: str, where: str, bindings: Any, + time_clock_id: str | None, ) -> tuple[tuple[str, ...], tuple[float, ...]]: from pops.analytic import ScalarExpr @@ -74,8 +77,8 @@ def _lower_expression( budget = [0] _lower_node( data["root"], expected="scalar", frame_id=frame_id, where=where + ".root", - depth=1, budget=budget, opcodes=opcodes, literals=literals, - bindings=bindings, + depth=1, budget=budget, opcodes=opcodes, literals=literals, bindings=bindings, + time_clock_id=time_clock_id, ) if len(opcodes) != len(literals) or not opcodes: raise RuntimeError("analytic lowering produced an invalid postfix program") @@ -93,6 +96,7 @@ def _lower_node( opcodes: list[str], literals: list[float], bindings: Any, + time_clock_id: str | None, ) -> None: if depth > _MAX_DEPTH: raise ValueError("%s exceeds analytic max_depth=%d" % (where, _MAX_DEPTH)) @@ -147,6 +151,23 @@ def _lower_node( literals.append(float(value_id)) return + if kind == "scalar" and op == "time": + if set(data) != {"kind", "op", "clock", "clock_id"}: + raise TypeError("%s time node has an unsupported shape" % where) + if not isinstance(time_clock_id, str) or not time_clock_id: + raise NotImplementedError( + "%s requires a consuming runtime with one exact physical-time Clock" % where + ) + if data["clock_id"] != time_clock_id: + raise ValueError("%s time belongs to another logical Clock" % where) + from pops.time import Clock + + if Clock.from_data(data["clock"]).qualified_id != time_clock_id: + raise ValueError("%s time Clock data does not authenticate clock_id" % where) + opcodes.append("input") + literals.append(0.0) + return + if set(data) != {"kind", "op", "arguments"} \ or not isinstance(data["arguments"], (tuple, list)): raise TypeError("%s operator node has an unsupported shape" % where) @@ -179,8 +200,8 @@ def _lower_node( _lower_node( argument, expected=child_kind, frame_id=frame_id, where="%s.arguments[%d]" % (where, index), depth=depth + 1, budget=budget, - opcodes=opcodes, literals=literals, - bindings=bindings, + opcodes=opcodes, literals=literals, bindings=bindings, + time_clock_id=time_clock_id, ) # The canonical schema vocabulary is also the native ABI vocabulary. Keeping one spelling # prevents the Python and C++ validators from accepting disjoint instruction sets. diff --git a/python/pops/runtime/_bricks_model.py b/python/pops/runtime/_bricks_model.py index 71addd4f8..7cceb5824 100644 --- a/python/pops/runtime/_bricks_model.py +++ b/python/pops/runtime/_bricks_model.py @@ -185,7 +185,8 @@ def Model(state: Any, transport: Any, source: Any, elliptic: Any) -> Any: Validates the state <-> transport consistency (Scalar with ExB; compressible FluidState with CompressibleFlux; isothermal with IsothermalFlux) and carries the parameters into the spec. - The returned ``ModelSpec`` is the BOUNDED LEGACY BRIDGE for the native ``add_block`` path (a + The returned ``ModelSpec`` is the bounded private bridge for the native-ABI branch of + ``add_equation`` (a flat C++ POD of brick tags + parameters); it is NOT the target representation. The target representation of a model is the operator-first ``pops.model.Module`` (compiled to a Problem) and its self-describing ``ModuleManifest`` (ADC-585). The POD remains an explicitly private diff --git a/python/pops/runtime/_bricks_scheme.py b/python/pops/runtime/_bricks_scheme.py index 7ee99202b..172d64f96 100644 --- a/python/pops/runtime/_bricks_scheme.py +++ b/python/pops/runtime/_bricks_scheme.py @@ -13,7 +13,8 @@ from pops.runtime._numeric import exact_real, positive_int, strict_bool from pops.runtime.routes import ( RECON_CONSERVATIVE, RECON_PRIMITIVE, - RIEMANN_HLL, RIEMANN_HLLC, RIEMANN_ROE, RIEMANN_RUSANOV, + RIEMANN_HLL, RIEMANN_HLLC, RIEMANN_ROE, RIEMANN_ROE_HLL_RUSANOV_RECOVERY, + RIEMANN_RUSANOV, TIME_EULER, TIME_EXPLICIT, TIME_SSPRK3, ) @@ -60,14 +61,19 @@ def __init__(self, a: Any, b: Any, rate: Any) -> None: # "user" stays a plain token: an EXTERNAL C++ flux brick resolves through the external-brick # catalog manifest (pops.descriptors), not the native route registry. "rusanov": RIEMANN_RUSANOV, "hll": RIEMANN_HLL, "hllc": RIEMANN_HLLC, "roe": RIEMANN_ROE, + "roe_hll_rusanov_recovery": RIEMANN_ROE_HLL_RUSANOV_RECOVERY, "user": "user", } _RECON_SCHEMES = { # variables descriptor scheme -> Spatial.recon route "conservative": RECON_CONSERVATIVE, "primitive": RECON_PRIMITIVE, } -_LIMITER_SUGGEST = ("pops.numerics.reconstruction.limiters.Minmod() / .VanLeer(), " +_LIMITER_SUGGEST = ("pops.numerics.reconstruction.limiters.Minmod() / .VanLeer() / .MC() / " + ".Superbee(), " "pops.numerics.reconstruction.FirstOrder() / WENO5() / MUSCL(...)") -_FLUX_SUGGEST = "pops.numerics.riemann.Rusanov() / HLL() / HLLC() / Roe()" +_FLUX_SUGGEST = ( + "pops.numerics.riemann.Rusanov() / HLL() / HLLC() / Roe() / " + "Recovery(primary=Roe(), fallbacks=(HLL(), Rusanov()))" +) _RECON_SUGGEST = "pops.numerics.variables.Conservative() / Primitive()" @@ -134,22 +140,27 @@ class Spatial: weno5=/primitive=) stay as typed-flag sugar. - ``limiter`` (Spec 5 sec.14.1 alias: ``reconstruction``): a reconstruction / limiter descriptor - lowering to "none" | "minmod" | "vanleer" | "weno5". + lowering to "none" | "minmod" | "vanleer" | "mc" | "superbee" | "weno5". ``pops.numerics.reconstruction.FirstOrder()`` -> none, ``.limiters.Minmod()`` / - ``.VanLeer()``, ``.WENO5()`` / ``.WENO5Z()`` -> weno5, ``.MUSCL(limiter=...)`` -> its limiter. + ``.VanLeer()`` / ``.MC()`` / ``.Superbee()``, ``.WENO5()`` / ``.WENO5Z()`` -> weno5, + ``.MUSCL(limiter=...)`` -> its limiter. weno5 = WENO5-Z, order 5 in smooth regions, 5-point stencil (3 ghosts), oscillation-free - capture near a front; only the native ``add_block`` path exposes it (the compiled .so paths - allocate 2 ghosts -> explicit rejection). + capture near a front; only the private native-``ModelSpec`` branch of ``add_equation`` + exposes it (the compiled .so paths allocate 2 ghosts -> explicit rejection). - ``flux``: a ``pops.numerics.riemann`` descriptor lowering to "rusanov" | "hll" | "hllc" | - "roe". + "roe" | the fixed "roe_hll_rusanov_recovery" policy. Rusanov() = minimal generic (requires only max_wave_speed, any model). HLL() = generic with signed waves (requires model.wave_speeds: native isothermal/compressible model, or a DSL model declaring a primitive 'p'); less diffusive than rusanov, without requiring a pressure or n_vars == 4. This is the recommended path for a NON Euler model with signed waves (moment system, isothermal): HLL() + Minmod(). HLLC() / Roe() = capability-driven contact-resolving and Roe-linearized solvers. The model - MUST supply HasHLLCStructure / HasRoeDissipation; the native Euler brick and DSL providers - conform through that same contract. There is no layout inference or implicit fallback. + MUST supply HasHLLCStructure / HasRoeDissipation; native Euler/isothermal bricks and DSL + providers conform through that same contract, including the annular-polar isothermal route. + There is no layout or coordinate inference and no implicit fallback. + Recovery(primary=Roe(), fallbacks=(HLL(), Rusanov())) is the sole explicit ordered recovery + policy. Only typed solver rejection advances the chain; retry/fatal outcomes remain terminal. + It is available on Uniform and AMR Cartesian routes and refused on annular polar geometry. - ``recon``: a ``pops.numerics.variables`` descriptor lowering to "conservative" | "primitive" (reconstructed variables; primitive more robust for Euler: positivity of rho and p; shortcut primitive=). @@ -340,6 +351,11 @@ def __init__(self, limiter: Any = None, flux: Any = None, recon: Any = None, *, positivity_floor, where="Spatial.positivity_floor", minimum=0)) self.wave_speed_cache = strict_bool( wave_speed_cache, where="Spatial.wave_speed_cache") + if self.wave_speed_cache and self.flux != RIEMANN_HLL: + raise ValueError( + "Spatial.wave_speed_cache requires flux=riemann.HLL(); got flux=%r; " + "no alternate flux is selected" % getattr(self.flux, "token", str(self.flux)) + ) def __str__(self) -> Any: # Spec 5 sec.12.1: a SHORT, deterministic one-line summary of the chosen scheme (the diff --git a/python/pops/runtime/_bricks_time.py b/python/pops/runtime/_bricks_time.py index 1b1f61b39..715f77dee 100644 --- a/python/pops/runtime/_bricks_time.py +++ b/python/pops/runtime/_bricks_time.py @@ -19,6 +19,9 @@ class Role: """Stable physical roles shared by descriptors and symbolic Program authoring.""" + AxialX = "axial_x" + AxialY = "axial_y" + AxialZ = "axial_z" Density = "density" MomentumX = "momentum_x" MomentumY = "momentum_y" diff --git a/python/pops/runtime/_generated_component_routes.py b/python/pops/runtime/_generated_component_routes.py index c3f59fdd2..e3eceee4e 100644 --- a/python/pops/runtime/_generated_component_routes.py +++ b/python/pops/runtime/_generated_component_routes.py @@ -5,15 +5,15 @@ COMPONENT_MANIFEST_SCHEMA_VERSION = 2 -ROUTE_REGISTRY_VERSION = 2 +ROUTE_REGISTRY_VERSION = 3 CAPABILITY_VOCAB_VERSION = 4 -COMPONENT_CATALOG_SHA256 = '5c67c081cf1808138583ed00856e6601c12384ae28e9c0f8cc7b8ce004c3b0f6' +COMPONENT_CATALOG_SHA256 = 'b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +COMPONENT_CATALOG_SEMANTIC_SHA256 = 'b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3' -ROUTE_REGISTRY_SIGNATURE = 'v2:adbb3693dc17eff5aa7b78415df35f011dfd2c64fc26eb9a98200923e52c47ea' +ROUTE_REGISTRY_SIGNATURE = 'v3:b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3' ROUTE_TABLES = {'riemann': (('rusanov', 'pops::RusanovFlux', @@ -32,15 +32,26 @@ 'wave_speeds', 'contact_speed', 'hllc_star_state'), - ('polar metric provider not wired; requires exact HasHLLCStructure capability',)), + ()), ('roe', 'pops::RoeFlux', ('physical_flux', 'provider_pack', 'stability_bound', 'roe_dissipation'), - ('polar metric provider not wired; requires exact HasRoeDissipation capability',))), + ()), + ('roe_hll_rusanov_recovery', + 'pops::PreparedRiemannRecoveryPolicy', + ('physical_flux', + 'provider_pack', + 'stability_bound', + 'wave_speeds', + 'roe_dissipation'), + ('fixed ordered policy Roe -> HLL -> Rusanov -> reject', + 'annular polar route unavailable'))), 'limiter': (('none', 'pops::NoSlope', (), ()), ('minmod', 'pops::Minmod', (), ()), ('vanleer', 'pops::VanLeer', (), ()), - ('weno5', 'pops::Weno5', ('3-cell halo',), ())), + ('weno5', 'pops::Weno5', ('3-cell halo',), ()), + ('mc', 'pops::MC', (), ()), + ('superbee', 'pops::Superbee', (), ())), 'recon': (('conservative', 'pops::make_block(recon_prim=false)', (), ()), ('primitive', 'pops::make_block(recon_prim=true)', @@ -117,15 +128,21 @@ 'hllc': {'needs_wave_speeds': False, 'needs_hllc_struct': True, 'needs_roe_diss': False, - 'polar_ok': False}, + 'polar_ok': True}, 'roe': {'needs_wave_speeds': False, 'needs_hllc_struct': False, 'needs_roe_diss': True, - 'polar_ok': False}}, + 'polar_ok': True}, + 'roe_hll_rusanov_recovery': {'needs_wave_speeds': True, + 'needs_hllc_struct': False, + 'needs_roe_diss': True, + 'polar_ok': False}}, 'limiter': {'none': {'n_ghost': 1, 'formal_order': 1, 'muscl_compatible': False}, 'minmod': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}, 'vanleer': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}, - 'weno5': {'n_ghost': 3, 'formal_order': 5, 'muscl_compatible': False}}, + 'weno5': {'n_ghost': 3, 'formal_order': 5, 'muscl_compatible': False}, + 'mc': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}, + 'superbee': {'n_ghost': 2, 'formal_order': 2, 'muscl_compatible': True}}, 'recon': {'conservative': {}, 'primitive': {}}, 'time': {'explicit': {}, 'ssprk3': {}, 'euler': {}, 'imex': {}, 'imexrk_ars222': {}}, 'field_solver': {'geometric_mg': {}, 'fft': {}, 'fft_spectral': {}, 'polar': {}}, @@ -165,10 +182,10 @@ ROUTE_CPP_BINDINGS = {'riemann': {'enum': 'RiemannRouteId', 'table': 'kRiemannRoutes', - 'ids': ('kRusanov', 'kHll', 'kHllc', 'kRoe')}, + 'ids': ('kRusanov', 'kHll', 'kHllc', 'kRoe', 'kRoeHllRusanovRecovery')}, 'limiter': {'enum': 'LimiterRouteId', 'table': 'kLimiterRoutes', - 'ids': ('kNone', 'kMinmod', 'kVanLeer', 'kWeno5')}, + 'ids': ('kNone', 'kMinmod', 'kVanLeer', 'kWeno5', 'kMc', 'kSuperbee')}, 'recon': {'enum': 'ReconRouteId', 'table': 'kReconRoutes', 'ids': ('kConservative', 'kPrimitive')}, 'time': {'enum': 'TimeRouteId', 'table': 'kTimeRoutes', diff --git a/python/pops/runtime/_lifecycle.py b/python/pops/runtime/_lifecycle.py index 03f3c3e40..b599b35f6 100644 --- a/python/pops/runtime/_lifecycle.py +++ b/python/pops/runtime/_lifecycle.py @@ -39,7 +39,7 @@ # structural after bind: only BindSchema may populate them. State/field/clock data remain mutable. FROZEN_STRUCTURAL = frozenset({ # blocks / field problems / aux LAYOUT - "add_block", "add_equation", "_install_native_block", + "add_equation", "_install_native_block", "set_poisson", "set_epsilon_field", "set_epsilon_anisotropic_field", "set_reaction_field", "set_aux_field_halo_component", "set_electron_temperature_from", "register_elliptic_field", "set_block_elliptic_field", "set_compiled_block", @@ -54,13 +54,18 @@ "set_program_params", }) +# Native facades still expose this ABI entry, but it is no longer a Python runtime-authoring +# spelling. Keep it out of ``__getattr__`` in both assembling and bound phases so deleting the +# duplicate mixin methods cannot accidentally reveal the C++ method as a compatibility fallback. +RETIRED_NATIVE_PASSTHROUGH = frozenset({"add_block"}) + def freeze_error(what: Any) -> Any: """The precise :class:`RuntimeError` for a structural mutation attempted after ``pops.bind``. @p what names the refused operation (a method / attribute name). The message speaks the BIND vocabulary and points at the assembly path (``pops.Case`` + ``pops.compile`` + ``pops.bind``); - it NEVER recommends a legacy setter as the remedy (no ``add_block`` / ``set_poisson`` / + it NEVER recommends a legacy setter as the remedy (no ``add_equation`` / ``set_poisson`` / ``install_program`` as an alternative), so it cannot be read as a validation bypass. """ @@ -75,7 +80,7 @@ def freeze_error(what: Any) -> Any: def guard_assembling(engine: Any, what: Any) -> Any: """Raise :func:`freeze_error` when @p engine is already bound (the Python-layer structural guard). - Called at the TOP of each Python-implemented structural method (add_block / add_equation / + Called at the TOP of each Python-implemented structural method (add_equation / set_poisson / set_disc_domain / _install_compiled / ...). Enforces the freeze at the Python layer WITHOUT the native ``mark_bound`` (bypass-proof on a prebuilt ``.so``): it reads the engine's ``_lifecycle`` flag, defaulting to ``assembling`` (so an engine constructed @@ -196,5 +201,11 @@ def last_restart_identity(self) -> Any: return getattr(self, "_last_restart_identity", None) -__all__ = ["FROZEN_STRUCTURAL", "freeze_error", "guard_assembling", "derive_lifecycle_state", - "_LifecycleMixin"] +__all__ = [ + "FROZEN_STRUCTURAL", + "RETIRED_NATIVE_PASSTHROUGH", + "freeze_error", + "guard_assembling", + "derive_lifecycle_state", + "_LifecycleMixin", +] diff --git a/python/pops/runtime/_multi_layout_executor.py b/python/pops/runtime/_multi_layout_executor.py index bdba4c2a1..752b1ba00 100644 --- a/python/pops/runtime/_multi_layout_executor.py +++ b/python/pops/runtime/_multi_layout_executor.py @@ -153,6 +153,33 @@ def _require_conservative_cell_average_geometry(source: Any, target: Any) -> Non ) +def _require_runtime_plan_projection( + plan: Any, runtime_plan: Any, transfers: tuple[Any, ...] +) -> None: + """Require every multi-layout route the provider claims before engine construction.""" + layout_plan = plan.artifact.layout_plan + assignments = { + row.subject.local_id: (row.subject_id, row.layout.qualified_id) + for row in layout_plan.assignments + if row.subject_kind == "block" + } + expected_calls = tuple(assignments[block.name] for block in plan.artifact.blocks) + actual_calls = tuple((row.block_id, row.layout_id) for row in runtime_plan.calls) + if actual_calls != expected_calls: + raise ValueError( + "RuntimePlanBundle calls differ from the multi-layout InstallPlan projection" + ) + if runtime_plan.communication.halos: + raise NotImplementedError( + "multi-layout RuntimePlan halos require an explicit per-layout halo scheduler" + ) + expected_providers = tuple(sorted({row.provider_id for row in transfers})) + if runtime_plan.resources.mapping_provider_ids != expected_providers: + raise ValueError( + "RuntimePlanBundle mapping providers differ from the consumed Transfers" + ) + + def _require_runtime_plan_bundle(plan: Any, runtime_plan: Any) -> None: """Authenticate the exact bundle and its Transfer projection against one InstallPlan.""" from pops.runtime._runtime_plan_contracts import LayoutTransfer @@ -184,6 +211,7 @@ def _require_runtime_plan_bundle(plan: Any, runtime_plan: Any) -> None: raise ValueError( "RuntimePlanBundle Transfers differ from the authenticated compiled LayoutPlan" ) + _require_runtime_plan_projection(plan, runtime_plan, transfers) _require_unique_transfer_targets(transfers) @@ -372,6 +400,213 @@ def executor_for_block(self, block: str) -> Any: def block_names(self) -> tuple[str, ...]: return tuple(self._block_layouts) + def _ordered_program_reports(self) -> tuple[tuple[Any, tuple[str, ...], Any], ...]: + """Return one authenticated report for every independently installed Program.""" + from pops.runtime.program_report import ProgramRuntimeReport + + layout_programs = tuple(self._plan.artifact.layout_programs) + layout_ids = tuple(row.layout_id for row in layout_programs) + if layout_ids != tuple(self._engines): + raise RuntimeError( + "multi-layout Program reports differ from the installed layout order" + ) + rows = [] + for layout_program in layout_programs: + engine = self._engines[layout_program.layout_id] + report = engine.program_report() + if type(report) is not ProgramRuntimeReport: + raise TypeError( + "multi-layout child returned a non-canonical ProgramRuntimeReport" + ) + if not report.installed or not isinstance(report.program_hash, str) or not ( + report.program_hash + ): + raise RuntimeError( + "multi-layout child has no authenticated installed Program" + ) + engine_blocks = tuple(engine.block_names()) + if ( + len(engine_blocks) != len(set(engine_blocks)) + or set(engine_blocks) != set(layout_program.block_names) + ): + raise RuntimeError( + "multi-layout child block registry differs from its compiled partition" + ) + local_map = tuple(report.block_map) + if ( + len(local_map) != len(engine_blocks) + or any( + isinstance(index, bool) + or not isinstance(index, int) + or index < 0 + for index in local_map + ) + or tuple(sorted(local_map)) != tuple(range(len(engine_blocks))) + ): + raise RuntimeError( + "multi-layout child Program block map is not an exact local bijection" + ) + parameter_blocks = tuple(row.get("program_block") for row in report.params) + if ( + len(parameter_blocks) != len(local_map) + or any( + isinstance(index, bool) + or not isinstance(index, int) + for index in parameter_blocks + ) + ): + raise RuntimeError( + "multi-layout child Program parameter report is not exact" + ) + exact_parameter_blocks = cast(tuple[int, ...], parameter_blocks) + if tuple(sorted(exact_parameter_blocks)) != tuple(range(len(local_map))): + raise RuntimeError( + "multi-layout child Program parameter report is not exact" + ) + rows.append((layout_program, engine_blocks, report)) + return tuple(rows) + + def program_report(self) -> Any: + """Aggregate every real child Program without inventing a single native engine.""" + from pops.identity import make_identity + from pops.runtime.program_report import ProgramRuntimeReport + + children = self._ordered_program_reports() + global_blocks = self.block_names() + global_block_indices = { + name: index for index, name in enumerate(global_blocks) + } + if len(global_block_indices) != len(global_blocks): + raise RuntimeError("multi-layout global block registry contains a duplicate") + + block_map = [] + params = [] + diagnostics = {} + histories = [] + cache = [] + clocks = [] + level_relations = [] + flux_ledger = [] + synchronization = [] + program_offset = 0 + + qualified_row_sets = ( + ("history", histories, "histories"), + ("clock", clocks, "clocks"), + ("level relation", level_relations, "level_relations"), + ("flux ledger", flux_ledger, "flux_ledger"), + ("synchronization", synchronization, "synchronization"), + ) + for layout_program, engine_blocks, report in children: + layout_id = layout_program.layout_id + local_map = tuple(report.block_map) + local_program_blocks = tuple( + engine_blocks[local_system_index] + for local_system_index in local_map + ) + block_map.extend( + global_block_indices[name] for name in local_program_blocks + ) + + for raw in report.params: + row = dict(raw) + local_program_block = row["program_block"] + if "layout_id" in row or "block" in row: + raise RuntimeError( + "multi-layout child parameter report contains reserved qualifiers" + ) + row["program_block"] = program_offset + local_program_block + row["layout_id"] = layout_id + row["block"] = local_program_blocks[local_program_block] + params.append(row) + + for name, value in report.diagnostics.items(): + if not isinstance(name, str) or not name: + raise RuntimeError( + "multi-layout child diagnostic name must be non-empty" + ) + diagnostics["%s::%s" % (layout_id, name)] = value + + for label, destination, attribute in qualified_row_sets: + for raw in getattr(report, attribute): + row = dict(raw) + if "layout_id" in row: + raise RuntimeError( + "multi-layout child %s report contains a reserved qualifier" + % label + ) + row["layout_id"] = layout_id + destination.append(row) + + for raw in report.cache: + row = dict(raw) + if "layout_id" in row or "layout_node_id" in row: + raise RuntimeError( + "multi-layout child cache report contains reserved qualifiers" + ) + local_node_id = row.get("node_id") + if ( + isinstance(local_node_id, bool) + or not isinstance(local_node_id, int) + or local_node_id < 0 + ): + raise RuntimeError( + "multi-layout child cache report has an invalid node identity" + ) + row["layout_id"] = layout_id + row["layout_node_id"] = local_node_id + row["node_id"] = len(cache) + cache.append(row) + program_offset += len(local_map) + + program_hash = make_identity( + "multi-layout-program", + [ + { + "layout_id": layout_program.layout_id, + "layout_program_identity": layout_program.identity.token, + "installed_program_hash": report.program_hash, + } + for layout_program, _engine_blocks, report in children + ], + ).hexdigest + return ProgramRuntimeReport( + installed=True, + program_hash=program_hash, + step_transaction=_common_exact( + (report.step_transaction for _row, _blocks, report in children), + where="multi-layout Program transaction report", + ), + block_map=block_map, + params=params, + diagnostics=diagnostics, + histories=histories, + cache=cache, + profiler=_common_exact( + (report.profiler for _row, _blocks, report in children), + where="multi-layout Program profiler report", + ), + clocks=clocks, + level_relations=level_relations, + flux_ledger=flux_ledger, + synchronization=synchronization, + temporal_partition=_common_exact( + ( + report.temporal_partition + for _row, _blocks, report in children + ), + where="multi-layout Program temporal-partition report", + ), + temporal=_common_exact( + (report.temporal for _row, _blocks, report in children), + where="multi-layout Program temporal report", + ), + ) + + def installed_program_hash(self) -> str: + """Return the domain-separated identity of the exact installed Program set.""" + return self.program_report().program_hash + def state_global(self, block: str) -> Any: return self.executor_for_block(block).state_global(block) @@ -969,7 +1204,8 @@ def install_multi_layout_uniform(plan: Any, runtime_plan: Any) -> Any: ) strategies.append(strategy) transaction_plans.append(authored.transaction_plan()) - configs[layout_id] = system_config_from_layout(row.descriptor) + configs[layout_id] = system_config_from_layout( + plan.artifact.native_layouts[layout_id]) if any(value != strategies[0] for value in strategies[1:]) or any( value != transaction_plans[0] for value in transaction_plans[1:] ): diff --git a/python/pops/runtime/_observer_runtime.py b/python/pops/runtime/_observer_runtime.py index b3ddf85cb..f23d49dc4 100644 --- a/python/pops/runtime/_observer_runtime.py +++ b/python/pops/runtime/_observer_runtime.py @@ -4,6 +4,7 @@ ``ObserverFrame`` only after native step finalization and submit it here. Keeping that splice explicit prevents a live packet from masquerading as a compensatable ConsumerTransaction artifact. """ + from __future__ import annotations import queue @@ -16,6 +17,7 @@ ObserverFrame, ObserverReceipt, ObserverRun, + ObserverWorkerCollectiveLost, authenticate_observer_session, detach_observer_frame, ) @@ -25,6 +27,21 @@ def _reason(error: BaseException) -> str: return "%s: %s" % (type(error).__name__, error) +class _WorkerCollectiveLost(RuntimeError): + """A provider worker lane no longer has rank-complete lifecycle evidence.""" + + +def _as_worker_collective_lost( + phase: str, + error: ObserverWorkerCollectiveLost, +) -> _WorkerCollectiveLost: + converted = _WorkerCollectiveLost( + "MPI observer %s lost its provider worker collective: %s" % (phase, _reason(error)) + ) + converted.__cause__ = error + return converted + + @dataclass(frozen=True, slots=True) class ObserverDeliveryReport: """Terminal, non-compensating result of one submitted accepted frame.""" @@ -40,18 +57,19 @@ class ObserverDeliveryReport: identity: Identity = field(init=False) def __post_init__(self) -> None: - if not isinstance(self.consumer_id, str) or not self.consumer_id \ - or self.consumer_id.strip() != self.consumer_id: + if ( + not isinstance(self.consumer_id, str) + or not self.consumer_id + or self.consumer_id.strip() != self.consumer_id + ): raise TypeError("observer report consumer_id must be non-empty canonical text") if type(self.run_identity) is not Identity or self.run_identity.domain != "run": raise TypeError("observer report run_identity must be an exact run Identity") if self.status not in {"delivered", "skipped"}: raise ValueError("observer delivery status must be delivered or skipped") - if isinstance(self.sequence, bool) or type(self.sequence) is not int \ - or self.sequence < 0: + if isinstance(self.sequence, bool) or type(self.sequence) is not int or self.sequence < 0: raise TypeError("observer delivery sequence must be an integer >= 0") - if isinstance(self.attempts, bool) or type(self.attempts) is not int \ - or self.attempts < 1: + if isinstance(self.attempts, bool) or type(self.attempts) is not int or self.attempts < 1: raise TypeError("observer delivery attempts must be a positive integer") if self.status == "delivered": if type(self.receipt) is not ObserverReceipt or self.reason is not None: @@ -60,8 +78,9 @@ def __post_init__(self) -> None: raise ValueError("observer receipt authenticates a different frame") elif self.receipt is not None or not isinstance(self.reason, str) or not self.reason: raise ValueError("skipped observer report requires only a non-empty reason") - object.__setattr__(self, "identity", make_identity( - "observer-delivery-report", self._payload())) + object.__setattr__( + self, "identity", make_identity("observer-delivery-report", self._payload()) + ) def _payload(self) -> dict[str, Any]: return { @@ -96,8 +115,16 @@ def to_collective_data(self) -> dict[str, Any]: @classmethod def from_data(cls, data: Any) -> ObserverDeliveryReport: if not isinstance(data, dict) or set(data) != { - "consumer_id", "run_identity", "sequence", "frame_identity", "status", - "attempts", "receipt", "reason", "identity"}: + "consumer_id", + "run_identity", + "sequence", + "frame_identity", + "status", + "attempts", + "receipt", + "reason", + "identity", + }: raise TypeError("observer delivery report data has an unsupported schema") receipt = None if data["receipt"] is None else ObserverReceipt.from_data(data["receipt"]) result = cls( @@ -110,19 +137,29 @@ def from_data(cls, data: Any) -> ObserverDeliveryReport: receipt=receipt, reason=data["reason"], ) - if result.identity != Identity.from_data(data["identity"]) \ - or result.to_data() != data: + if result.identity != Identity.from_data(data["identity"]) or result.to_data() != data: raise ValueError("observer delivery report data is not canonical") return result @classmethod def from_collective_data(cls, data: Any) -> ObserverDeliveryReport: if not isinstance(data, dict) or set(data) != { - "consumer_id", "run_identity", "sequence", "frame_identity", "status", - "attempts", "receipt", "reason", "identity"}: + "consumer_id", + "run_identity", + "sequence", + "frame_identity", + "status", + "attempts", + "receipt", + "reason", + "identity", + }: raise TypeError("observer delivery report collective data has an unsupported schema") - receipt = None if data["receipt"] is None \ + receipt = ( + None + if data["receipt"] is None else ObserverReceipt.from_collective_data(data["receipt"]) + ) result = cls( data["consumer_id"], Identity.from_token(data["run_identity"]), @@ -133,8 +170,10 @@ def from_collective_data(cls, data: Any) -> ObserverDeliveryReport: receipt=receipt, reason=data["reason"], ) - if result.identity != Identity.from_token(data["identity"]) \ - or result.to_collective_data() != data: + if ( + result.identity != Identity.from_token(data["identity"]) + or result.to_collective_data() != data + ): raise ValueError("observer delivery report collective data is not canonical") return result @@ -163,6 +202,19 @@ def cancel(self, error: BaseException) -> None: self._resolved = True self._event.set() + def cancel_if_pending(self, error: BaseException) -> bool: + """Resolve a still-blocked gate without racing an already admitted operation.""" + + if not isinstance(error, BaseException): + raise TypeError("observer submission cancellation requires an exception") + with self._lock: + if self._resolved: + return False + self._error = error + self._resolved = True + self._event.set() + return True + def wait(self) -> BaseException | None: self._event.wait() return self._error @@ -180,6 +232,30 @@ def cancel(self, error: BaseException) -> None: self._gate.cancel(error) +@dataclass(slots=True) +class _PreparedWorkerCall: + """One worker call held behind a main-thread collective admission gate.""" + + _gate: _SubmissionGate + _done: threading.Event + _results: list[Any] + _failures: list[BaseException] + + def arm(self) -> None: + self._gate.arm() + + def cancel(self, error: BaseException) -> None: + self._gate.cancel(error) + + def result(self) -> Any: + self._done.wait() + if self._failures: + raise self._failures[0] + if len(self._results) != 1: + raise RuntimeError("post-commit worker call lost its result") + return self._results[0] + + @dataclass(frozen=True, slots=True) class _Job: sequence: int @@ -233,6 +309,18 @@ class _SharedWorkerTask: on_failure: Any +@dataclass(frozen=True, slots=True) +class _PrivateQueueCloseAttempt: + done: threading.Event + failures: list[BaseException] + + +@dataclass(frozen=True, slots=True) +class _PrivateQueueAbortAttempt: + done: threading.Event + failures: list[BaseException] + + class PostCommitObserverWorker: """One process-local FIFO for every post-commit session in a runtime run. @@ -243,36 +331,84 @@ class PostCommitObserverWorker: main-thread submission order on each process while keeping them off the simulation thread. """ - def __init__(self, *, thread_name: str = "pops-post-commit-worker") -> None: + def __init__( + self, + *, + thread_name: str = "pops-post-commit-worker", + run_identity: Identity | None = None, + ) -> None: if not isinstance(thread_name, str) or not thread_name: raise TypeError("post-commit worker thread_name must be non-empty text") + if run_identity is not None and ( + type(run_identity) is not Identity or run_identity.domain != "run" + ): + raise TypeError("post-commit worker run_identity must be an exact run Identity") + self._run_identity = run_identity self._jobs: queue.Queue[Any] = queue.Queue() self._lock = threading.Lock() + self._close_lock = threading.Lock() + self._close_requested = False + self._stop_enqueued = False + self._stop_consumed = False self._closed = False - self._thread = threading.Thread( - target=self._run, name=thread_name, daemon=False) + self._terminal_error: BaseException | None = None + self._thread = threading.Thread(target=self._run, name=thread_name, daemon=False) self._thread.start() + @property + def close_requested(self) -> bool: + with self._lock: + return self._close_requested + + @property + def close_succeeded(self) -> bool: + with self._lock: + return self._closed + + @property + def closed(self) -> bool: + return self.close_succeeded + + @property + def close_authority(self) -> str | None: + return None if self._run_identity is None else self._run_identity.token + def submit(self, operation: Any, on_failure: Any) -> None: if not callable(operation) or not callable(on_failure): raise TypeError("post-commit worker tasks require callable operation/failure routes") with self._lock: - if self._closed: + if self._close_requested: raise RuntimeError("post-commit worker is closed") + if self._terminal_error is not None: + raise RuntimeError( + "post-commit worker is unavailable: " + _reason(self._terminal_error) + ) from self._terminal_error self._jobs.put(_SharedWorkerTask(operation, on_failure)) def call(self, operation: Any) -> Any: """Run one lifecycle operation in FIFO order and return or re-raise on the caller.""" + prepared = self.prepare_call(operation) + prepared.arm() + return prepared.result() + + def prepare_call(self, operation: Any) -> _PreparedWorkerCall: + """Enqueue one lifecycle operation without permitting provider entry yet.""" + if not callable(operation): raise TypeError("post-commit worker call requires a callable operation") + gate = _SubmissionGate() done = threading.Event() result: list[Any] = [] failure: list[BaseException] = [] def invoke() -> None: try: - result.append(operation()) + gate_error = gate.wait() + if gate_error is not None: + failure.append(gate_error) + else: + result.append(operation()) except BaseException as error: failure.append(error) finally: @@ -283,34 +419,78 @@ def failed(error: BaseException) -> None: done.set() self.submit(invoke, failed) - done.wait() - if failure: - raise failure[0] - if len(result) != 1: - raise RuntimeError("post-commit worker call lost its result") - return result[0] + return _PreparedWorkerCall(gate, done, result, failure) def close(self) -> None: - with self._lock: - was_closed = self._closed - if not was_closed: + with self._close_lock: + with self._lock: + if self._closed: + return + self._close_requested = True + if not self._stop_enqueued: + self._jobs.put(_STOP) + self._stop_enqueued = True + self._thread.join() + if self._thread.is_alive(): + raise RuntimeError("post-commit worker did not stop") + with self._lock: + if not self._stop_consumed: + raise RuntimeError("post-commit worker lost its close request") self._closed = True - self._jobs.put(_STOP) - if not was_closed: + + def seal_local(self, error: BaseException) -> None: + """Poison and join this process-local worker without entering provider or MPI code.""" + + if not isinstance(error, BaseException): + raise TypeError("post-commit worker local seal requires an exception") + with self._close_lock: + with self._lock: + if self._terminal_error is None: + self._terminal_error = error + if self._closed: + return + self._close_requested = True + if not self._stop_enqueued: + self._jobs.put(_STOP) + self._stop_enqueued = True self._thread.join() + if self._thread.is_alive(): + raise RuntimeError("post-commit worker did not stop during local seal") + with self._lock: + if not self._stop_consumed: + raise RuntimeError("post-commit worker lost its local seal request") + self._closed = True def _run(self) -> None: while True: item = self._jobs.get() try: if item is _STOP: + with self._lock: + self._stop_consumed = True return if type(item) is not _SharedWorkerTask: raise TypeError("post-commit worker received an invalid internal task") + with self._lock: + terminal_error = self._terminal_error + if terminal_error is not None: + try: + item.on_failure(terminal_error) + except BaseException: + pass + continue try: item.operation() except BaseException as error: - item.on_failure(error) + try: + item.on_failure(error) + except BaseException as callback_error: + add_note = getattr(callback_error, "add_note", None) + if callable(add_note): + add_note("post-commit operation failure was: %s" % _reason(error)) + with self._lock: + if self._terminal_error is None: + self._terminal_error = callback_error finally: self._jobs.task_done() @@ -334,11 +514,15 @@ def __init__( thread_name: str = "pops-post-commit-observer", worker_communicator: Any = None, shared_worker: PostCommitObserverWorker | None = None, + defer_initialize: bool = False, ) -> None: if type(run) is not ObserverRun: raise TypeError("PostCommitObserverQueue requires an exact ObserverRun") - if not isinstance(consumer_id, str) or not consumer_id \ - or consumer_id.strip() != consumer_id: + if ( + not isinstance(consumer_id, str) + or not consumer_id + or consumer_id.strip() != consumer_id + ): raise TypeError("observer queue consumer_id must be non-empty canonical text") if isinstance(capacity, bool) or type(capacity) is not int or capacity < 1: raise ValueError("observer queue capacity must be an integer >= 1") @@ -352,20 +536,30 @@ def __init__( require_communicator(worker_communicator, allow_world=False) if not authority["worker_mpi"]: - raise ValueError( - "a serial observer session must not receive a worker MPI lane") + raise ValueError("a serial observer session must not receive a worker MPI lane") if max_attempts != 1: raise ValueError( - "MPI observer queues require max_attempts=1 after a collective call") + "MPI observer queues require max_attempts=1 after a collective call" + ) elif authority["worker_mpi"]: - raise ValueError( - "an MPI observer session requires an explicit duplicated worker lane") - if shared_worker is not None and type(shared_worker) is not PostCommitObserverWorker: - raise TypeError( - "observer queue shared_worker must be an exact PostCommitObserverWorker") + raise ValueError("an MPI observer session requires an explicit duplicated worker lane") + if shared_worker is not None: + if type(shared_worker) is not PostCommitObserverWorker: + raise TypeError( + "observer queue shared_worker must be an exact PostCommitObserverWorker" + ) + if shared_worker.close_authority != run.run_identity.token: + raise ValueError("observer queue shared_worker belongs to a different run") if worker_communicator is not None and shared_worker is None: raise ValueError( - "an MPI observer queue requires the runtime's shared post-commit worker") + "an MPI observer queue requires the runtime's shared post-commit worker" + ) + if type(defer_initialize) is not bool: + raise TypeError("observer defer_initialize must be an exact bool") + if defer_initialize and shared_worker is None: + raise ValueError("deferred observer initialization requires the shared worker") + if worker_communicator is not None and not defer_initialize: + raise ValueError("an MPI observer queue requires collectively deferred initialization") self._session = session self._worker_communicator = worker_communicator self._shared_worker = shared_worker @@ -375,31 +569,45 @@ def __init__( self._max_attempts = max_attempts self._capacity = capacity self._jobs: queue.Queue[Any] | None = ( - None if shared_worker is not None else queue.Queue(maxsize=capacity)) + None if shared_worker is not None else queue.Queue(maxsize=capacity) + ) self._condition = threading.Condition() self._reports: list[ObserverDeliveryReport] = [] self._next_sequence = 0 self._pending = 0 + self._close_lock = threading.RLock() + self._close_requested = False + self._close_prepared = False + self._finalize_succeeded = False + self._abort_prepared = False + self._abort_succeeded = False self._closed = False self._lifecycle_error: BaseException | None = None + self._worker_collective_lost = False + self._initialize_attempt: _PreparedWorkerCall | None = None + self._initialize_succeeded = False + self._finalize_attempt: _PreparedWorkerCall | None = None + self._abort_attempt: _PreparedWorkerCall | None = None + self._deferred_submission_gates: dict[int, _SubmissionGate] = {} self._ready = threading.Event() self._thread: threading.Thread | None = None if shared_worker is None: - self._thread = threading.Thread( - target=self._worker, name=thread_name, daemon=False) + self._thread = threading.Thread(target=self._worker, name=thread_name, daemon=False) self._thread.start() self._ready.wait() - else: + elif not defer_initialize: try: shared_worker.call(self._initialize_session) except BaseException as error: self._set_lifecycle_error(error) + else: + self._initialize_succeeded = True if self._lifecycle_error is not None: if self._thread is not None: self._thread.join() raise RuntimeError( - "observer session initialization failed: " - + _reason(self._lifecycle_error)) from self._lifecycle_error + "observer session initialization failed: " + _reason(self._lifecycle_error) + ) from self._lifecycle_error @property def capacity(self) -> int: @@ -415,6 +623,57 @@ def reports(self) -> tuple[ObserverDeliveryReport, ...]: with self._condition: return tuple(self._reports) + @property + def close_requested(self) -> bool: + with self._condition: + return self._close_requested + + @property + def close_succeeded(self) -> bool: + with self._condition: + return self._closed + + @property + def closed(self) -> bool: + return self.close_succeeded + + @property + def close_authority(self) -> dict[str, str]: + return { + "run_identity": self._run.run_identity.token, + "consumer_id": self._consumer_id, + "provider_id": self._provider_id, + } + + @property + def abort_succeeded(self) -> bool: + with self._condition: + return self._abort_succeeded + + @property + def abort_required(self) -> bool: + """Whether an internal worker failure requires gated provider abort at close.""" + + with self._condition: + return ( + self._lifecycle_error is not None + and not self._worker_collective_lost + and not self._finalize_succeeded + ) + + @property + def worker_collective_lost(self) -> bool: + """Whether the duplicated worker lane lost rank-complete collective evidence.""" + + with self._condition: + return self._worker_collective_lost + + @property + def accepted_run_identities(self) -> tuple[Identity, ...]: + """Exact active and recovery run authorities accepted by this queue.""" + + return self._run.accepted_run_identities + def submit( self, frame: ObserverFrame, @@ -442,7 +701,8 @@ def _submit_detached( """Submit runtime-authenticated owned storage without a second deep copy.""" submission = self._enqueue_detached( - owned, journal=journal, journal_record=journal_record, deferred=False) + owned, journal=journal, journal_record=journal_record, deferred=False + ) return submission.sequence def _prepare_detached( @@ -457,7 +717,8 @@ def _prepare_detached( if self._shared_worker is None: raise RuntimeError("deferred observer submission requires the shared worker") return self._enqueue_detached( - owned, journal=journal, journal_record=journal_record, deferred=True) + owned, journal=journal, journal_record=journal_record, deferred=True + ) def _enqueue_detached( self, @@ -468,6 +729,8 @@ def _enqueue_detached( deferred: bool, ) -> _PreparedObserverSubmission: frame = _authenticated_detached_frame(owned) + if frame.snapshot.provenance.run_identity not in self._run.accepted_run_identities: + raise ValueError("observer frame is outside the queue's accepted run authority") if (journal is None) != (journal_record is None): raise TypeError("durable observer submission requires both journal and record") if journal is not None: @@ -477,21 +740,31 @@ def _enqueue_detached( raise TypeError("durable observer submission requires an exact DurableJournal") record_frame = getattr(journal_record, "frame", None) record_state = getattr(journal_record, "state", None) - if type(record_frame) is not ObserverFrame or record_frame.identity != frame.identity \ - or record_state not in {"pending", "delivered"}: + if ( + type(record_frame) is not ObserverFrame + or record_frame.identity != frame.identity + or record_state not in {"pending", "delivered"} + ): raise ValueError( - "durable observer record does not authenticate the submitted frame") + "durable observer record does not authenticate the submitted frame" + ) + gate = _SubmissionGate() + if not deferred: + gate.arm() with self._condition: - while self._shared_worker is not None and self._pending >= self._capacity \ - and not self._closed and self._lifecycle_error is None: + while ( + self._shared_worker is not None + and self._pending >= self._capacity + and not self._close_requested + and self._lifecycle_error is None + ): self._condition.wait() self._require_available_locked() sequence = self._next_sequence self._next_sequence += 1 self._pending += 1 - gate = _SubmissionGate() - if not deferred: - gate.arm() + if deferred: + self._deferred_submission_gates[sequence] = gate job = _Job(sequence, frame, journal, journal_record, gate) try: if self._shared_worker is None: @@ -505,11 +778,44 @@ def _enqueue_detached( ) except BaseException: with self._condition: + self._deferred_submission_gates.pop(sequence, None) self._pending -= 1 self._condition.notify_all() raise return _PreparedObserverSubmission(sequence, gate) + def seal_local(self, error: BaseException) -> None: + """Poison one shared-worker queue and release every unresolved local gate. + + This method deliberately performs no provider call, worker join, or MPI operation. The + runtime can therefore seal every queue first and only then call ``worker.seal_local()`` + once no queue can leave the shared FIFO blocked behind a local admission gate. + """ + + if not isinstance(error, BaseException): + raise TypeError("observer queue local seal requires an exception") + if self._shared_worker is None: + raise RuntimeError("observer queue local seal requires the shared worker") + with self._close_lock: + with self._condition: + self._close_requested = True + self._worker_collective_lost = True + if self._lifecycle_error is None: + self._lifecycle_error = error + submission_gates = tuple(self._deferred_submission_gates.values()) + initialize_attempt = self._initialize_attempt + finalize_attempt = self._finalize_attempt + abort_attempt = self._abort_attempt + self._initialize_attempt = None + self._finalize_attempt = None + self._abort_attempt = None + self._condition.notify_all() + for gate in submission_gates: + gate.cancel_if_pending(error) + for attempt in (initialize_attempt, finalize_attempt, abort_attempt): + if attempt is not None: + attempt._gate.cancel_if_pending(error) + def flush(self) -> tuple[ObserverDeliveryReport, ...]: """Wait until every accepted frame submitted so far has a terminal report.""" @@ -518,35 +824,319 @@ def flush(self) -> tuple[ObserverDeliveryReport, ...]: self._condition.wait() if self._lifecycle_error is not None: raise RuntimeError( - "observer worker is unavailable: " - + _reason(self._lifecycle_error)) from self._lifecycle_error + "observer worker is unavailable: " + _reason(self._lifecycle_error) + ) from self._lifecycle_error return tuple(self._reports) def close(self) -> tuple[ObserverDeliveryReport, ...]: """Drain frames, finalize the optional backend, and join its non-daemon worker.""" - with self._condition: - was_closed = self._closed - if not was_closed: - self._closed = True - if was_closed: - return self.reports - self.flush() - if self._shared_worker is None: - if self._jobs is None or self._thread is None: # pragma: no cover - invariant - raise RuntimeError("observer queue lost its private worker") - self._jobs.put(_STOP) - self._thread.join() - else: + if self._worker_communicator is not None: + raise RuntimeError( + "an MPI observer queue must close through the runtime collective protocol" + ) + with self._close_lock: + self.prepare_close() + return self.complete_close() + + def prepare_initialize(self) -> None: + """Enqueue initialization while keeping provider entry collectively gated.""" + + with self._close_lock: + if self._initialize_succeeded: + return + if self._lifecycle_error is not None: + raise RuntimeError( + "observer session initialization failed: " + _reason(self._lifecycle_error) + ) from self._lifecycle_error + if self._shared_worker is None: + raise RuntimeError("deferred initialization requires the shared worker") + if self._initialize_attempt is None: + self._initialize_attempt = self._shared_worker.prepare_call( + self._initialize_session + ) + + def cancel_initialize(self, error: BaseException) -> None: + """Cancel one prepared initialization before any provider can enter it.""" + + with self._close_lock: + attempt = self._initialize_attempt + if attempt is None: + return + attempt.cancel(error) try: - self._shared_worker.call(self._finalize_session) + attempt.result() + except BaseException: + pass + self._initialize_attempt = None + + def arm_initialize(self) -> None: + """Permit provider initialization after collective enqueue admission.""" + + with self._close_lock: + attempt = self._initialize_attempt + if attempt is None: + raise RuntimeError("observer queue initialization was not prepared") + attempt.arm() + + def complete_initialize(self) -> None: + """Await initialization after every MPI peer armed the same phase.""" + + with self._close_lock: + attempt = self._initialize_attempt + if attempt is None: + if self._initialize_succeeded: + return + raise RuntimeError("observer queue initialization was not prepared") + try: + attempt.result() except BaseException as error: self._set_lifecycle_error(error) - if self._lifecycle_error is not None: + raise RuntimeError( + "observer session initialization failed: " + _reason(error) + ) from error + finally: + self._initialize_attempt = None + self._initialize_succeeded = True + + def prepare_close(self) -> tuple[ObserverDeliveryReport, ...]: + """Reach the local no-work boundary without entering provider finalization.""" + + with self._close_lock: + with self._condition: + if self._closed or self._close_prepared: + return tuple(self._reports) + self._close_requested = True + self._condition.notify_all() + while self._pending: + self._condition.wait() + if self._lifecycle_error is not None: + raise RuntimeError( + "observer worker is unavailable: " + _reason(self._lifecycle_error) + ) from self._lifecycle_error + self._close_prepared = True + return tuple(self._reports) + + def complete_close(self) -> tuple[ObserverDeliveryReport, ...]: + """Finalize only after the runtime proves every MPI peer prepared the same close.""" + + with self._close_lock: + with self._condition: + if self._closed: + return tuple(self._reports) + if not self._close_prepared: + raise RuntimeError("observer queue close was not prepared") + finalize_succeeded = self._finalize_succeeded + if self._shared_worker is None: + if self._jobs is None or self._thread is None: # pragma: no cover - invariant + raise RuntimeError("observer queue lost its private worker") + if not finalize_succeeded: + attempt = _PrivateQueueCloseAttempt(threading.Event(), []) + self._jobs.put(attempt) + attempt.done.wait() + if attempt.failures: + error = attempt.failures[0] + raise RuntimeError( + "observer session finalization failed: " + _reason(error) + ) from error + self._thread.join() + if self._thread.is_alive(): + raise RuntimeError("observer queue private worker did not stop") + elif not finalize_succeeded: + attempt = self._finalize_attempt + if attempt is None: + if self._worker_communicator is not None: + raise RuntimeError("MPI observer queue finalization was not prepared") + self.prepare_complete_close() + attempt = self._finalize_attempt + if attempt is None: # pragma: no cover - prepared unless finalized + raise RuntimeError("observer queue lost its prepared finalization") + attempt.arm() + try: + attempt.result() + except BaseException as error: + if isinstance(error, _WorkerCollectiveLost): + self._set_lifecycle_error(error) + raise RuntimeError( + "observer session finalization failed: " + _reason(error) + ) from error + finally: + self._finalize_attempt = None + with self._condition: + self._finalize_succeeded = True + self._condition.notify_all() + with self._condition: + self._closed = True + self._condition.notify_all() + return tuple(self._reports) + + def prepare_complete_close(self) -> None: + """Enqueue finalization without permitting provider entry.""" + + with self._close_lock: + if self._finalize_succeeded: + return + if self.worker_collective_lost: + raise RuntimeError( + "observer finalization refused because its MPI worker collective is lost" + ) + if not self._close_prepared: + raise RuntimeError("observer queue close was not prepared") + if self._shared_worker is None: + return + if self._finalize_attempt is None: + self._finalize_attempt = self._shared_worker.prepare_call(self._finalize_session) + + def cancel_complete_close(self, error: BaseException) -> None: + """Cancel a prepared finalization after collective enqueue refusal.""" + + with self._close_lock: + attempt = self._finalize_attempt + if attempt is None: + return + attempt.cancel(error) + try: + attempt.result() + except BaseException: + pass + self._finalize_attempt = None + + def arm_complete_close(self) -> None: + """Permit provider finalization after collective enqueue admission.""" + + with self._close_lock: + attempt = self._finalize_attempt + if attempt is None: + raise RuntimeError("observer queue finalization was not prepared") + attempt.arm() + + def abort_close(self) -> tuple[ObserverDeliveryReport, ...]: + """Stop an incompletely opened queue through the provider's abort route. + + This is distinct from normal close: a failed run opening must never mix provider + ``abort`` on one MPI rank with ``finalize`` on another. Accepted jobs are allowed to + reach a terminal report first, then abort runs on the same dedicated worker that owns the + provider session. + """ + + if self._worker_communicator is not None: raise RuntimeError( - "observer session finalization failed: " - + _reason(self._lifecycle_error)) from self._lifecycle_error - return self.reports + "an MPI observer queue must abort through the runtime collective protocol" + ) + with self._close_lock: + self.prepare_abort_close() + return self.complete_abort_close() + + def prepare_abort_close(self) -> tuple[ObserverDeliveryReport, ...]: + """Reach the local no-work boundary without entering provider abort.""" + + with self._close_lock: + with self._condition: + if self._closed or self._abort_prepared: + return tuple(self._reports) + if self._worker_collective_lost: + raise RuntimeError( + "observer abort refused because its MPI worker collective is lost" + ) + self._close_requested = True + self._condition.notify_all() + while self._pending: + self._condition.wait() + self._abort_prepared = True + return tuple(self._reports) + + def complete_abort_close(self) -> tuple[ObserverDeliveryReport, ...]: + """Abort only after the runtime proves every MPI peer prepared failed-open cleanup.""" + + with self._close_lock: + with self._condition: + if self._closed: + return tuple(self._reports) + if not self._abort_prepared: + raise RuntimeError("observer queue abort was not prepared") + abort_succeeded = self._abort_succeeded + if self._shared_worker is None: + if self._jobs is None or self._thread is None: # pragma: no cover - invariant + raise RuntimeError("observer queue lost its private worker") + if not abort_succeeded: + attempt = _PrivateQueueAbortAttempt(threading.Event(), []) + self._jobs.put(attempt) + attempt.done.wait() + if attempt.failures: + error = attempt.failures[0] + raise RuntimeError( + "observer session abort failed: " + _reason(error) + ) from error + self._thread.join() + if self._thread.is_alive(): + raise RuntimeError("observer queue private worker did not stop") + elif not abort_succeeded: + attempt = self._abort_attempt + if attempt is None: + if self._worker_communicator is not None: + raise RuntimeError("MPI observer queue abort was not prepared") + self.prepare_complete_abort_close() + attempt = self._abort_attempt + if attempt is None: # pragma: no cover - prepared unless aborted + raise RuntimeError("observer queue lost its prepared abort") + attempt.arm() + try: + attempt.result() + except BaseException as error: + if isinstance(error, _WorkerCollectiveLost): + self._set_lifecycle_error(error) + raise RuntimeError( + "observer session abort failed: " + _reason(error) + ) from error + finally: + self._abort_attempt = None + with self._condition: + self._abort_succeeded = True + self._condition.notify_all() + with self._condition: + self._closed = True + self._condition.notify_all() + return tuple(self._reports) + + def prepare_complete_abort_close(self) -> None: + """Enqueue abort without permitting provider entry.""" + + with self._close_lock: + if self._abort_succeeded: + return + if self.worker_collective_lost: + raise RuntimeError( + "observer abort refused because its MPI worker collective is lost" + ) + if not self._abort_prepared: + raise RuntimeError("observer queue abort was not prepared") + if self._shared_worker is None: + return + if self._abort_attempt is None: + self._abort_attempt = self._shared_worker.prepare_call(self._abort_session) + + def cancel_complete_abort_close(self, error: BaseException) -> None: + """Cancel a prepared abort after collective enqueue refusal.""" + + with self._close_lock: + attempt = self._abort_attempt + if attempt is None: + return + attempt.cancel(error) + try: + attempt.result() + except BaseException: + pass + self._abort_attempt = None + + def arm_complete_abort_close(self) -> None: + """Permit provider abort after collective enqueue admission.""" + + with self._close_lock: + attempt = self._abort_attempt + if attempt is None: + raise RuntimeError("observer queue abort was not prepared") + attempt.arm() def _record(self, report: ObserverDeliveryReport) -> None: with self._condition: @@ -557,14 +1147,17 @@ def _record(self, report: ObserverDeliveryReport) -> None: self._condition.notify_all() def _require_available_locked(self) -> None: - if self._closed: + if not self._initialize_succeeded: + raise RuntimeError("observer queue is not initialized") + if self._close_requested: raise RuntimeError("observer queue is closed") if self._lifecycle_error is not None: - raise RuntimeError( - "observer worker is unavailable: " + _reason(self._lifecycle_error)) + raise RuntimeError("observer worker is unavailable: " + _reason(self._lifecycle_error)) def _set_lifecycle_error(self, error: BaseException) -> None: with self._condition: + if isinstance(error, _WorkerCollectiveLost): + self._worker_collective_lost = True if self._lifecycle_error is None: self._lifecycle_error = error self._condition.notify_all() @@ -581,11 +1174,20 @@ def _skipped_job(self, job: _Job, error: BaseException) -> ObserverDeliveryRepor ) def _fail_job(self, job: _Job, error: BaseException) -> None: + self._forget_deferred_submission(job) self._set_lifecycle_error(error) - self._record(self._skipped_job(job, error)) + try: + self._record(self._skipped_job(job, error)) + except BaseException as record_error: + self._set_lifecycle_error(record_error) + with self._condition: + if self._pending: + self._pending -= 1 + self._condition.notify_all() def _process_job(self, job: _Job) -> None: gate_error = job.gate.wait() + self._forget_deferred_submission(job) if gate_error is not None: self._record(self._skipped_job(job, gate_error)) return @@ -598,49 +1200,88 @@ def _process_job(self, job: _Job) -> None: report = self._deliver(job) except BaseException as error: self._set_lifecycle_error(error) - try: - self._session.abort() - except BaseException as abort_error: - add_note = getattr(error, "add_note", None) - if callable(add_note): - add_note("observer abort also failed: %s" % _reason(abort_error)) + # An MPI provider may only enter abort through the main-thread WORLD gate. A local + # internal failure here must therefore retain the session for collective cleanup. + if self._worker_communicator is None: + try: + self._session.abort() + except BaseException as abort_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("observer abort also failed: %s" % _reason(abort_error)) report = self._skipped_job(job, error) self._record(report) + def _forget_deferred_submission(self, job: _Job) -> None: + with self._condition: + tracked = self._deferred_submission_gates.get(job.sequence) + if tracked is job.gate: + self._deferred_submission_gates.pop(job.sequence, None) + def _deliver(self, job: _Job) -> ObserverDeliveryReport: gate_error: BaseException | None = None if self._worker_communicator is not None: try: from pops._native_collectives import allgather_value, rank, size - request = job.frame.request.to_data() - request.pop("rank") - gate = { - "rank": rank(self._worker_communicator), - "consumer_id": self._consumer_id, - "run_identity": job.frame.snapshot.provenance.run_identity.token, - "sequence": job.sequence, - "clock": job.frame.snapshot.clock.to_data(), - "request": request, - } - rows = allgather_value(self._worker_communicator, gate) + owner = rank(self._worker_communicator) + local_gate = None + local_error = None + try: + request = job.frame.request.to_data() + request.pop("rank") + local_gate = { + "consumer_id": self._consumer_id, + "run_identity": job.frame.snapshot.provenance.run_identity.token, + "sequence": job.sequence, + "clock": job.frame.snapshot.clock.to_data(), + "request": request, + } + except BaseException as error: + local_error = _reason(error) + try: + rows = allgather_value( + self._worker_communicator, + {"rank": owner, "error": local_error, "gate": local_gate}, + ) + except BaseException as error: + raise _WorkerCollectiveLost( + "MPI observer frame gate lost its collective proof: %s" % _reason(error) + ) from error if len(rows) != size(self._worker_communicator) or any( - not isinstance(row, dict) or row.get("rank") != owner - for owner, row in enumerate(rows)): + not isinstance(row, dict) + or set(row) != {"rank", "error", "gate"} + or row["rank"] != peer + or (row["error"] is not None and not isinstance(row["error"], str)) + or (row["error"] is None and not isinstance(row["gate"], dict)) + or (row["error"] is not None and row["gate"] is not None) + for peer, row in enumerate(rows) + ): + raise _WorkerCollectiveLost( + "MPI observer frame gate returned malformed rank evidence" + ) + failures = [ + "rank %d: %s" % (peer, row["error"]) + for peer, row in enumerate(rows) + if row["error"] is not None + ] + if failures: raise RuntimeError( - "MPI observer frame gate returned malformed rank evidence") - canonical = dict(rows[0]) - canonical.pop("rank") - if any( - {key: value for key, value in row.items() if key != "rank"} != canonical - for row in rows[1:]): + "MPI observer frame authority construction failed collectively: " + + "; ".join(failures) + ) + canonical = rows[0]["gate"] + if any(row["gate"] != canonical for row in rows[1:]): raise RuntimeError( - "MPI observer ranks submitted different accepted frame authorities") + "MPI observer ranks submitted different accepted frame authorities" + ) except BaseException as caught: gate_error = caught for attempt in range(1, self._max_attempts + 1): error = gate_error receipt = None + if isinstance(error, _WorkerCollectiveLost): + raise error if error is None: try: receipt = self._session.execute(job.frame) @@ -651,55 +1292,96 @@ def _deliver(self, job: _Job) -> ObserverDeliveryReport: if receipt.provider_id != self._provider_id: raise ValueError( "observer receipt provider_id differs from authenticated session " - "authority") + "authority" + ) except BaseException as caught: error = caught + if isinstance(error, ObserverWorkerCollectiveLost): + raise _as_worker_collective_lost("execution", error) if self._worker_communicator is not None: - from pops._native_collectives import allgather_value, rank + from pops._native_collectives import allgather_value, rank, size - rows = allgather_value(self._worker_communicator, { - "rank": rank(self._worker_communicator), - "error": None if error is None else _reason(error), - }) - failures = [] - for owner, row in enumerate(rows): - if not isinstance(row, dict): - failures.append("rank %d: malformed execution evidence" % owner) - elif row.get("rank") != owner: - failures.append("rank %d: mismatched rank evidence" % owner) - elif row.get("error") is not None: - failures.append("rank %d: %s" % (owner, row["error"])) - if failures: - error = RuntimeError( - "MPI observer execution failed collectively: " + "; ".join(failures)) + try: + rows = allgather_value( + self._worker_communicator, + { + "rank": rank(self._worker_communicator), + "error": None if error is None else _reason(error), + }, + ) + except BaseException as collective_error: + raise _WorkerCollectiveLost( + "MPI observer execution lost its collective proof: %s" + % _reason(collective_error) + ) from collective_error + malformed = len(rows) != size(self._worker_communicator) or any( + not isinstance(row, dict) + or set(row) != {"rank", "error"} + or row["rank"] != owner + or (row["error"] is not None and not isinstance(row["error"], str)) + for owner, row in enumerate(rows) + ) + if malformed: + error = _WorkerCollectiveLost( + "MPI observer execution returned malformed rank evidence" + ) else: - error = None + failures = [ + "rank %d: %s" % (owner, row["error"]) + for owner, row in enumerate(rows) + if row["error"] is not None + ] + error = ( + RuntimeError( + "MPI observer execution failed collectively: " + "; ".join(failures) + ) + if failures + else None + ) if error is None and job.journal is not None: try: job.journal.delivered(job.journal_record) except BaseException as caught: error = caught if self._worker_communicator is not None: - from pops._native_collectives import allgather_value, rank - - rows = allgather_value(self._worker_communicator, { - "rank": rank(self._worker_communicator), - "error": None if error is None else _reason(error), - }) - failures = [] - for owner, row in enumerate(rows): - if not isinstance(row, dict): - failures.append( - "rank %d: malformed journal evidence" % owner) - elif row.get("rank") != owner: - failures.append("rank %d: mismatched rank evidence" % owner) - elif row.get("error") is not None: - failures.append("rank %d: %s" % (owner, row["error"])) - if failures: + from pops._native_collectives import allgather_value, rank, size + + try: + rows = allgather_value( + self._worker_communicator, + { + "rank": rank(self._worker_communicator), + "error": None if error is None else _reason(error), + }, + ) + except BaseException as collective_error: + raise _WorkerCollectiveLost( + "MPI observer journal acknowledgement lost its collective proof: %s" + % _reason(collective_error) + ) from collective_error + malformed = len(rows) != size(self._worker_communicator) or any( + not isinstance(row, dict) + or set(row) != {"rank", "error"} + or row["rank"] != owner + or (row["error"] is not None and not isinstance(row["error"], str)) + for owner, row in enumerate(rows) + ) + if malformed: + error = _WorkerCollectiveLost( + "MPI observer journal acknowledgement returned malformed rank evidence" + ) + else: + failures = [ + "rank %d: %s" % (owner, row["error"]) + for owner, row in enumerate(rows) + if row["error"] is not None + ] + if not malformed and failures: error = RuntimeError( "MPI observer journal acknowledgement failed collectively: " - + "; ".join(failures)) - else: + + "; ".join(failures) + ) + elif not malformed: error = None if error is None: return ObserverDeliveryReport( @@ -713,6 +1395,8 @@ def _deliver(self, job: _Job) -> ObserverDeliveryReport: ) if error is None: # max_attempts validation makes this unreachable error = RuntimeError("observer delivery failed without diagnostic") + if isinstance(error, _WorkerCollectiveLost): + raise error return ObserverDeliveryReport( self._consumer_id, job.frame.snapshot.provenance.run_identity, @@ -724,64 +1408,77 @@ def _deliver(self, job: _Job) -> ObserverDeliveryReport: ) def _worker_agreement( - self, phase: str, error: BaseException | None, + self, + phase: str, + error: BaseException | None, ) -> BaseException | None: """Make one worker lifecycle result uniform before any rank leaves the lane.""" + if isinstance(error, ObserverWorkerCollectiveLost): + return _as_worker_collective_lost(phase, error) if self._worker_communicator is None: return error try: from pops._native_collectives import allgather_value, rank, size - rows = allgather_value(self._worker_communicator, { - "rank": rank(self._worker_communicator), - "error": None if error is None else _reason(error), - }) + rows = allgather_value( + self._worker_communicator, + { + "rank": rank(self._worker_communicator), + "error": None if error is None else _reason(error), + }, + ) if len(rows) != size(self._worker_communicator) or any( - not isinstance(row, dict) - or set(row) != {"rank", "error"} - or row["rank"] != owner - or (row["error"] is not None and not isinstance(row["error"], str)) - for owner, row in enumerate(rows)): - return RuntimeError( - "MPI observer %s returned malformed lifecycle evidence" % phase) + not isinstance(row, dict) + or set(row) != {"rank", "error"} + or row["rank"] != owner + or (row["error"] is not None and not isinstance(row["error"], str)) + for owner, row in enumerate(rows) + ): + return _WorkerCollectiveLost( + "MPI observer %s returned malformed lifecycle evidence" % phase + ) failures = [ "rank %d: %s" % (owner, row["error"]) - for owner, row in enumerate(rows) if row["error"] is not None + for owner, row in enumerate(rows) + if row["error"] is not None ] if failures: return RuntimeError( - "MPI observer %s failed collectively: %s" - % (phase, "; ".join(failures))) + "MPI observer %s failed collectively: %s" % (phase, "; ".join(failures)) + ) return None except BaseException as agreement_error: - return agreement_error + return _WorkerCollectiveLost( + "MPI observer %s lost its lifecycle collective: %s" + % (phase, _reason(agreement_error)) + ) def _initialize_session(self) -> None: initialized = False initialization_error: BaseException | None = None - try: - if self._worker_communicator is not None: - from pops._native_collectives import barrier + if self._worker_communicator is not None: + from pops._native_collectives import barrier + try: barrier(self._worker_communicator) + except BaseException as error: + raise _WorkerCollectiveLost( + "MPI observer initialization barrier lost its collective proof: %s" + % _reason(error) + ) from error + try: result = self._session.initialize(self._run) if result is not None: raise TypeError("observer initialize() must return None") initialized = True except BaseException as error: initialization_error = error - initialization_error = self._worker_agreement( - "initialization", initialization_error) + initialization_error = self._worker_agreement("initialization", initialization_error) if initialization_error is not None: - try: - self._session.abort() - except BaseException as abort_error: - add_note = getattr(initialization_error, "add_note", None) - if callable(add_note): - add_note( - "observer abort after initialization failure also failed: %s" - % _reason(abort_error)) + # The main-thread runtime owns the one gated abort route. Compensating here would + # let successful ranks cache abort completion while failed ranks later re-enter a + # collective provider alone. raise initialization_error if not initialized: # defensive: collective agreement cannot clear a local failure raise RuntimeError("observer initialization lost its local failure evidence") @@ -794,19 +1491,18 @@ def _finalize_session(self) -> None: raise TypeError("observer finalize() must return None") except BaseException as error: finalization_error = error - finalization_error = self._worker_agreement( - "finalization", finalization_error) + finalization_error = self._worker_agreement("finalization", finalization_error) if finalization_error is not None: - try: - self._session.abort() - except BaseException as abort_error: - add_note = getattr(finalization_error, "add_note", None) - if callable(add_note): - add_note( - "observer abort after finalization failure also failed: %s" - % _reason(abort_error)) raise finalization_error + def _abort_session(self) -> None: + try: + result = self._session.abort() + except ObserverWorkerCollectiveLost as error: + raise _as_worker_collective_lost("abort", error) from error + if result is not None: + raise TypeError("observer abort() must return None") + def _worker(self) -> None: if self._jobs is None: # pragma: no cover - constructor establishes this invariant self._set_lifecycle_error(RuntimeError("observer queue lost its private jobs")) @@ -818,21 +1514,46 @@ def _worker(self) -> None: self._set_lifecycle_error(error) self._ready.set() return + self._initialize_succeeded = True self._ready.set() - try: - while True: - item = self._jobs.get() - try: - if item is _STOP: - break - if type(item) is not _Job: - raise TypeError("observer queue received an invalid internal job") - self._process_job(item) - finally: - self._jobs.task_done() - self._finalize_session() - except BaseException as error: - self._set_lifecycle_error(error) + while True: + item = self._jobs.get() + try: + if type(item) is _PrivateQueueCloseAttempt: + try: + self._finalize_session() + except BaseException as error: + item.failures.append(error) + else: + with self._condition: + self._finalize_succeeded = True + self._condition.notify_all() + finally: + item.done.set() + if not item.failures: + return + continue + if type(item) is _PrivateQueueAbortAttempt: + try: + self._abort_session() + except BaseException as error: + item.failures.append(error) + else: + with self._condition: + self._abort_succeeded = True + self._condition.notify_all() + finally: + item.done.set() + if not item.failures: + return + continue + if type(item) is not _Job: + raise TypeError("observer queue received an invalid internal job") + self._process_job(item) + except BaseException as error: + self._set_lifecycle_error(error) + finally: + self._jobs.task_done() def __enter__(self) -> PostCommitObserverQueue: return self @@ -843,5 +1564,7 @@ def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: __all__ = [ - "ObserverDeliveryReport", "PostCommitObserverQueue", "PostCommitObserverWorker", + "ObserverDeliveryReport", + "PostCommitObserverQueue", + "PostCommitObserverWorker", ] diff --git a/python/pops/runtime/_platform_manifest.py b/python/pops/runtime/_platform_manifest.py index a0d91bc22..8d49f2748 100644 --- a/python/pops/runtime/_platform_manifest.py +++ b/python/pops/runtime/_platform_manifest.py @@ -96,15 +96,28 @@ def native_runtime_backend_for_route(backend, target, communicator): memory_spaces = data["memory_spaces"] if not isinstance(memory_spaces, (list, tuple)): raise TypeError("native runtime memory_spaces must be a sequence") - result = RuntimeBackendManifest( + legacy_capabilities = { + name: proof(tuple(value) if isinstance(value, list) else value) + for name, value in capabilities.items() + } + legacy = RuntimeBackendManifest( backend=proof(data["backend"]), target=proof(data["target"]), abi=proof(data["abi"]), precision=PrecisionPolicy(**{name: proof(value) for name, value in precision.items()}), device=proof(data["device"]), memory_spaces=proof(tuple(memory_spaces)), communicator=proof(data["communicator"]), - capabilities={name: proof(tuple(value) if isinstance(value, list) else value) - for name, value in capabilities.items()}) - if result.identity.token != data["identity"]: + capabilities=legacy_capabilities) + if legacy.identity.token != data["identity"]: raise ValueError("native RuntimeBackendManifest identity does not match its exact payload") + if "supported_dimensions" in legacy_capabilities or "dimensions" not in legacy_capabilities: + raise ValueError( + "native RuntimeBackendManifest must expose the exact legacy dimensions wire field") + translated_capabilities = dict(legacy_capabilities) + translated_capabilities["supported_dimensions"] = translated_capabilities.pop("dimensions") + result = RuntimeBackendManifest( + backend=proof(data["backend"]), target=proof(data["target"]), abi=proof(data["abi"]), + precision=PrecisionPolicy(**{name: proof(value) for name, value in precision.items()}), + device=proof(data["device"]), memory_spaces=proof(tuple(memory_spaces)), + communicator=proof(data["communicator"]), capabilities=translated_capabilities) return result diff --git a/python/pops/runtime/_program_cadence_install.py b/python/pops/runtime/_program_cadence_install.py new file mode 100644 index 000000000..9d1cbef52 --- /dev/null +++ b/python/pops/runtime/_program_cadence_install.py @@ -0,0 +1,42 @@ +"""Bind-time installation of the immutable cadence carried by a compiled Program.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, cast + + +def install_program_cadence(engine: Any, program: Any) -> None: + """Install one authenticated cadence before the native Program and runtime freeze.""" + from pops.time._cadence import ProgramCadence + from pops.time._program.contract import require_program + + require_program(program, exact=True, where="pops.bind Program cadence") + if getattr(program, "_compiled_detached", False) is not True \ + or getattr(program, "_frozen", False) is not True: + raise TypeError( + "pops.bind Program cadence requires the frozen compiled Program authority" + ) + contract = program.cadence_contract() + if type(contract) is not ProgramCadence: + raise TypeError("pops.bind Program cadence is not an exact ProgramCadence") + setter = getattr(engine, "set_program_cadence", None) + if not callable(setter): + raise RuntimeError("pops.bind runtime cannot install the authored Program cadence") + setter(contract.substeps, contract.stride) + + substeps = getattr(engine, "program_substeps", None) + stride = getattr(engine, "program_stride", None) + if not callable(substeps) or not callable(stride): + raise RuntimeError("pops.bind runtime cannot authenticate the installed Program cadence") + installed_substeps = cast(Callable[[], int], substeps) + installed_stride = cast(Callable[[], int], stride) + actual = (int(installed_substeps()), int(installed_stride())) + expected = (contract.substeps, contract.stride) + if actual != expected: + raise RuntimeError( + "pops.bind runtime Program cadence differs from the compiled contract: " + "expected=%r actual=%r" % (expected, actual) + ) + + +__all__ = ["install_program_cadence"] diff --git a/python/pops/runtime/_runtime_authorities.py b/python/pops/runtime/_runtime_authorities.py index 4ceea4dac..068bff101 100644 --- a/python/pops/runtime/_runtime_authorities.py +++ b/python/pops/runtime/_runtime_authorities.py @@ -109,6 +109,7 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: execution_data = component_execution_data(install_plan.execution_context) component_installers = { "apply_region_batch": getattr(native, "_install_ghost_boundary_component", None), + "transform_faces": getattr(native, "_install_boundary_flux_component", None), "residual": getattr(native, "_install_field_boundary_residual_component", None), "jvp": getattr(native, "_install_field_boundary_jvp_component", None), } @@ -158,16 +159,102 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: or [row.get("ordinal") for row in faces] != [0, 1, 2, 3]: raise ValueError("prepared boundary plan must contain canonical xlo/xhi/ylo/yhi rows") types = [row.get("type") for row in faces] - if any(value not in {"periodic", "foextrap", "dirichlet", "external"} + if any(value not in { + "periodic", "foextrap", "dirichlet", "no_flux", "slip_wall", "external", + "characteristic_no_inflow"} for value in types): raise NotImplementedError("prepared boundary plan selected an unavailable face producer") + if "characteristic_no_inflow" in types and not bool( + getattr(component, "has_characteristic_no_inflow", False)): + raise NotImplementedError( + "characteristic no-inflow requires a compiled model prepared with " + "m.roe_from_jacobian(); no component-wise or Euler-specific fallback exists" + ) + representations = [row.get("representation", "conservative") for row in faces] + converter_identities = [row.get("converter") for row in faces] + for face, (face_type, representation, converter) in enumerate(zip( + types, representations, converter_identities, strict=True)): + if representation == "conservative": + if converter is not None: + raise ValueError( + "prepared conservative boundary face must not carry a converter") + elif representation == "primitive": + if face_type != "dirichlet" or not isinstance(converter, str) or not converter: + raise ValueError( + "prepared primitive boundary face %d requires an exact fixed-state " + "converter identity" % face) + else: + raise NotImplementedError( + "prepared boundary selected unavailable representation %r" % representation) + if face_type == "characteristic_no_inflow" and representation != "conservative": + raise NotImplementedError( + "characteristic no-inflow requires a conservative reference state" + ) + face_identities = [row.get("producer") for row in faces] + if any(not isinstance(value, str) or not value for value in face_identities): + raise TypeError( + "prepared boundary faces require non-empty owner-qualified producer identities") + component_roles = getattr(component, "cons_roles", None) + if not isinstance(component_roles, (list, tuple)) \ + or len(component_roles) != ncomp \ + or any(not isinstance(role, str) or not role for role in component_roles): + raise TypeError( + "compiled block must expose one authenticated physical role per component") values = [] + analytic_opcodes = [] + analytic_literals = [] + analytic_clocks = [] + plan_clocks = set() for comp in range(ncomp): for row in faces: row_values = row.get("values") if not isinstance(row_values, list) or len(row_values) != ncomp: raise ValueError("prepared boundary face values must exactly cover every component") values.append(float(row_values[comp])) + for face, row in enumerate(faces): + programs = row.get("analytic_programs", []) + clock = row.get("analytic_clock") + if not isinstance(programs, list) or len(programs) not in (0, ncomp): + raise ValueError( + "prepared boundary analytic programs must be empty or cover every component" + ) + if programs and (types[face] != "dirichlet" or representations[face] != "conservative"): + raise NotImplementedError( + "prepared analytic boundary programs require conservative fixed-state inflow" + ) + if clock is not None and (not isinstance(clock, str) or not clock or not programs): + raise TypeError( + "prepared boundary analytic Clock must be non-empty text on an analytic face" + ) + analytic_clocks.append("" if clock is None else clock) + if clock is not None: + plan_clocks.add(clock) + for component in range(ncomp): + if not programs: + analytic_opcodes.append([]) + analytic_literals.append([]) + continue + program = programs[component] + if not isinstance(program, dict) or set(program) != {"opcodes", "literals"}: + raise TypeError( + "prepared boundary analytic program must contain opcodes and literals" + ) + opcodes = program["opcodes"] + literals = program["literals"] + if ( + not isinstance(opcodes, list) + or not opcodes + or any(not isinstance(opcode, str) or not opcode for opcode in opcodes) + or not isinstance(literals, list) + or len(literals) != len(opcodes) + ): + raise ValueError( + "prepared boundary analytic opcode/literal rows must be non-empty and aligned" + ) + analytic_opcodes.append(opcodes) + analytic_literals.append([float(value) for value in literals]) + if len(plan_clocks) > 1: + raise ValueError("prepared analytic boundary plan cannot mix several logical Clocks") boundary_state_identity = _canonical_qualified_id( first.get("state"), where="prepared boundary state") if boundary_state_identity != state_identity: @@ -182,10 +269,16 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: required_depth, types, values, - ncomp, + face_identities, + list(component_roles), list(first.get("omitted_interface_faces", [])), state_identity, periodic_identifications, + representations, + ["" if value is None else value for value in converter_identities], + analytic_opcodes, + analytic_literals, + analytic_clocks, ) component_rows = first.get("component_regions", []) if not isinstance(component_rows, list): @@ -252,6 +345,12 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: if operation in {"residual", "jvp"} and len(row["outputs"]) != 1: raise NotImplementedError( "native boundary residual/JVP currently requires one exact mutable output") + if operation == "transform_faces" and ( + len(row["outputs"]) != 1 or + row["outputs"][0] != row["state_identity"] or row["directions"]): + raise NotImplementedError( + "native post-Riemann boundary flux requires one exact state output and no " + "JVP direction table") component_jobs.append(( install_component, block.name, @@ -373,20 +472,90 @@ def _materialized_shared_interface_levels(native: Any, hierarchy: Any) -> tuple[ return tuple(range(materialized)) +def _requires_shared_interface_implicit_jacvec_pair(install_plan: Any) -> bool: + """Read the authenticated compiled-Program requirement, retaining old explicit artifacts.""" + capabilities = install_plan.artifact.plan.capabilities + if not isinstance(capabilities, Mapping): + raise TypeError("compiled shared-interface capabilities must be a mapping") + evidence = capabilities.get("shared_interfaces") + if evidence is None: + # Artifacts predating the implicit pair route could contain only explicit shared rates. + return False + if not isinstance(evidence, Mapping) or set(evidence) != {"implicit_jacvec_pair"}: + raise TypeError("compiled shared-interface capability evidence is not canonical") + required = evidence["implicit_jacvec_pair"] + if type(required) is not bool: + raise TypeError("compiled shared-interface implicit-JVP requirement must be an exact bool") + return required + + +def _validate_shared_interface_implicit_execution_envelope( + execution_data: dict[str, Any], rank_count: int +) -> None: + """Authenticate the narrow native pair envelope without mutating runtime state.""" + if type(rank_count) is not int or rank_count < 1: + raise RuntimeError("native shared-interface rank count must be a positive integer") + device = execution_data.get("device_identity") + memory_space = execution_data.get("memory_space") + if device not in ("host", "cpu") or memory_space != 1: + raise NotImplementedError( + "shared NumericalFlux implicit JVP is currently host-memory-only; device or " + "managed-memory execution is refused until its paired packing and residual " + "evaluation have a native portability proof") + communicator = execution_data.get("communicator_identity") + if communicator != "serial" or rank_count != 1: + raise NotImplementedError( + "shared NumericalFlux implicit JVP is currently serial-only; MPI execution is " + "refused until its pair admission and local packing have a collective deadlock proof") + + +def _validate_shared_interface_implicit_execution_before_install( + install_plan: Any, +) -> None: + """Refuse an unsupported compiled pair before Program or interface installation mutates AMR.""" + if not _requires_shared_interface_implicit_jacvec_pair(install_plan): + return + from pops.runtime._component_execution_context import component_execution_data + from pops import _pops + + _validate_shared_interface_implicit_execution_envelope( + component_execution_data(install_plan.execution_context), + _pops.n_ranks(), + ) + + def _validate_refined_shared_interface_execution( levels: tuple[int, ...], execution_data: dict[str, Any], rank_count: int, *, dynamic_regrid: bool = False, + implicit_jacvec_pair: bool = False, + complete_bind: bool = False, ) -> None: - """Require one contiguous materialized prefix on the selected communicator.""" + """Require one contiguous materialized prefix on the selected communicator. + + Frozen and depth-preserving dynamic hierarchies share this exact execution contract. Native + rematerialization prepares a detached collective registry and publishes it only after every + ``MPI_COMM_WORLD`` rank agrees on the replacement layout identity. + """ if not levels or levels != tuple(range(len(levels))): raise ValueError("shared-interface materialized levels must be a contiguous L0 prefix") if type(rank_count) is not int or rank_count < 1: raise RuntimeError("native shared-interface rank count must be a positive integer") if type(dynamic_regrid) is not bool: raise TypeError("shared-interface dynamic_regrid must be an exact bool") + if type(implicit_jacvec_pair) is not bool or type(complete_bind) is not bool: + raise TypeError( + "shared-interface implicit-JVP and complete-bind contracts must be exact bools") + if implicit_jacvec_pair: + _validate_shared_interface_implicit_execution_envelope( + execution_data, rank_count + ) + if implicit_jacvec_pair and complete_bind and levels != (0, 1): + raise NotImplementedError( + "shared NumericalFlux implicit JVP requires exactly materialized levels (L0, L1) " + "at bind") communicator = execution_data.get("communicator_identity") if communicator == "serial": if rank_count != 1: @@ -483,6 +652,7 @@ def finalize_runtime_authorities( raise ValueError("native block registry contains duplicate names") block_indices = {name: index for index, name in enumerate(block_names)} execution_data = component_execution_data(install_plan.execution_context) + implicit_jacvec_pair = _requires_shared_interface_implicit_jacvec_pair(install_plan) adaptive = {row.adaptive for row in install_plan.artifact.layout_plan.layouts} levels = (0,) if adaptive == {True}: @@ -502,7 +672,13 @@ def finalize_runtime_authorities( from pops import _pops _validate_refined_shared_interface_execution( - levels, execution_data, _pops.n_ranks(), dynamic_regrid=dynamic_refined) + levels, + execution_data, + _pops.n_ranks(), + dynamic_regrid=dynamic_refined, + implicit_jacvec_pair=implicit_jacvec_pair, + complete_bind=complete, + ) if complete and dynamic_refined and levels != tuple(range(hierarchy.level_count)): raise NotImplementedError( "dynamic shared interfaces require the complete configured prefix materialized " @@ -643,8 +819,10 @@ def _install_amr_provider_authorities(engine: Any, install_plan: Any) -> None: """Install every AMR provider through its authority-carried runtime protocol.""" providers = install_plan.amr_providers - if not isinstance(providers, Mapping) or tuple(providers) != ("clustering", "tagger"): - raise ValueError("adaptive runtime requires exact clustering and tagger providers") + if not isinstance(providers, Mapping) \ + or tuple(providers) != ("clustering", "tagger", "reflux"): + raise ValueError( + "adaptive runtime requires exact clustering, tagger and reflux providers") native = getattr(engine, "_s", None) from pops.amr.providers import prepare_amr_provider_installation from pops.runtime._component_execution_context import component_execution_data diff --git a/python/pops/runtime/_runtime_consumers.py b/python/pops/runtime/_runtime_consumers.py index 0d7dbddd1..328b4c026 100644 --- a/python/pops/runtime/_runtime_consumers.py +++ b/python/pops/runtime/_runtime_consumers.py @@ -44,6 +44,8 @@ from pops.output.observers import ( ObserverFrame, ObserverRun, + ObserverWorkerCollectiveLost, + authenticate_observer_session, ) from pops.output._consumer_contracts import ConsumerKind, ParallelMode from pops.output._writers.common import ( @@ -76,6 +78,141 @@ _BUILTIN_CATALYST_PROCESS_STARTED = False +class _ObserverCollectiveLost(RuntimeError): + """The runtime cannot prove that every rank completed a control collective.""" + + +class _ObserverWorkerLaneLost(RuntimeError): + """A duplicated observer lane is sealed while MPI_COMM_WORLD remains usable.""" + + +class _ObserverCollectiveRejected(RuntimeError): + """Every rank returned valid evidence and at least one reported a local failure.""" + + +def _observer_provider_id(operation_data: Any) -> str: + """Read the authenticated provider id from either supported observer schema.""" + + if not isinstance(operation_data, Mapping): + raise TypeError("post-commit operation_data must be a mapping") + observer = operation_data.get("observer") + if not isinstance(observer, Mapping): + raise TypeError("post-commit operation_data lost its observer authority") + nested = observer.get("provider") + provider_id = nested.get("provider_id") if isinstance(nested, Mapping) else None + direct = observer.get("provider_id") + if provider_id is None: + provider_id = direct + elif direct is not None and direct != provider_id: + raise ValueError("post-commit observer provider authorities disagree") + if not isinstance(provider_id, str) or not provider_id: + raise TypeError("post-commit observer requires a non-empty provider_id") + return provider_id + + +class _PendingObserverSession: + """Run-qualified authority retained until a pre-queue session is aborted or transferred.""" + + __slots__ = ( + "_abort_succeeded", + "_authentication_error", + "_worker_collective_lost", + "consumer_id", + "provider_id", + "run_identity", + "session", + "worker_mpi", + ) + + def __init__( + self, + run_identity: Identity, + consumer_id: str, + provider_id: str, + worker_mpi: bool, + session: Any, + ) -> None: + if type(run_identity) is not Identity or run_identity.domain != "run": + raise TypeError("pending observer session requires an exact run Identity") + if not isinstance(consumer_id, str) or not consumer_id: + raise TypeError("pending observer session requires a non-empty consumer id") + if not isinstance(provider_id, str) or not provider_id: + raise TypeError("pending observer session requires a non-empty provider id") + if type(worker_mpi) is not bool: + raise TypeError("pending observer session worker_mpi must be an exact bool") + self.run_identity = run_identity + self.consumer_id = consumer_id + self.provider_id = provider_id + self.worker_mpi = worker_mpi + self.session = session + self._abort_succeeded = False + self._worker_collective_lost = False + authentication_error = None + try: + authority = authenticate_observer_session(session) + if authority["provider_id"] != provider_id: + raise ValueError( + "observer session provider_id differs from its manifest: %r != %r" + % (authority["provider_id"], provider_id) + ) + if authority["worker_mpi"] is not worker_mpi: + raise ValueError( + "observer session worker_mpi differs from its resolved parallel mode" + ) + except BaseException as error: + authentication_error = _exception_text(error) + self._authentication_error = authentication_error + + @property + def authority(self) -> Any: + return self.session.authority + + @property + def abort_succeeded(self) -> bool: + return self._abort_succeeded + + @property + def worker_collective_lost(self) -> bool: + return self._worker_collective_lost + + @property + def authenticated(self) -> bool: + return self._authentication_error is None + + @property + def authentication_error(self) -> str | None: + return self._authentication_error + + @property + def close_authority(self) -> dict[str, str]: + return { + "run_identity": self.run_identity.token, + "consumer_id": self.consumer_id, + "provider_id": self.provider_id, + } + + def abort(self) -> None: + if self._abort_succeeded: + return + try: + result = self.session.abort() + except ObserverWorkerCollectiveLost: + self._worker_collective_lost = True + raise + if result is not None: + raise TypeError("observer abort() must return None") + self._abort_succeeded = True + + def initialize(self, run: ObserverRun) -> Any: + return self.session.initialize(run) + + def execute(self, frame: ObserverFrame) -> Any: + return self.session.execute(frame) + + def finalize(self) -> Any: + return self.session.finalize() + + def _reserve_builtin_catalyst_process_lifecycle() -> None: """Reserve Catalyst's process-global initialize/finalize lifecycle exactly once.""" @@ -242,7 +379,13 @@ def _post_commit_root_consensus( ) -> None: """Reach exactly one ROOT status collective before exposing any local failure.""" - rows = allgather_value(communicator, {"rank": rank, "error": error}) + try: + rows = allgather_value(communicator, {"rank": rank, "error": error}) + except BaseException as collective_error: + raise _ObserverCollectiveLost( + "ROOT post-commit %s lost its collective proof: %s" + % (phase, _exception_text(collective_error)) + ) from collective_error if len(rows) != size or any( not isinstance(row, Mapping) or set(row) != {"rank", "error"} @@ -250,14 +393,16 @@ def _post_commit_root_consensus( or (row["error"] is not None and not isinstance(row["error"], str)) for owner_rank, row in enumerate(rows) ): - raise RuntimeError("ROOT post-commit %s returned a malformed envelope" % phase) + raise _ObserverCollectiveLost("ROOT post-commit %s returned a malformed envelope" % phase) failures = [ "rank %d: %s" % (owner_rank, row["error"]) for owner_rank, row in enumerate(rows) if row["error"] is not None ] if failures: - raise RuntimeError("ROOT post-commit %s failed: %s" % (phase, "; ".join(failures))) + raise _ObserverCollectiveRejected( + "ROOT post-commit %s failed: %s" % (phase, "; ".join(failures)) + ) class _PreparedDiagnostic(PreparedPublication): @@ -281,6 +426,10 @@ def effect_identity(self) -> Identity: def payload_identity(self) -> Identity: return self._effect.payload.identity + @property + def recoveries(self) -> tuple[Any, ...]: + return () + def publish(self) -> PublicationReceipt: if self._discarded: raise RuntimeError("discarded diagnostic cannot be published") @@ -464,7 +613,10 @@ def payload_identity(self) -> Identity: @property def recoveries(self) -> tuple[Any, ...]: - return self._output.recoveries + recoveries = getattr(self._output, "recoveries", ()) + if not isinstance(recoveries, tuple): + raise TypeError("prepared output recoveries must be a tuple") + return recoveries def publish(self) -> PublicationReceipt: if self._discarded: @@ -593,6 +745,11 @@ def rollback(self) -> None: self._published = False self._discarded = True + def finalize(self) -> None: + finalize = getattr(self._snapshot, "finalize", None) + if callable(finalize): + finalize() + def _writer_snapshot_data(snapshot: OutputSnapshot, request: OutputRequest) -> dict[str, Any]: """Project the complete selected snapshot into the generated Writer POD vocabulary.""" @@ -1286,15 +1443,25 @@ def __init__(self, owner: Any) -> None: self._diagnostics: dict[str, DiagnosticPayload] = {} self._baselines: dict[str, float] = {} self._rank, self._size, self._communicator = rank, size, communicator - self._observer_queues: dict[tuple[str, str], PostCommitObserverQueue] = {} + self._observer_queues: dict[tuple[str, str], PostCommitObserverQueue | None] = {} self._observer_lanes: dict[tuple[str, str], Any] = {} - self._observer_workers: dict[str, PostCommitObserverWorker] = {} + self._root_output_lanes: dict[str, Any] = {} + self._observer_workers: dict[str, PostCommitObserverWorker | None] = {} + self._observer_pending_sessions: dict[tuple[str, str], _PendingObserverSession | None] = {} self._observer_journals: dict[tuple[str, str], Any] = {} self._observer_preflight_sessions: dict[str, Any] = {} self._observer_reports: dict[str, ObserverDeliveryReport] = {} + self._observer_pending_reports: dict[ + tuple[str, str], tuple[ObserverDeliveryReport, ...] + ] = {} + self._observer_report_run_authorities: dict[tuple[str, str], frozenset[Identity]] = {} self._observer_pending_failures: dict[tuple[str, str], list[str]] = {} + self._observer_abort_retry_blocked: set[tuple[str, str]] = set() + self._observer_finalize_retry_blocked: set[tuple[str, str]] = set() + self._observer_world_collective_lost: str | None = None self._observer_diagnostics: list[str] = [] self._closed_observer_runs: set[str] = set() + self._observer_run_phases: dict[str, str] = {} self._output = ConsumerOutputPublisher( self._resolve_output, retain_recoveries=owner._retain_output_recoveries, @@ -1321,6 +1488,18 @@ def __init__(self, owner: Any) -> None: ) self._builtin_catalyst_consumers = tuple(sorted(builtin_catalyst)) self._builtin_catalyst_run_started = False + self._root_output_consumers = tuple( + sorted( + candidate.qualified_id + for candidate in owner._consumer_graph.nodes + if candidate.kind + in { + ConsumerKind.SCIENTIFIC_OUTPUT, + ConsumerKind.MONITOR, + } + and candidate.parallel_mode is ParallelMode.ROOT + ) + ) from pops import interfaces for manifest in owner._consumer_graph.nodes: @@ -1475,11 +1654,8 @@ def accepted_diagnostics(self) -> tuple[DiagnosticPayload, ...]: @property def post_commit_reports(self) -> tuple[ObserverDeliveryReport, ...]: - """Terminal post-commit deliveries, including reports from a still-open run.""" + """Deliveries authenticated by the run's required main-thread consensus.""" rows = dict(self._observer_reports) - for observer_queue in self._observer_queues.values(): - for report in observer_queue.reports: - rows[report.identity.token] = report return tuple( sorted( rows.values(), @@ -1501,6 +1677,143 @@ def post_commit_diagnostics(self) -> tuple[str, ...]: ) return tuple(self._observer_diagnostics) + pending + def seal_observer_collective_loss(self, error: BaseException) -> bool: + """Seal WORLD-backed observer operations when an exception chain lost their proof.""" + + if getattr(self, "_observer_world_collective_lost", None) is not None: + return True + pending: list[BaseException] = [error] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, _ObserverCollectiveLost): + if getattr(self, "_observer_world_collective_lost", None) is None: + self._observer_world_collective_lost = _exception_text(current) + return True + if current.__cause__ is not None: + pending.append(current.__cause__) + if current.__context__ is not None: + pending.append(current.__context__) + return False + + def seal_observer_workers_after_world_loss(self, error: BaseException) -> tuple[str, ...]: + """Stop local non-daemon workers without MPI or provider lifecycle re-entry.""" + + if not isinstance(error, BaseException): + raise TypeError("observer WORLD-loss sealing requires an exception") + if getattr(self, "_observer_world_collective_lost", None) is None: + raise RuntimeError("observer workers may be sealed only after WORLD proof loss") + local_error = RuntimeError( + "post-commit worker sealed locally after MPI_COMM_WORLD collective proof loss" + ) + failures: list[str] = [] + for key in sorted(getattr(self, "_observer_queues", {})): + observer_queue = self._observer_queues[key] + if observer_queue is None: + continue + seal_local = getattr(observer_queue, "seal_local", None) + if not callable(seal_local): + failures.append("observer queue %r has no local seal route" % (key,)) + continue + try: + seal_local(local_error) + except BaseException as caught: + failures.append( + "observer queue %r local seal failed: %s" % (key, _exception_text(caught)) + ) + for run_key in sorted(getattr(self, "_observer_workers", {})): + worker = self._observer_workers[run_key] + if worker is None: + continue + seal_local = getattr(worker, "seal_local", None) + if not callable(seal_local): + failures.append("observer worker %s has no local seal route" % run_key) + continue + try: + seal_local(local_error) + except BaseException as caught: + failures.append( + "observer worker %s local seal failed: %s" % (run_key, _exception_text(caught)) + ) + return tuple(failures) + + def _refuse_lost_observer_world(self) -> None: + reason = getattr(self, "_observer_world_collective_lost", None) + if reason is not None: + raise RuntimeError( + "post-commit MPI_COMM_WORLD is sealed after collective proof loss: %s" % reason + ) + + def require_observer_world_available(self) -> None: + """Refuse reuse of a RuntimeInstance whose observer control world lost proof.""" + + self._refuse_lost_observer_world() + + def failed_run_effect_fence(self) -> str: + """Authenticate publisher state whose mutation makes a run identity non-reusable.""" + + def encoded(value: Any) -> str: + collective = getattr(value, "to_collective_data", None) + if callable(collective): + value = collective() + else: + data = getattr(value, "to_data", None) + if callable(data): + value = data() + return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + + pending = getattr(self, "_pending", {}) + pending_baselines = getattr(self, "_pending_baselines", {}) + diagnostics = getattr(self, "_diagnostics", {}) + baselines = getattr(self, "_baselines", {}) + observer_reports = getattr(self, "_observer_reports", {}) + observer_pending_reports = getattr(self, "_observer_pending_reports", {}) + observer_run_authorities = getattr(self, "_observer_report_run_authorities", {}) + observer_failures = getattr(self, "_observer_pending_failures", {}) + payload = { + "pending": [ + [key, [encoded(value) for value in pending[key]]] for key in sorted(pending) + ], + "pending_baselines": [ + [ + key, + [ + [name, float(value).hex()] + for name, value in sorted(pending_baselines[key].items()) + ], + ] + for key in sorted(pending_baselines) + ], + "diagnostics": [[key, encoded(diagnostics[key])] for key in sorted(diagnostics)], + "baselines": [[key, float(baselines[key]).hex()] for key in sorted(baselines)], + "observer_journals": [ + list(key) for key in sorted(getattr(self, "_observer_journals", {})) + ], + "observer_preflight_sessions": sorted( + getattr(self, "_observer_preflight_sessions", {}) + ), + "observer_reports": [ + [key, encoded(observer_reports[key])] for key in sorted(observer_reports) + ], + "observer_pending_reports": [ + [list(key), [encoded(report) for report in observer_pending_reports[key]]] + for key in sorted(observer_pending_reports) + ], + "observer_report_run_authorities": [ + [list(key), sorted(identity.token for identity in observer_run_authorities[key])] + for key in sorted(observer_run_authorities) + ], + "observer_failures": [ + [list(key), list(observer_failures[key])] for key in sorted(observer_failures) + ], + "observer_diagnostics": list(getattr(self, "_observer_diagnostics", ())), + "builtin_catalyst_started": bool(getattr(self, "_builtin_catalyst_run_started", False)), + } + return make_identity("failed-run-consumer-fence", payload).token + @property def live_visualization_reports(self) -> tuple[ObserverDeliveryReport, ...]: """Compatibility alias for :attr:`post_commit_reports`.""" @@ -1600,14 +1913,22 @@ def _inspect_observer_journal( local_error = _exception_text(error) records = () local_events = [] - rows = allgather_value( - self._communicator, - { - "rank": self._rank, - "events": local_events, - "error": local_error, - }, - ) + try: + rows = allgather_value( + self._communicator, + { + "rank": self._rank, + "events": local_events, + "error": local_error, + }, + ) + except BaseException as error: + lost = _ObserverCollectiveLost( + "durable MPI observer replay lost its WORLD inspection proof: %s" + % _exception_text(error) + ) + self.seal_observer_collective_loss(lost) + raise lost from error if len(rows) != self._size or any( not isinstance(row, Mapping) or set(row) != {"rank", "events", "error"} @@ -1616,7 +1937,11 @@ def _inspect_observer_journal( or (row["error"] is not None and not isinstance(row["error"], str)) for owner, row in enumerate(rows) ): - raise RuntimeError("durable MPI observer replay returned malformed rank evidence") + lost = _ObserverCollectiveLost( + "durable MPI observer replay returned malformed WORLD rank evidence" + ) + self.seal_observer_collective_loss(lost) + raise lost failures = [ "rank %d: %s" % (owner, row["error"]) for owner, row in enumerate(rows) @@ -1695,6 +2020,18 @@ def _replay_observer_journal( raise if submission is not None: submission.arm() + delivery_error = None + try: + observer_queue.flush() + except BaseException as error: + delivery_error = _exception_text(error) + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=delivery_error, + phase="durable replay delivery %d" % index, + ) return for record in records: observer_queue.submit(record.frame, journal=journal, journal_record=record) @@ -1730,10 +2067,13 @@ def _observer_queue( *, session: Any = None, recovery_run_identities: tuple[Identity, ...] = (), + defer_initialize: bool = False, ) -> PostCommitObserverQueue: key = self._observer_key(manifest.qualified_id, run_identity) - current = self._observer_queues.get(key) - if current is not None: + if key in self._observer_queues: + current = self._observer_queues[key] + if current is None: + raise RuntimeError("post-commit queue construction is already reserved") return current operation_data = manifest.operation_data if operation_data is None: @@ -1750,34 +2090,66 @@ def _observer_queue( }, recovery_run_identities, ) - current = PostCommitObserverQueue( - session, - observer_run, - consumer_id=manifest.qualified_id, - capacity=operation_data["queue_capacity"], - max_attempts=operation_data["max_attempts"], - thread_name="pops-live-%s" % manifest.identity.hexdigest[:12], - worker_communicator=lane, - shared_worker=self._observer_worker(run_identity), - ) + accepted_runs = frozenset(observer_run.accepted_run_identities) + report_authorities = getattr(self, "_observer_report_run_authorities", None) + if report_authorities is None: + report_authorities = {} + self._observer_report_run_authorities = report_authorities + retained_runs = report_authorities.get(key) + if retained_runs is not None and retained_runs != accepted_runs: + raise RuntimeError("observer queue report run authority changed during construction") + report_authorities[key] = accepted_runs + self._observer_queues[key] = None + try: + current = PostCommitObserverQueue( + session, + observer_run, + consumer_id=manifest.qualified_id, + capacity=operation_data["queue_capacity"], + max_attempts=operation_data["max_attempts"], + thread_name="pops-live-%s" % manifest.identity.hexdigest[:12], + worker_communicator=lane, + shared_worker=self._observer_worker(run_identity), + defer_initialize=defer_initialize, + ) + except BaseException: + if self._observer_queues.get(key) is None: + self._observer_queues.pop(key, None) + if self._observer_queues.get(key) is None: + report_authorities.pop(key, None) + raise self._observer_queues[key] = current return current def _observer_worker(self, run_identity: Identity) -> PostCommitObserverWorker: self._observer_key("worker", run_identity) - current = self._observer_workers.get(run_identity.token) - if current is None: + run_key = run_identity.token + if run_key in self._observer_workers: + current = self._observer_workers[run_key] + if current is None: + raise RuntimeError("post-commit worker construction is already reserved") + return current + self._observer_workers[run_key] = None + try: current = PostCommitObserverWorker( - thread_name="pops-post-commit-%s" % run_identity.hexdigest[:12] + thread_name="pops-post-commit-%s" % run_identity.hexdigest[:12], + run_identity=run_identity, ) - self._observer_workers[run_identity.token] = current + except BaseException: + if self._observer_workers.get(run_key) is None: + self._observer_workers.pop(run_key, None) + raise + self._observer_workers[run_key] = current return current def _drain_post_commit_before_hdf5(self) -> None: """Exclude process-global observer-library calls from synchronous HDF5 publication.""" for key in sorted(self._observer_queues): - self._observer_queues[key].flush() + observer_queue = self._observer_queues[key] + if observer_queue is None: + raise RuntimeError("post-commit queue construction remained reserved") + observer_queue.flush() def begin_post_commit_consumers(self, run_identity: Identity) -> None: """Initialize every active post-commit session before the first consumer/step. @@ -1788,7 +2160,66 @@ def begin_post_commit_consumers(self, run_identity: Identity) -> None: before any rank exposes a local failure. """ + self._refuse_lost_observer_world() self._observer_key("run-begin", run_identity) + if run_identity.token in self._closed_observer_runs: + raise RuntimeError("post-commit consumers cannot reopen an already closed run") + if run_identity.token in self._observer_run_phases: + raise RuntimeError("post-commit consumers already own lifecycle state for this run") + self._observer_run_phases[run_identity.token] = "opening" + if self._root_output_consumers: + if run_identity.token in self._root_output_lanes: + raise RuntimeError( + "the ROOT scientific-output MPI lane is already active for this run" + ) + if self._communicator is None: + raise RuntimeError( + "ROOT scientific output lost its authenticated execution communicator" + ) + lane_identity = "scientific-output/root/%s" % run_identity.token + self._root_output_lanes[run_identity.token] = None + lane_error = None + try: + lane = self._communicator.duplicate_observer_lane(lane_identity) + except BaseException as error: + lane_error = _exception_text(error) + else: + self._root_output_lanes[run_identity.token] = lane + if self._size > 1: + lane_rows = self._collective_close_rows( + "ROOT scientific-output lane construction", + { + "rank": self._rank, + "error": lane_error, + "present": self._root_output_lanes[run_identity.token] is not None, + }, + ) + malformed = any( + (row["error"] is not None and not isinstance(row["error"], str)) + or type(row["present"]) is not bool + or (row["error"] is None) is not row["present"] + for row in lane_rows + ) + if malformed: + raise _ObserverCollectiveRejected( + "ROOT scientific-output lane construction returned malformed evidence" + ) + failures = tuple( + "rank %d: %s" % (row["rank"], row["error"]) + for row in lane_rows + if row["error"] is not None + ) + if failures: + if not any(row["present"] is True for row in lane_rows): + self._root_output_lanes.pop(run_identity.token, None) + raise _ObserverCollectiveRejected( + "ROOT scientific-output lane construction failed: " + "; ".join(failures) + ) + elif lane_error is not None: + self._root_output_lanes.pop(run_identity.token, None) + raise RuntimeError( + "ROOT scientific-output lane construction failed: %s" % lane_error + ) if self._builtin_catalyst_consumers: if self._builtin_catalyst_run_started: raise RuntimeError( @@ -1796,34 +2227,26 @@ def begin_post_commit_consumers(self, run_identity: Identity) -> None: "a new process for another Catalyst simulation run" ) self._builtin_catalyst_run_started = True - manifests = tuple( - sorted( - ( - row - for row in self._owner._consumer_graph.nodes - if row.kind is ConsumerKind.MONITOR - ), - key=lambda value: value.qualified_id, - ) - ) + manifests = self._monitor_manifests() for manifest in manifests: local_error = None - session = None journal = None replay_records: tuple[Any, ...] = () replay_states: tuple[tuple[str, ...], ...] = ((),) worker_mpi = manifest.parallel_mode in (ParallelMode.PER_RANK, ParallelMode.COLLECTIVE) key = self._observer_key(manifest.qualified_id, run_identity) if worker_mpi: + self._observer_lanes[key] = None try: lane_identity = "post-commit/%s/%s" % ( manifest.identity.token, run_identity.token, ) - self._observer_lanes[key] = self._communicator.duplicate_observer_lane( - lane_identity - ) + lane = self._communicator.duplicate_observer_lane(lane_identity) + self._observer_lanes[key] = lane except BaseException as error: + if self._observer_lanes.get(key) is None: + self._observer_lanes.pop(key, None) local_error = _exception_text(error) active = self._rank == 0 or worker_mpi if active and local_error is None: @@ -1835,17 +2258,13 @@ def begin_post_commit_consumers(self, run_identity: Identity) -> None: # committed events. Otherwise a healthy rank could enter replay allgather while a # failing rank has already left the phase. if manifest.parallel_mode is not ParallelMode.SERIAL: - try: - _post_commit_root_consensus( - self._communicator, - rank=self._rank, - size=self._size, - error=local_error, - phase="journal/lane construction", - ) - except BaseException: - self._observer_lanes.pop(key, None) - raise + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="journal/lane construction", + ) elif local_error is not None: raise RuntimeError("post-commit journal construction failed: %s" % local_error) @@ -1855,53 +2274,67 @@ def begin_post_commit_consumers(self, run_identity: Identity) -> None: replay_records, replay_states = self._inspect_observer_journal( manifest, journal ) + except _ObserverCollectiveLost: + raise except BaseException as error: local_error = _exception_text(error) if manifest.parallel_mode is not ParallelMode.SERIAL: - try: - _post_commit_root_consensus( - self._communicator, - rank=self._rank, - size=self._size, - error=local_error, - phase="durable journal inspection", - ) - except BaseException: - self._observer_lanes.pop(key, None) - raise + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="durable journal inspection", + ) elif local_error is not None: raise RuntimeError("post-commit journal inspection failed: %s" % local_error) + if worker_mpi: + local_error = None + try: + self._observer_worker(run_identity) + except BaseException as error: + local_error = _exception_text(error) + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="post-commit worker construction", + ) + local_error = None if active: + self._observer_pending_sessions[key] = None try: session = self._open_observer_session( manifest, run_identity, self._observer_lanes.get(key) ) + provider_id = _observer_provider_id(manifest.operation_data) + pending_session = _PendingObserverSession( + run_identity, + manifest.qualified_id, + provider_id, + worker_mpi, + session, + ) + self._observer_pending_sessions[key] = pending_session + if not pending_session.authenticated: + local_error = pending_session.authentication_error except BaseException as error: + if self._observer_pending_sessions.get(key) is None: + self._observer_pending_sessions.pop(key, None) local_error = _exception_text(error) - # No worker is started until provider imports, pipeline authentication and replay - # inspection have succeeded everywhere. + # The run worker already exists on every MPI rank, so any retained session can later + # execute its fail-closed abort on the same owner thread. if manifest.parallel_mode is not ParallelMode.SERIAL: - try: - _post_commit_root_consensus( - self._communicator, - rank=self._rank, - size=self._size, - error=local_error, - phase="session construction", - ) - except BaseException: - if session is not None: - try: - session.abort() - except BaseException: - pass - # Do not attempt a collective free after a possibly asymmetric communicator - # construction failure. ObserverMpiLane deliberately leaks safely until MPI - # finalization in this exceptional path instead of risking a cleanup deadlock. - self._observer_lanes.pop(key, None) - raise + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="session construction/cleanup-authority registration", + ) elif local_error is not None: raise RuntimeError("post-commit session construction failed: %s" % local_error) @@ -1911,18 +2344,27 @@ def begin_post_commit_consumers(self, run_identity: Identity) -> None: _reserve_builtin_catalyst_process_lifecycle() except BaseException as error: local_error = _exception_text(error) - if manifest.parallel_mode is not ParallelMode.SERIAL: - _post_commit_root_consensus( - self._communicator, - rank=self._rank, - size=self._size, - error=local_error, - phase="Catalyst process lifecycle reservation", - ) - elif local_error is not None: - raise RuntimeError( - "Catalyst process lifecycle reservation failed: %s" % local_error - ) + reservation_error = None + try: + if manifest.parallel_mode is not ParallelMode.SERIAL: + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="Catalyst process lifecycle reservation", + ) + elif local_error is not None: + raise RuntimeError( + "Catalyst process lifecycle reservation failed: %s" % local_error + ) + except BaseException as error: + reservation_error = error + if reservation_error is not None: + # Failed-run close owns the one authenticated abort route. Aborting here would + # let successful ranks replay a non-idempotent collective when another rank fails + # and the retained pending session is retried later. + raise reservation_error recovery_run_identities = tuple( sorted( @@ -1937,20 +2379,23 @@ def begin_post_commit_consumers(self, run_identity: Identity) -> None: local_error = None if active: try: + pending_session = self._observer_pending_sessions.get(key) + if pending_session is None: + raise RuntimeError( + "post-commit queue construction lost its pending session authority" + ) + if not pending_session.authenticated: + raise RuntimeError( + "post-commit queue construction refused an unauthenticated session" + ) observer_queue = self._observer_queue( manifest, run_identity, - session=session, + session=pending_session, recovery_run_identities=recovery_run_identities, + defer_initialize=worker_mpi, ) - if journal is not None: - self._replay_observer_journal( - manifest, - observer_queue, - journal, - replay_records, - replay_states, - ) + self._observer_pending_sessions.pop(key, None) except BaseException as error: local_error = _exception_text(error) if manifest.parallel_mode is not ParallelMode.SERIAL: @@ -1959,22 +2404,91 @@ def begin_post_commit_consumers(self, run_identity: Identity) -> None: rank=self._rank, size=self._size, error=local_error, - phase="session initialization/replay", + phase="observer queue construction", ) elif local_error is not None: - raise RuntimeError( - "post-commit session initialization/replay failed: %s" % local_error + raise RuntimeError("post-commit session initialization failed: %s" % local_error) + + if worker_mpi: + observer_queue = self._observer_queues.get(key) + local_error = None + try: + if observer_queue is None: + raise RuntimeError("MPI observer initialization lost its constructed queue") + observer_queue.prepare_initialize() + except BaseException as error: + local_error = _exception_text(error) + admission_error = None + try: + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="observer initialization enqueue", + ) + except BaseException as error: + admission_error = error + if admission_error is not None: + if observer_queue is not None: + observer_queue.cancel_initialize(admission_error) + raise admission_error + if observer_queue is None: # pragma: no cover - enqueue consensus proved it + raise RuntimeError("MPI observer initialization lost its queue") + + local_error = None + try: + observer_queue.arm_initialize() + observer_queue.complete_initialize() + except BaseException as error: + local_error = _exception_text(error) + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="observer initialization completion", ) - def _submit_live_visualization( - self, - effect: AcceptedSideEffect, - frame: _DetachedObserverFrame | None, + local_error = None + if active and journal is not None: + try: + observer_queue = self._observer_queues.get(key) + if observer_queue is None: + raise RuntimeError("durable replay lost its initialized observer queue") + self._replay_observer_journal( + manifest, + observer_queue, + journal, + replay_records, + replay_states, + ) + except _ObserverCollectiveLost: + raise + except BaseException as error: + local_error = _exception_text(error) + if manifest.parallel_mode is not ParallelMode.SERIAL: + _post_commit_root_consensus( + self._communicator, + rank=self._rank, + size=self._size, + error=local_error, + phase="durable session replay", + ) + elif local_error is not None: + raise RuntimeError("post-commit durable replay failed: %s" % local_error) + self._observer_run_phases[run_identity.token] = "open" + + def _submit_live_visualization( + self, + effect: AcceptedSideEffect, + frame: _DetachedObserverFrame | None, journal: Any = None, journal_record: Any = None, preexisting_committed: bool = False, ) -> None: """Commit and arm one post-commit job only after rank-identical main-thread consensus.""" + self._refuse_lost_observer_world() manifest = self._manifest(effect) raw_frame = None if frame is not None: @@ -2038,6 +2552,9 @@ def _submit_live_visualization( if consensus_error is not None: if submission is not None: submission.cancel(consensus_error) + if isinstance(consensus_error, _ObserverCollectiveLost): + self.seal_observer_collective_loss(consensus_error) + raise consensus_error if active and type(run_identity) is Identity and run_identity.domain == "run": self._record_observer_failure(manifest.qualified_id, run_identity, consensus_error) return None @@ -2045,13 +2562,17 @@ def _submit_live_visualization( raise RuntimeError("post-commit consensus accepted no exact run identity") if submission is not None: submission.arm() - if manifest.parallel_mode is not ParallelMode.SERIAL: + if manifest.parallel_mode in ( + ParallelMode.PER_RANK, + ParallelMode.COLLECTIVE, + ): # A Catalyst implementation may enter MPI from its worker thread even when PoPS gives # it a duplicated communicator. Do not let the next AMR/native step concurrently # enter solver collectives on the main thread: MPICH and third-party VTK internals do # not guarantee progress for that cross-library ordering. Drain the accepted live # frame locally, then prove every rank has left the worker lane before any rank returns - # to the solver. Serial observers and asynchronous scientific writers remain async. + # to the solver. SERIAL and gathered ROOT workers never enter MPI, so they remain + # asynchronous with the next numerical step. delivery_error = None try: self._observer_queue(manifest, run_identity).flush() @@ -2066,9 +2587,425 @@ def _submit_live_visualization( phase="collective live delivery", ) except BaseException as error: + if isinstance(error, _ObserverCollectiveLost): + self.seal_observer_collective_loss(error) + raise self._record_observer_failure(manifest.qualified_id, run_identity, error) return None + def _monitor_manifests(self) -> tuple[Any, ...]: + return tuple( + sorted( + ( + row + for row in self._owner._consumer_graph.nodes + if row.kind is ConsumerKind.MONITOR + ), + key=lambda value: value.qualified_id, + ) + ) + + @staticmethod + def _observer_close_state(value: Any) -> dict[str, Any] | None: + if value is None: + return None + return { + "authority": getattr(value, "close_authority", None), + "close_requested": getattr(value, "close_requested", None), + "close_succeeded": getattr(value, "close_succeeded", None), + } + + @staticmethod + def _observer_lane_close_state(value: Any) -> dict[str, Any] | None: + if value is None: + return None + return { + "identity": getattr(value, "identity", None), + "active": getattr(value, "active", None), + "closed": getattr(value, "closed", None), + } + + @staticmethod + def _observer_pending_session_state(value: Any) -> dict[str, Any] | None: + if value is None: + return None + return { + "authority": getattr(value, "close_authority", None), + "abort_succeeded": getattr(value, "abort_succeeded", None), + "authenticated": getattr(value, "authenticated", None), + } + + def _qualified_observer_lane_identity(self, local_identity: str) -> str: + parent_identity = ( + None if self._communicator is None else getattr(self._communicator, "identity", None) + ) + if type(parent_identity) is not str or not parent_identity: + raise RuntimeError("observer lane lost its parent communicator identity") + if type(local_identity) is not str or not local_identity: + raise RuntimeError("observer lane requires a non-empty local identity") + return "%s/%s" % (parent_identity, local_identity) + + def _collective_close_rows( + self, + phase: str, + local: Mapping[str, Any], + ) -> tuple[Mapping[str, Any], ...]: + if self._size > 1 and self._communicator is None: + lost = _ObserverCollectiveLost("%s lost its authenticated MPI communicator" % phase) + self.seal_observer_collective_loss(lost) + raise lost + try: + rows = ( + allgather_value(self._communicator, dict(local)) + if self._size > 1 + else (dict(local),) + ) + except BaseException as error: + lost = _ObserverCollectiveLost( + "%s lost its MPI collective proof: %s" % (phase, _exception_text(error)) + ) + self.seal_observer_collective_loss(lost) + raise lost from error + keys = set(local) + if len(rows) != self._size or any( + not isinstance(row, Mapping) or set(row) != keys or row.get("rank") != owner + for owner, row in enumerate(rows) + ): + lost = _ObserverCollectiveLost("%s returned a malformed MPI envelope" % phase) + self.seal_observer_collective_loss(lost) + raise lost + return tuple(rows) + + def _seal_poisoned_observer_worker( + self, + run_identity: Identity, + message: str, + ) -> str: + """Stop one poisoned run worker locally, then prove that stop on WORLD.""" + + local_error = _ObserverWorkerLaneLost(message) + failures: list[str] = [] + for key in sorted(getattr(self, "_observer_queues", {})): + if len(key) != 2 or key[1] != run_identity.token: + continue + observer_queue = self._observer_queues[key] + if observer_queue is None: + continue + seal_local = getattr(observer_queue, "seal_local", None) + if not callable(seal_local): + failures.append("observer queue %s has no local seal route" % (key[0],)) + continue + try: + seal_local(local_error) + except BaseException as error: + failures.append( + "observer queue %s local seal failed: %s" % (key[0], _exception_text(error)) + ) + worker = getattr(self, "_observer_workers", {}).get(run_identity.token) + if worker is not None: + seal_local = getattr(worker, "seal_local", None) + if not callable(seal_local): + failures.append("post-commit worker has no local seal route") + else: + try: + seal_local(local_error) + except BaseException as error: + failures.append( + "post-commit worker local seal failed: %s" % _exception_text(error) + ) + rendered_error = "; ".join(failures) if failures else None + worker_rows = self._collective_close_rows( + "MPI observer poisoned worker local seal", + { + "rank": self._rank, + "error": rendered_error, + "closed": worker is None or worker.close_succeeded is True, + }, + ) + worker_failures = tuple( + "rank %d: %s" + % ( + row["rank"], + row["error"] or "local worker did not authenticate closure", + ) + for row in worker_rows + if row["error"] is not None or row["closed"] is not True + ) + if worker_failures: + message += "; local worker seal failed: " + "; ".join(worker_failures) + return message + + def _preflight_observer_close(self, run_identity: Identity) -> bool: + """Authenticate every retained close handle before entering its MPI lifecycle.""" + + run_key = run_identity.token + manifests = self._monitor_manifests() + phase = getattr(self, "_observer_run_phases", {}).get(run_key) + if phase not in {"opening", "open", "closing_opening", "closing_open", "closed"}: + raise RuntimeError("post-commit close has no authenticated run lifecycle phase") + local_error = None + root_lane: dict[str, Any] | None = None + worker: dict[str, bool] | None = None + monitors: list[dict[str, Any]] = [] + try: + root_lane = self._observer_lane_close_state(self._root_output_lanes.get(run_key)) + worker = self._observer_close_state(self._observer_workers.get(run_key)) + for manifest in manifests: + key = self._observer_key(manifest.qualified_id, run_identity) + monitors.append( + { + "consumer_id": manifest.qualified_id, + "mode": manifest.parallel_mode.value, + "session": self._observer_pending_session_state( + getattr(self, "_observer_pending_sessions", {}).get(key) + ), + "queue": self._observer_close_state(self._observer_queues.get(key)), + "lane": self._observer_lane_close_state(self._observer_lanes.get(key)), + } + ) + except BaseException as error: + local_error = _exception_text(error) + root_lane = None + worker = None + monitors = [] + rows = self._collective_close_rows( + "post-commit close preflight", + { + "rank": self._rank, + "phase": phase, + "error": local_error, + "root_lane": root_lane, + "worker": worker, + "monitors": monitors, + }, + ) + failures = tuple( + "rank %d: %s" % (row["rank"], row["error"]) for row in rows if row["error"] is not None + ) + if failures: + raise RuntimeError( + "post-commit close inventory failed collectively: %s" % "; ".join(failures) + ) + if any(row["phase"] != phase for row in rows): + raise RuntimeError("post-commit close refused divergent run lifecycle phases") + + def valid_close_state(value: Any) -> bool: + return value is None or ( + isinstance(value, Mapping) + and set(value) == {"authority", "close_requested", "close_succeeded"} + and type(value["close_requested"]) is bool + and type(value["close_succeeded"]) is bool + and (not value["close_succeeded"] or value["close_requested"]) + ) + + def valid_lane_state(value: Any) -> bool: + return value is None or ( + isinstance(value, Mapping) + and set(value) == {"identity", "active", "closed"} + and isinstance(value["identity"], str) + and bool(value["identity"]) + and type(value["active"]) is bool + and type(value["closed"]) is bool + and (value["active"], value["closed"]) in {(True, False), (False, True)} + ) + + def valid_session_state(value: Any) -> bool: + return value is None or ( + isinstance(value, Mapping) + and set(value) == {"authority", "abort_succeeded", "authenticated"} + and isinstance(value["authority"], Mapping) + and type(value["abort_succeeded"]) is bool + and type(value["authenticated"]) is bool + ) + + if any( + row["error"] is not None + or not valid_lane_state(row["root_lane"]) + or not valid_close_state(row["worker"]) + or not isinstance(row["monitors"], (tuple, list)) + or len(row["monitors"]) != len(manifests) + for row in rows + ): + raise RuntimeError("post-commit close preflight contains malformed handle evidence") + worker_states = tuple(row["worker"] for row in rows) + worker_ranks = tuple(rank for rank, state in enumerate(worker_states) if state is not None) + if any(state is not None and state["authority"] != run_key for state in worker_states): + raise RuntimeError("post-commit close found a worker owned by another run") + if phase == "open" and any( + state is not None + and (state["close_requested"] is True or state["close_succeeded"] is True) + for state in worker_states + ): + raise RuntimeError("post-commit close found a prematurely closed run worker") + + root_states = tuple(row["root_lane"] for row in rows) + root_present = tuple(state is not None for state in root_states) + if any(root_present) and not all(root_present): + raise RuntimeError("post-commit close refused divergent ROOT output lane inventory") + if self._root_output_consumers and phase == "open" and not any(root_present): + raise RuntimeError("post-commit close lost its opened ROOT output lane") + if all(root_present): + expected = self._qualified_observer_lane_identity("scientific-output/root/%s" % run_key) + root_signatures = tuple( + (state["identity"], state["active"], state["closed"]) for state in root_states + ) + if ( + any(signature != root_signatures[0] for signature in root_signatures[1:]) + or root_signatures[0][0] != expected + ): + raise RuntimeError( + "post-commit close refused unauthenticated ROOT output lane inventory" + ) + if phase == "open" and root_signatures[0][1:] != (True, False): + raise RuntimeError("post-commit close found a prematurely closed ROOT output lane") + + for index, manifest in enumerate(manifests): + entries = tuple(row["monitors"][index] for row in rows) + if any( + not isinstance(entry, Mapping) + or set(entry) != {"consumer_id", "mode", "session", "queue", "lane"} + or entry["consumer_id"] != manifest.qualified_id + or entry["mode"] != manifest.parallel_mode.value + or not valid_session_state(entry["session"]) + or not valid_close_state(entry["queue"]) + or not valid_lane_state(entry["lane"]) + for entry in entries + ): + raise RuntimeError( + "post-commit close preflight contains malformed monitor evidence" + ) + sessions = tuple(entry["session"] for entry in entries) + queues = tuple(entry["queue"] for entry in entries) + lanes = tuple(entry["lane"] for entry in entries) + expected_queue_authority = { + "run_identity": run_key, + "consumer_id": manifest.qualified_id, + "provider_id": _observer_provider_id(manifest.operation_data), + } + if any( + state is not None and state["authority"] != expected_queue_authority + for state in sessions + ): + raise RuntimeError( + "post-commit close found a pending session owned by another run or consumer" + ) + if any( + state is not None and state["authority"] != expected_queue_authority + for state in queues + ): + raise RuntimeError( + "post-commit close found a queue owned by another run or consumer" + ) + session_ranks = tuple(rank for rank, state in enumerate(sessions) if state is not None) + queue_ranks = tuple(rank for rank, state in enumerate(queues) if state is not None) + lane_ranks = tuple(rank for rank, state in enumerate(lanes) if state is not None) + if any( + session is not None and queue is not None + for session, queue in zip(sessions, queues, strict=True) + ): + raise RuntimeError("post-commit close found duplicate session ownership") + if phase in {"open", "closing_open"} and any( + state is not None and state["authenticated"] is not True for state in sessions + ): + raise RuntimeError("post-commit close found an unauthenticated opened session") + if phase == "open" and any( + state is not None + and (state["close_requested"] is True or state["close_succeeded"] is True) + for state in queues + ): + raise RuntimeError("post-commit close found a prematurely closed observer queue") + if manifest.parallel_mode is ParallelMode.SERIAL: + if self._size != 1 or self._communicator is not None or lane_ranks: + raise RuntimeError("SERIAL post-commit close lost its serial topology") + if phase == "open" and (session_ranks or queue_ranks != (0,)): + raise RuntimeError("post-commit close lost its opened SERIAL monitor queue") + if phase == "closing_open" and (session_ranks or queue_ranks not in {(), (0,)}): + raise RuntimeError("post-commit close found a partial SERIAL monitor queue") + elif manifest.parallel_mode is ParallelMode.ROOT: + if session_ranks not in {(), (0,)} or queue_ranks not in {(), (0,)} or lane_ranks: + raise RuntimeError("post-commit close refused divergent ROOT monitor inventory") + if phase == "open" and (session_ranks or queue_ranks != (0,)): + raise RuntimeError("post-commit close lost its opened ROOT monitor queue") + if phase == "closing_open" and (session_ranks or queue_ranks not in {(), (0,)}): + raise RuntimeError("post-commit close found a partial ROOT monitor queue") + else: + all_ranks = tuple(range(self._size)) + owner_ranks = tuple(sorted((*session_ranks, *queue_ranks))) + if lane_ranks not in {(), all_ranks}: + raise RuntimeError("post-commit close refused divergent MPI monitor inventory") + if phase == "open" and (session_ranks or queue_ranks != all_ranks): + raise RuntimeError("post-commit close lost an opened MPI monitor queue") + if phase == "closing_open" and ( + session_ranks or queue_ranks not in {(), all_ranks} + ): + raise RuntimeError("post-commit close found a partial opened MPI queue") + if phase in {"opening", "closing_opening"} and owner_ranks not in { + (), + all_ranks, + }: + raise RuntimeError( + "post-commit close found a gap in partial MPI session ownership" + ) + if owner_ranks and not lane_ranks: + raise RuntimeError( + "post-commit close refused an MPI queue without its worker lane" + ) + if owner_ranks and worker_ranks != all_ranks: + raise RuntimeError( + "post-commit close refused MPI session ownership without every run worker" + ) + if lane_ranks: + expected = self._qualified_observer_lane_identity( + "post-commit/%s/%s" % (manifest.identity.token, run_key) + ) + lane_signatures = tuple( + (state["identity"], state["active"], state["closed"]) for state in lanes + ) + if ( + any(signature != lane_signatures[0] for signature in lane_signatures[1:]) + or lane_signatures[0][0] != expected + ): + raise RuntimeError( + "post-commit close refused unauthenticated MPI worker lanes" + ) + if phase == "open" and lane_signatures[0][1:] != (True, False): + raise RuntimeError( + "post-commit close found a prematurely closed MPI worker lane" + ) + if lane_signatures[0][2] and any( + state is not None and not state["close_succeeded"] for state in queues + ): + raise RuntimeError( + "post-commit close found an open queue on an already closed MPI lane" + ) + + mpi_monitors = any( + manifest.parallel_mode in (ParallelMode.PER_RANK, ParallelMode.COLLECTIVE) + for manifest in manifests + ) + if mpi_monitors: + required_worker_ranks = tuple(range(self._size)) + elif manifests: + required_worker_ranks = (0,) + else: + required_worker_ranks = () + if phase == "open" and worker_ranks != required_worker_ranks: + raise RuntimeError("post-commit close lost an opened run worker") + if phase == "opening" and any(rank not in required_worker_ranks for rank in worker_ranks): + raise RuntimeError("post-commit close refused an invalid partial worker inventory") + + return any( + row["root_lane"] is not None + or row["worker"] is not None + or any( + entry["session"] is not None + or entry["queue"] is not None + or entry["lane"] is not None + for entry in row["monitors"] + ) + for row in rows + ) + def _drain_observer_manifest( self, manifest: Any, @@ -2076,32 +3013,408 @@ def _drain_observer_manifest( *, close: bool, ) -> tuple[str, ...]: + self._refuse_lost_observer_world() key = self._observer_key(manifest.qualified_id, run_identity) - local_reports: tuple[ObserverDeliveryReport, ...] = () - local_diagnostics = list(self._observer_pending_failures.pop(key, ())) + report_run_authorities = getattr(self, "_observer_report_run_authorities", None) + if report_run_authorities is None: + report_run_authorities = {} + self._observer_report_run_authorities = report_run_authorities + accepted_report_runs = report_run_authorities.get(key, frozenset((run_identity,))) + if run_identity not in accepted_report_runs: + raise RuntimeError("observer report authority excludes the active run") + pending_reports = getattr(self, "_observer_pending_reports", None) + if pending_reports is None: + pending_reports = {} + self._observer_pending_reports = pending_reports + local_reports = pending_reports.get(key, ()) + + def retain_pending_reports(values: tuple[ObserverDeliveryReport, ...]) -> None: + retained = tuple(values) + if any(type(report) is not ObserverDeliveryReport for report in retained): + raise TypeError( + "pending observer reports require exact ObserverDeliveryReport values" + ) + if key in pending_reports and pending_reports[key] != retained: + raise RuntimeError( + "pending observer report authority differs from the closed queue reports" + ) + pending_reports[key] = retained + + def world_lost(message: str) -> _ObserverCollectiveLost: + lost = _ObserverCollectiveLost(message) + self.seal_observer_collective_loss(lost) + return lost + + local_diagnostics = list(self._observer_pending_failures.get(key, ())) worker_mpi = manifest.parallel_mode in (ParallelMode.PER_RANK, ParallelMode.COLLECTIVE) active = self._rank == 0 or worker_mpi + phase = getattr(self, "_observer_run_phases", {}).get(run_identity.token) + failed_open = close and phase == "closing_opening" + pending_session = ( + getattr(self, "_observer_pending_sessions", {}).get(key) if active else None + ) observer_queue = self._observer_queues.get(key) if active else None - if observer_queue is not None: + release_lane = False + cleanup_ready = True + abort_retry_blocked = getattr(self, "_observer_abort_retry_blocked", None) + if abort_retry_blocked is None: + abort_retry_blocked = set() + self._observer_abort_retry_blocked = abort_retry_blocked + finalize_retry_blocked = getattr(self, "_observer_finalize_retry_blocked", None) + if finalize_retry_blocked is None: + finalize_retry_blocked = set() + self._observer_finalize_retry_blocked = finalize_retry_blocked + if close and worker_mpi: + local_lane_lost = bool( + ( + observer_queue is not None + and getattr(observer_queue, "worker_collective_lost", False) + ) + or ( + pending_session is not None + and getattr(pending_session, "worker_collective_lost", False) + ) + ) + lane_health_rows = self._collective_close_rows( + "MPI observer worker lane health", + {"rank": self._rank, "lost": local_lane_lost}, + ) + malformed_lane_health = any(type(row["lost"]) is not bool for row in lane_health_rows) + if malformed_lane_health or any(row["lost"] is True for row in lane_health_rows): + message = ( + "MPI observer worker lane lost collective proof; provider cleanup and lane " + "reuse are sealed until process finalization" + ) + if malformed_lane_health: + message += " (health evidence was malformed)" + message = self._seal_poisoned_observer_worker(run_identity, message) + if message not in local_diagnostics: + local_diagnostics.append(message) + self._observer_pending_failures[key] = local_diagnostics + raise _ObserverWorkerLaneLost(message) + local_owner = pending_session is not None or observer_queue is not None + queues_ready = True + abort_close = failed_open + if close and not failed_open and observer_queue is not None: + local_abort_required = bool(getattr(observer_queue, "abort_required", False)) + if worker_mpi: + abort_rows = self._collective_close_rows( + "MPI observer close route", + {"rank": self._rank, "abort_required": local_abort_required}, + ) + abort_close = any(row["abort_required"] is True for row in abort_rows) + else: + abort_close = local_abort_required + if abort_close: + pending_abort_attempt = None + if key in abort_retry_blocked: + cleanup_ready = False + local_diagnostics.append( + "collective observer abort retry refused after rank-divergent completion" + ) + elif observer_queue is not None: + try: + local_reports = observer_queue.prepare_abort_close() + except BaseException as error: + cleanup_ready = False + local_diagnostics.append( + "observer failed-open abort preparation failed: %s" % _exception_text(error) + ) + if worker_mpi: + preparation_rows = self._collective_close_rows( + "MPI failed-open observer abort preparation", + {"rank": self._rank, "owned": local_owner, "ready": cleanup_ready}, + ) + cleanup_ready = all(row["ready"] is True for row in preparation_rows) + abort_admission_error = None + if cleanup_ready: + try: + if pending_session is not None: + worker = self._observer_workers.get(run_identity.token) + if worker is None: + if worker_mpi: + raise RuntimeError("MPI pending observer abort lost its run worker") + worker = self._observer_worker(run_identity) + pending_abort_attempt = worker.prepare_call(pending_session.abort) + elif observer_queue is not None: + observer_queue.prepare_complete_abort_close() + except BaseException as error: + abort_admission_error = _exception_text(error) + if worker_mpi: + admission_failure = None + admission_collective_error = None + try: + admission_rows = self._collective_close_rows( + "MPI failed-open observer abort enqueue", + { + "rank": self._rank, + "owned": local_owner, + "error": abort_admission_error, + }, + ) + except BaseException as error: + admission_collective_error = error + admission_failure = RuntimeError( + "MPI observer abort enqueue consensus failed: %s" % _exception_text(error) + ) + else: + admission_failures = tuple( + "rank %d: %s" % (row["rank"], row["error"]) + for row in admission_rows + if row["error"] is not None + ) + if admission_failures: + admission_failure = RuntimeError( + "MPI observer abort enqueue failed collectively: %s" + % "; ".join(admission_failures) + ) + if admission_failure is not None: + if pending_abort_attempt is not None: + pending_abort_attempt.cancel(admission_failure) + try: + pending_abort_attempt.result() + except BaseException: + pass + elif observer_queue is not None: + observer_queue.cancel_complete_abort_close(admission_failure) + cleanup_ready = False + local_diagnostics.append(str(admission_failure)) + if admission_collective_error is not None: + raise _ObserverCollectiveLost(str(admission_failure)) from ( + admission_collective_error + ) + elif abort_admission_error is not None: + cleanup_ready = False + local_diagnostics.append( + "observer failed-open abort enqueue failed: %s" % abort_admission_error + ) + completion_ready = cleanup_ready + abort_armed = cleanup_ready and local_owner + if cleanup_ready: + try: + if pending_session is not None: + if pending_abort_attempt is None: + pending_session.abort() + else: + pending_abort_attempt.arm() + pending_abort_attempt.result() + elif observer_queue is not None: + observer_queue.arm_complete_abort_close() + local_reports = observer_queue.complete_abort_close() + except BaseException as error: + completion_ready = False + local_diagnostics.append( + "observer failed-open abort failed: %s" % _exception_text(error) + ) + if worker_mpi: + local_abort_lane_lost = bool( + ( + observer_queue is not None + and getattr(observer_queue, "worker_collective_lost", False) + ) + or ( + pending_session is not None + and getattr(pending_session, "worker_collective_lost", False) + ) + ) + try: + completion_rows = self._collective_close_rows( + "MPI failed-open observer abort completion", + { + "rank": self._rank, + "owned": local_owner, + "ready": completion_ready, + "worker_lane_lost": local_abort_lane_lost, + }, + ) + except BaseException as error: + completion_ready = False + if abort_armed: + abort_retry_blocked.add(key) + local_diagnostics.append( + "MPI observer abort completion consensus failed after provider entry; " + "retry is unsafe: %s" % _exception_text(error) + ) + raise _ObserverCollectiveLost( + "MPI observer abort completion lost its collective proof" + ) from error + else: + owner_success = any( + row["owned"] is True and row["ready"] is True for row in completion_rows + ) + owner_failure = any( + row["owned"] is True and row["ready"] is not True for row in completion_rows + ) + if abort_armed and owner_failure: + abort_retry_blocked.add(key) + local_diagnostics.append( + "collective observer abort failed after provider entry" + + (" on only a subset of MPI ranks" if owner_success else "") + + "; retry is unsafe" + ) + malformed_abort_health = any( + type(row["worker_lane_lost"]) is not bool for row in completion_rows + ) + if malformed_abort_health or any( + row["worker_lane_lost"] is True for row in completion_rows + ): + message = ( + "MPI observer abort lost worker-lane collective proof; provider " + "cleanup and lane reuse are sealed until process finalization" + ) + if malformed_abort_health: + message += " (abort health evidence was malformed)" + message = self._seal_poisoned_observer_worker(run_identity, message) + if message not in local_diagnostics: + local_diagnostics.append(message) + self._observer_pending_failures[key] = local_diagnostics + raise _ObserverWorkerLaneLost(message) + completion_ready = all(row["ready"] is True for row in completion_rows) + cleanup_ready = completion_ready + queues_ready = completion_ready + if cleanup_ready: + self._observer_pending_sessions.pop(key, None) + if observer_queue is not None: + retain_pending_reports(local_reports) + self._observer_queues.pop(key, None) + elif close and worker_mpi and key in finalize_retry_blocked: + cleanup_ready = False + local_diagnostics.append( + "collective observer finalize retry refused after rank-divergent completion" + ) + elif observer_queue is not None: try: - local_reports = observer_queue.close() if close else observer_queue.flush() + if close and worker_mpi: + local_reports = observer_queue.prepare_close() + else: + local_reports = observer_queue.close() if close else observer_queue.flush() except BaseException as error: + cleanup_ready = False local_reports = observer_queue.reports local_diagnostics.append(_exception_text(error)) - finally: - if close: - self._observer_queues.pop(key, None) - if close and worker_mpi: - lane = self._observer_lanes.pop(key, None) - if lane is None: - local_diagnostics.append("worker MPI lane disappeared before collective close") + if close and worker_mpi and not abort_close: + preparation_rows = self._collective_close_rows( + "MPI observer queue close preparation", + {"rank": self._rank, "ready": cleanup_ready}, + ) + cleanup_ready = all(row["ready"] is True for row in preparation_rows) + finalize_admission_error = None + if cleanup_ready and observer_queue is not None: + try: + observer_queue.prepare_complete_close() + except BaseException as error: + finalize_admission_error = _exception_text(error) + admission_failure = None + admission_collective_error = None + try: + admission_rows = self._collective_close_rows( + "MPI observer queue finalization enqueue", + { + "rank": self._rank, + "owned": observer_queue is not None, + "error": finalize_admission_error, + }, + ) + except BaseException as error: + admission_collective_error = error + admission_failure = RuntimeError( + "MPI observer finalization enqueue consensus failed: %s" + % _exception_text(error) + ) else: + admission_failures = tuple( + "rank %d: %s" % (row["rank"], row["error"]) + for row in admission_rows + if row["error"] is not None + ) + if admission_failures: + admission_failure = RuntimeError( + "MPI observer finalization enqueue failed collectively: %s" + % "; ".join(admission_failures) + ) + if admission_failure is not None: + if observer_queue is not None: + observer_queue.cancel_complete_close(admission_failure) + cleanup_ready = False + local_diagnostics.append(str(admission_failure)) + if admission_collective_error is not None: + raise _ObserverCollectiveLost(str(admission_failure)) from ( + admission_collective_error + ) + completion_ready = cleanup_ready + finalize_armed = cleanup_ready and observer_queue is not None + if cleanup_ready and observer_queue is not None: try: - lane.close_collectively() + observer_queue.arm_complete_close() + local_reports = observer_queue.complete_close() except BaseException as error: + completion_ready = False + local_reports = observer_queue.reports + local_diagnostics.append(_exception_text(error)) + try: + completion_rows = self._collective_close_rows( + "MPI observer queue close completion", + { + "rank": self._rank, + "owned": observer_queue is not None, + "ready": completion_ready, + }, + ) + except BaseException as error: + queues_ready = False + if finalize_armed: + finalize_retry_blocked.add(key) + local_diagnostics.append( + "MPI observer finalization completion consensus failed after provider entry; " + "retry is unsafe: %s" % _exception_text(error) + ) + raise _ObserverCollectiveLost( + "MPI observer finalization completion lost its collective proof" + ) from error + else: + owner_success = any( + row["owned"] is True and row["ready"] is True for row in completion_rows + ) + owner_failure = any( + row["owned"] is True and row["ready"] is not True for row in completion_rows + ) + if finalize_armed and owner_failure: + finalize_retry_blocked.add(key) local_diagnostics.append( - "worker MPI lane close failed: %s" % _exception_text(error) + "collective observer finalize failed after provider entry" + + (" on only a subset of MPI ranks" if owner_success else "") + + "; retry is unsafe" ) + queues_ready = all(row["ready"] is True for row in completion_rows) + if queues_ready and observer_queue is not None: + retain_pending_reports(local_reports) + self._observer_queues.pop(key, None) + if close and observer_queue is not None and not worker_mpi and not abort_close: + if observer_queue.close_succeeded is not True and not local_diagnostics: + local_diagnostics.append( + "observer queue close returned without authenticated completion" + ) + if close and worker_mpi: + lane = self._observer_lanes.get(key) + lane_error = None + if queues_ready and lane is not None and lane.closed is not True: + try: + lane.close_collectively() + except BaseException as error: + lane_error = _exception_text(error) + local_diagnostics.append("worker MPI lane close failed: %s" % lane_error) + if queues_ready: + lane_rows = self._collective_close_rows( + "MPI observer lane close", + { + "rank": self._rank, + "error": lane_error, + "closed": lane is None or lane.closed is True, + }, + ) + release_lane = lane is not None and all( + row["error"] is None and row["closed"] is True for row in lane_rows + ) envelope = { "rank": self._rank, "reports": [report.to_collective_data() for report in local_reports], @@ -2109,8 +3422,13 @@ def _drain_observer_manifest( } if manifest.parallel_mode is ParallelMode.ROOT: if self._communicator is None: - raise RuntimeError("ROOT post-commit consumer lost its native communicator") - rows = allgather_value(self._communicator, envelope) + raise world_lost("ROOT post-commit consumer lost its native communicator") + try: + rows = allgather_value(self._communicator, envelope) + except BaseException as error: + raise world_lost( + "ROOT post-commit flush lost its collective proof: %s" % _exception_text(error) + ) from error if len(rows) != self._size or any( not isinstance(row, Mapping) or set(row) != {"rank", "reports", "diagnostics"} @@ -2119,14 +3437,19 @@ def _drain_observer_manifest( or not isinstance(row["diagnostics"], (tuple, list)) for rank, row in enumerate(rows) ): - raise RuntimeError("ROOT post-commit flush returned a malformed envelope") + raise world_lost("ROOT post-commit flush returned a malformed envelope") if any(row["reports"] or row["diagnostics"] for row in rows[1:]): raise RuntimeError("ROOT post-commit delivery occurred outside rank zero") authoritative = rows[0] elif worker_mpi: if self._communicator is None: - raise RuntimeError("MPI post-commit flush lost its world communicator") - rows = allgather_value(self._communicator, envelope) + raise world_lost("MPI post-commit flush lost its world communicator") + try: + rows = allgather_value(self._communicator, envelope) + except BaseException as error: + raise world_lost( + "MPI post-commit flush lost its collective proof: %s" % _exception_text(error) + ) from error if len(rows) != self._size or any( not isinstance(row, Mapping) or set(row) != {"rank", "reports", "diagnostics"} @@ -2135,7 +3458,7 @@ def _drain_observer_manifest( or not isinstance(row["diagnostics"], (tuple, list)) for owner, row in enumerate(rows) ): - raise RuntimeError("MPI post-commit flush returned a malformed envelope") + raise world_lost("MPI post-commit flush returned a malformed envelope") authoritative = { "rank": 0, "reports": [report for row in rows for report in row["reports"]], @@ -2152,9 +3475,22 @@ def _drain_observer_manifest( for row in authoritative["reports"] ) for report in reports: - if report.consumer_id != manifest.qualified_id: - raise RuntimeError("post-commit report authenticates another session") + if ( + report.consumer_id != manifest.qualified_id + or report.run_identity not in accepted_report_runs + ): + raise RuntimeError("post-commit report authenticates another run or session") + for report in reports: self._observer_reports[report.identity.token] = report + if close: + pending_reports.pop(key, None) + report_run_authorities.pop(key, None) + if close: + if worker_mpi: + if release_lane: + self._observer_lanes.pop(key, None) + elif observer_queue is not None and observer_queue.close_succeeded is True: + self._observer_queues.pop(key, None) diagnostics = tuple(str(value) for value in authoritative["diagnostics"]) release_diagnostics = tuple( "frame %s writer finalization: %s" @@ -2178,6 +3514,7 @@ def _drain_observer_manifest( for report in reports if report.status == "skipped" ) + self._observer_pending_failures.pop(key, None) if manifest.operation_data["on_failure"]["action"] == "report_only": return () return tuple("%s: %s" % (manifest.qualified_id, message) for message in failures) @@ -2190,41 +3527,114 @@ def flush_live_visualizations( raise_on_failure: bool = True, ) -> tuple[ObserverDeliveryReport, ...]: """Drain every live consumer for one run, with ROOT consensus on the main thread.""" + self._refuse_lost_observer_world() self._observer_key("run-flush", run_identity) - if close and run_identity.token in self._closed_observer_runs: - return tuple( - report for report in self.post_commit_reports if report.run_identity == run_identity - ) + if close: + self._closed_observer_runs.add(run_identity.token) + if not self._preflight_observer_close(run_identity): + self._observer_run_phases[run_identity.token] = "closed" + return tuple( + report + for report in self.post_commit_reports + if report.run_identity == run_identity + ) + current_phase = self._observer_run_phases[run_identity.token] + if current_phase == "opening": + self._observer_run_phases[run_identity.token] = "closing_opening" + elif current_phase == "open": + self._observer_run_phases[run_identity.token] = "closing_open" + elif current_phase not in {"closing_opening", "closing_open"}: + raise RuntimeError("post-commit close lost its lifecycle origin") failures = [] - manifests = tuple( - sorted( - ( - row - for row in self._owner._consumer_graph.nodes - if row.kind is ConsumerKind.MONITOR - ), - key=lambda value: value.qualified_id, - ) - ) + manifests = self._monitor_manifests() for manifest in manifests: try: failures.extend(self._drain_observer_manifest(manifest, run_identity, close=close)) + except _ObserverCollectiveLost as error: + self.seal_observer_collective_loss(error) + raise except BaseException as error: rendered = "%s: %s" % (manifest.qualified_id, _exception_text(error)) if rendered not in self._observer_diagnostics: self._observer_diagnostics.append(rendered) failures.append(rendered) if close: - worker = self._observer_workers.pop(run_identity.token, None) - if worker is not None: + root_lane = self._root_output_lanes.get(run_identity.token) + root_error = None + if root_lane is not None and root_lane.closed is not True: + try: + root_lane.close_collectively() + except BaseException as error: + root_error = _exception_text(error) + root_rows = self._collective_close_rows( + "ROOT scientific-output lane close", + { + "rank": self._rank, + "error": root_error, + "closed": root_lane is None or root_lane.closed is True, + }, + ) + root_failures = tuple( + "ROOT scientific-output MPI lane close failed on rank %d: %s" + % (row["rank"], row["error"]) + for row in root_rows + if row["error"] is not None + ) + failures.extend(root_failures) + if root_lane is not None and all( + row["error"] is None and row["closed"] is True for row in root_rows + ): + self._root_output_lanes.pop(run_identity.token, None) + + local_queues_remaining = ( + any(len(key) == 2 and key[1] == run_identity.token for key in self._observer_queues) + or any( + len(key) == 2 and key[1] == run_identity.token + for key in getattr(self, "_observer_pending_sessions", {}) + ) + or any( + len(key) == 2 and key[1] == run_identity.token + for key in getattr(self, "_observer_pending_reports", {}) + ) + ) + queue_rows = self._collective_close_rows( + "post-commit worker close readiness", + {"rank": self._rank, "queues_remaining": local_queues_remaining}, + ) + worker = self._observer_workers.get(run_identity.token) + worker_error = None + if worker is not None and not any( + row["queues_remaining"] is True for row in queue_rows + ): try: worker.close() except BaseException as error: - rendered = "post-commit worker: %s" % _exception_text(error) - if rendered not in self._observer_diagnostics: - self._observer_diagnostics.append(rendered) - failures.append(rendered) - self._closed_observer_runs.add(run_identity.token) + worker_error = _exception_text(error) + worker_rows = self._collective_close_rows( + "post-commit worker close", + { + "rank": self._rank, + "error": worker_error, + "closed": worker is None or worker.close_succeeded is True, + }, + ) + worker_failures = tuple( + "post-commit worker close failed on rank %d: %s" % (row["rank"], row["error"]) + for row in worker_rows + if row["error"] is not None + ) + failures.extend(worker_failures) + if worker is not None and all( + row["error"] is None and row["closed"] is True for row in worker_rows + ): + self._observer_workers.pop(run_identity.token, None) + if self._preflight_observer_close(run_identity): + failures.append("run-scoped post-commit cleanup authority remains retained") + else: + self._observer_run_phases[run_identity.token] = "closed" + for rendered in (*root_failures, *worker_failures): + if rendered not in self._observer_diagnostics: + self._observer_diagnostics.append(rendered) if failures and raise_on_failure: raise RuntimeError( "post-commit consumer delivery failed at %s: %s" @@ -2255,6 +3665,77 @@ def close_live_visualizations( run_identity, close=True, raise_on_failure=raise_on_failure ) + def close_failed_run_consumers( + self, + run_identity: Identity, + *, + release_identity: bool, + entry_effect_fence: str | None = None, + ) -> tuple[ObserverDeliveryReport, ...]: + """Close a zero-progress failed run and retain its identity unless reuse is trivial. + + ``RunManifest`` identities intentionally describe execution semantics rather than an + invocation nonce. A run that fails before its first accepted step therefore receives the + same identity when the caller fixes the external fault and retries from the restored entry + boundary. Reuse is deliberately limited to a single-rank RuntimeInstance with an empty + ConsumerGraph and an unchanged publisher fence. A size-one MPI world has no cross-rank + observer lifecycle, so it is equivalent to the serial route here. Multi-rank MPI, output + and observer lifecycles stay sealed because opening or closing their external resources is + already observable. + """ + + if type(release_identity) is not bool: + raise TypeError("failed-run identity release decision must be an exact bool") + run_key = run_identity.token + already_closed = run_key in self._closed_observer_runs + reports = self.flush_live_visualizations( + run_identity, + close=True, + raise_on_failure=True, + ) + graph = getattr(getattr(self, "_owner", None), "_consumer_graph", None) + nodes = tuple(getattr(graph, "nodes", ())) + current_effect_fence = None + if ( + self._size == 1 + and not nodes + and not self._root_output_consumers + and not self._builtin_catalyst_consumers + ): + try: + current_effect_fence = self.failed_run_effect_fence() + except BaseException: + current_effect_fence = None + reusable = bool( + self._size == 1 + and release_identity + and entry_effect_fence is not None + and current_effect_fence == entry_effect_fence + and not already_closed + and not reports + and not nodes + and not self._root_output_consumers + and not self._builtin_catalyst_consumers + ) + if reusable: + self._closed_observer_runs.discard(run_key) + self._observer_run_phases.pop(run_key, None) + return reports + + def _root_output_communicator(self) -> Any: + """Return the one active duplicated lane used by native ROOT snapshot gathers.""" + + if not self._root_output_consumers: + raise RuntimeError("the ConsumerGraph declares no ROOT snapshot consumer") + if len(self._root_output_lanes) != 1: + raise RuntimeError( + "ROOT scientific output requires exactly one active run-scoped MPI lane" + ) + lane = next(iter(self._root_output_lanes.values())) + if lane.active is not True or lane.closed is not False: + raise RuntimeError("ROOT scientific-output MPI lane is not active") + return lane + def diagnostic_restart_state(self) -> dict[str, Any]: """Return the complete last-accepted typed diagnostic registry.""" baselines = dict(self._baselines) @@ -2411,6 +3892,65 @@ def _validate_diagnostic_providers(self) -> None: reductions = { operation["reduction"] for operation in quantity.execution["operations"] } + layout = layouts.get(quantity.layout_id) + if layout is None: + raise KeyError("diagnostic selected unknown layout %s" % quantity.layout_id) + engine = self._owner._executor_for_block(block) + if "accepted_balance" in reductions and reductions != {"accepted_balance"}: + raise ValueError( + "accepted balance evidence cannot be mixed with field reductions" + ) + if reductions == {"accepted_balance"}: + if len(quantity.execution["operations"]) != 1: + raise ValueError( + "accepted balance requires exactly one native evidence route" + ) + (operation,) = quantity.execution["operations"] + automatic_terms = tuple(operation.get("automatic_terms", ())) + if automatic_terms: + if not callable(getattr(engine, "_selected_accepted_balance_terms", None)): + raise NotImplementedError( + "automatic balance terms require native " + "_selected_accepted_balance_terms(...)" + ) + component = operation["balance_component"] + if component >= len(names): + raise ValueError( + "automatic balance component %d is outside block %r width %d" + % (component, block, len(names)) + ) + if quantity.execution["role"] is not None: + role_component, _ = self._diagnostic_component( + names, roles, quantity.execution["role"] + ) + if role_component != component: + raise ValueError( + "automatic balance role selects component %d but ledger " + "declares component %d" % (role_component, component) + ) + if "reflux" in automatic_terms and not layout.adaptive: + raise NotImplementedError( + "automatic reflux balance requires an adaptive hierarchy" + ) + if ( + "projection" in automatic_terms + and layout.geometry.cell_measure != CARTESIAN_CELL_AREA + ): + raise NotImplementedError( + "automatic projection balance requires exact Cartesian cell " + "measure support" + ) + elif not callable(getattr(engine, "_accepted_balance_terms", None)): + raise NotImplementedError( + "balance diagnostic requires native _accepted_balance_terms(route)" + ) + configured_levels = tuple(level.index for level in layout.levels) + if tuple(quantity.levels) != configured_levels: + raise ValueError( + "balance diagnostic must select the complete configured hierarchy; " + "a subset cannot be reconciled with the accepted Program ledger" + ) + continue if reductions == {"step_change_l2"}: if quantity.execution["role"] is not None: raise ValueError("step-change norm is a whole-state diagnostic") @@ -2422,10 +3962,6 @@ def _validate_diagnostic_providers(self) -> None: ) else: self._diagnostic_component(names, roles, quantity.execution["role"]) - layout = layouts.get(quantity.layout_id) - if layout is None: - raise KeyError("diagnostic selected unknown layout %s" % quantity.layout_id) - engine = self._owner._executor_for_block(block) if layout.adaptive: if not callable(getattr(engine, "composite_reduce", None)): raise NotImplementedError( @@ -2524,6 +4060,48 @@ def _native_diagnostic_reduction( kind = reduction + ("_all" if full_state else "") return float(cast(Any, native)(block, kind, component)), False + @staticmethod + def _native_balance_terms( + engine: Any, + route: str, + *, + block: str, + component: int, + levels: tuple[int, ...], + automatic_terms: tuple[str, ...], + ) -> Any: + """Read one current-attempt balance tuple from the native transaction mailbox.""" + from pops.output.diagnostics import BalanceTerms + + native_name = ( + "_selected_accepted_balance_terms" if automatic_terms else "_accepted_balance_terms" + ) + native = getattr(engine, native_name, None) + if not callable(native): + raise RuntimeError("installed runtime has no accepted balance evidence provider") + raw = ( + native(route, block, component, list(levels), list(automatic_terms)) + if automatic_terms + else native(route) + ) + required = { + "storage_change", + "outward_boundary_flux", + "sources", + "reflux", + "projection", + } + if not isinstance(raw, Mapping) or set(raw) != required: + raise TypeError( + "native accepted balance provider must return exactly storage_change, " + "outward_boundary_flux, sources, reflux, and projection" + ) + if any(type(raw[name]) is not float for name in required): + raise TypeError( + "native accepted balance provider terms must be exact floating-point scalars" + ) + return BalanceTerms(**{name: raw[name] for name in sorted(required)}) + def _diagnostic_values( self, manifest: Any, @@ -2550,6 +4128,37 @@ def _diagnostic_values( variables, roles = _conservative_metadata(self._owner, block) execution = quantity.execution reductions = {operation["reduction"] for operation in execution["operations"]} + if reductions == {"accepted_balance"}: + if "accepted_balance" in skip_reductions: + continue + (operation,) = execution["operations"] + automatic_terms = tuple(operation.get("automatic_terms", ())) + component = operation.get("balance_component", 0) + balance = self._native_balance_terms( + engine, + operation["balance_route"], + block=block, + component=component, + levels=levels, + automatic_terms=automatic_terms, + ) + terms = { + "storage_change": balance.storage_change, + "outward_boundary_flux": balance.outward_boundary_flux, + "sources": balance.sources, + "reflux": balance.reflux, + "projection": balance.projection, + } + key = DiagnosticKey( + quantity.handle, + self._owner._component_manifests[block].manifest_digest, + self._owner.layout_identity(quantity.layout_id), + min(levels), + quantity.identity.token, + "discrete_balance", + ) + values.append(DiagnosticPayload(key, balance.residual, "unspecified", terms)) + continue if reductions == {"step_change_l2"}: component, full_state = 0, True else: @@ -2572,6 +4181,24 @@ def _diagnostic_values( value = math.sqrt(value) elif operation["transform"] != "identity": raise ValueError("unknown diagnostic scalar transform") + coefficient_token = operation["coefficient"] + if not isinstance(coefficient_token, str): + raise TypeError("diagnostic coefficient must be canonical float.hex() text") + try: + coefficient = float.fromhex(coefficient_token) + except (OverflowError, ValueError) as exc: + raise ValueError( + "diagnostic coefficient is not valid float.hex() text" + ) from exc + if ( + coefficient.hex() != coefficient_token + or not math.isfinite(coefficient) + or coefficient == 0.0 + ): + raise ValueError( + "diagnostic coefficient is not canonical finite nonzero binary64" + ) + value *= coefficient reduction_name = operation["name"] terms: dict[str, float] = {} conservation = execution["conservation"] @@ -2761,13 +4388,27 @@ def publish_console( effect, values, publish_callback, self._discard_diagnostics, rollback ) + def _snapshot_for_effect( + self, + effect: AcceptedSideEffect, + manifest: Any, + ) -> tuple[OutputSnapshot, OutputRequest]: + if not getattr(manifest, "diagnostic_quantities", ()): + return self._owner._output_snapshot(manifest) + token = effect.identity.token + try: + diagnostics = self._pending[token] + except KeyError as error: + raise RuntimeError( + "scientific output diagnostics were not prepared for the accepted effect" + ) from error + return self._owner._output_snapshot(manifest, diagnostics) + def _resolve_output(self, effect: AcceptedSideEffect) -> OutputPreparation: manifest = self._manifest(effect) if manifest.output_format_data["provider_id"] == "pops.output.hdf5.v1": self._drain_post_commit_before_hdf5() - snapshot, request = self._owner._output_snapshot( - manifest, self._pending.get(effect.identity.token, ()) - ) + snapshot, request = self._snapshot_for_effect(effect, manifest) fmt = manifest.output_format format_name = manifest.output_format_data["format_name"] target = _target( @@ -2789,7 +4430,7 @@ def _prepare_live_visualization( effect: AcceptedSideEffect, manifest: Any, ) -> _PreparedLiveVisualization: - snapshot, request = self._owner._output_snapshot(manifest) + snapshot, request = self._snapshot_for_effect(effect, manifest) frame = None journal = None journal_record = None @@ -2849,7 +4490,18 @@ def prepare(self, effect: AcceptedSideEffect) -> PreparedPublication: if manifest.kind is ConsumerKind.DIAGNOSTIC: return self._prepare_diagnostic(effect, manifest) if manifest.kind is ConsumerKind.MONITOR: - return self._prepare_live_visualization(effect, manifest) + diagnostic = ( + self._prepare_diagnostic(effect, manifest) + if manifest.diagnostic_quantities + else None + ) + try: + live = self._prepare_live_visualization(effect, manifest) + except BaseException: + if diagnostic is not None: + diagnostic.discard() + raise + return live if diagnostic is None else _PreparedScientificOutput(live, diagnostic) if manifest.kind is ConsumerKind.SCIENTIFIC_OUTPUT: diagnostic = ( self._prepare_diagnostic(effect, manifest) @@ -3241,10 +4893,16 @@ def _distributed_pieces( else method_name ) try: + native_communicator = communicator + if mode is ParallelMode.ROOT: + lane_provider = getattr(self._owner._publisher, "_root_output_communicator", None) + if not callable(lane_provider): + raise RuntimeError("ROOT scientific output has no run-scoped MPI lane provider") + native_communicator = lane_provider() local = self._local_pieces( native_engine, selected_method, - (communicator, *args) if mode is ParallelMode.ROOT else args, + (native_communicator, *args) if mode is ParallelMode.ROOT else args, mode=mode, rank=rank, require_local_owner=mode is not ParallelMode.ROOT, diff --git a/python/pops/runtime/_runtime_executor.py b/python/pops/runtime/_runtime_executor.py index 53ef2bf4b..fb6feba27 100644 --- a/python/pops/runtime/_runtime_executor.py +++ b/python/pops/runtime/_runtime_executor.py @@ -78,7 +78,9 @@ def _uniform_initial_sources(plan: Any) -> dict[str, dict[str, Any]]: return result -def _require_supported_execution_context(plan: Any) -> None: +def _require_supported_execution_context( + plan: Any, native_facts: dict[str, Any] | None = None +) -> None: """Refuse every resource the native engines cannot consume before constructing one.""" from pops._platform_contracts import ExecutionContext @@ -89,7 +91,7 @@ def _require_supported_execution_context(plan: Any) -> None: raise NotImplementedError( "native RuntimeInstance providers require exact float64" ) - facts = _native_runtime_facts() + facts = _native_runtime_facts() if native_facts is None else native_facts expected_device = facts.get("kokkos_device") expected_memory = facts.get("field_memory_space") expected_backend = facts.get("kokkos_backend") @@ -150,6 +152,67 @@ def _require_supported_execution_context(plan: Any) -> None: ) +def _require_runtime_determinism( + plan: Any, runtime_plan: Any, native_facts: dict[str, Any] +) -> None: + """Consume the plan's determinism guarantee against current native facts.""" + context = plan.execution_context + communication = runtime_plan.communication + planned = runtime_plan.determinism.assumptions + provider_facts = { + "rank_count": native_facts.get("mpi_ranks"), + "device": native_facts.get("kokkos_device"), + "communicator": native_facts.get("communicator"), + "execution_backend": native_facts.get("kokkos_backend"), + "shared_space": native_facts.get("kokkos_shared_space"), + "stream_identity": native_facts.get("kokkos_stream"), + "reduction_order": [ + row.identity.token for row in communication.collectives + ], + "reduction_strategy": [ + "%s:%s" % (row.operation, row.strategy) + for row in communication.collectives + ], + } + actual = {} + for name in planned: + if name in provider_facts: + actual[name] = provider_facts[name] + continue + proof = context.backend.capabilities.get(name) + actual[name] = ( + None + if proof is None or not proof.known + else proof.require("runtime.%s" % name) + ) + runtime_plan.determinism.require_assumptions(actual) + + +def _require_single_layout_runtime_plan(plan: Any, runtime_plan: Any) -> None: + """Require the exact call/layout projection consumed by one native engine.""" + layout_plan = plan.artifact.layout_plan + if len(layout_plan.layouts) != 1: + raise ValueError("single-layout native provider requires exactly one resolved layout") + layout_id = layout_plan.layouts[0].handle.qualified_id + assignments = { + row.subject.local_id: (row.subject_id, row.layout.qualified_id) + for row in layout_plan.assignments + if row.subject_kind == "block" + } + expected_calls = tuple(assignments[block.name] for block in plan.artifact.blocks) + actual_calls = tuple((row.block_id, row.layout_id) for row in runtime_plan.calls) + if actual_calls != expected_calls: + raise ValueError( + "RuntimePlanBundle calls differ from the single-layout InstallPlan projection" + ) + if runtime_plan.communication.transfers: + raise ValueError("single-layout native provider cannot consume layout Transfers") + if runtime_plan.resources.mapping_provider_ids: + raise ValueError("single-layout native provider cannot consume mapping providers") + if any(row.layout_id != layout_id for row in runtime_plan.communication.halos): + raise ValueError("RuntimePlanBundle halo differs from the installed single layout") + + class _UniformNativeProvider(RuntimeExecutorProvider): def supports(self, install_plan: Any) -> bool: return _adaptive(install_plan) is False @@ -163,6 +226,7 @@ def install(self, install_plan: Any, runtime_plan: Any = None) -> Any: return install_multi_layout_uniform(plan, runtime_plan) + _require_single_layout_runtime_plan(plan, runtime_plan) _require_native_geometry(plan) from pops.runtime._runtime_mesh_lowering import ( install_uniform_embedded_boundary, @@ -170,10 +234,10 @@ def install(self, install_plan: Any, runtime_plan: Any = None) -> Any: ) from pops.runtime._system import System - config = system_config_from_layout(plan.layout) + normalized_layout, = plan.artifact.layout_plan.layouts + config = system_config_from_layout(normalized_layout.native_spatial_layout) engine = System(config) cast(Any, engine)._execution_context = plan.execution_context - normalized_layout, = plan.artifact.layout_plan.layouts install_uniform_embedded_boundary(engine, normalized_layout) from pops.runtime._runtime_authorities import install_runtime_authorities @@ -198,8 +262,8 @@ def supports(self, install_plan: Any) -> bool: return _adaptive(install_plan) is True def install(self, install_plan: Any, runtime_plan: Any = None) -> Any: - del runtime_plan plan = require_install_plan(install_plan) + _require_single_layout_runtime_plan(plan, runtime_plan) _require_native_geometry(plan) if plan.initial_condition_plan is None or plan.bootstrap_plan is None: raise ValueError( @@ -212,7 +276,12 @@ def install(self, install_plan: Any, runtime_plan: Any = None) -> Any: artifact = plan.artifact assert artifact.program is not None, \ "resolved single-layout AMR artifact lost its compiled Program" - engine = AmrSystem(amr_config_from_layout(plan.layout, hierarchy=plan.resolved_hierarchy)) + normalized_layout, = artifact.layout_plan.layouts + engine = AmrSystem(amr_config_from_layout( + plan.layout, + hierarchy=plan.resolved_hierarchy, + native_layout=normalized_layout.native_spatial_layout, + )) engine._execution_context = plan.execution_context from pops.runtime._runtime_authorities import install_runtime_authorities @@ -279,7 +348,9 @@ def install_runtime_executor(install_plan: Any, runtime_plan: Any = None) -> Any from pops.runtime._runtime_planning import require_runtime_plan_bundle runtime_plan = require_runtime_plan_bundle(plan, runtime_plan) - _require_supported_execution_context(plan) + native_facts = _native_runtime_facts() + _require_runtime_determinism(plan, runtime_plan, native_facts) + _require_supported_execution_context(plan, native_facts) matches = tuple(provider for provider in _PROVIDERS if provider.supports(plan)) if len(matches) != 1: raise ValueError( diff --git a/python/pops/runtime/_runtime_instance.py b/python/pops/runtime/_runtime_instance.py index 403281e61..45655e6d8 100644 --- a/python/pops/runtime/_runtime_instance.py +++ b/python/pops/runtime/_runtime_instance.py @@ -433,6 +433,15 @@ def consumer_recoveries(self) -> tuple[ConsumerRecoveryRecord, ...]: registry = getattr(self, "_consumer_recoveries", {}) return tuple(registry[key].record for key in sorted(registry)) + def _failed_run_effect_fence(self) -> tuple[Any, ...]: + """Snapshot every RuntimeInstance-owned authority that can outlive a failed run.""" + return ( + self._consumer_cursors, + self._consumer_reports, + tuple(getattr(self, "_consumer_finalize_pending", ())), + self.consumer_recoveries, + ) + @property def post_commit_reports(self) -> tuple[Any, ...]: """Post-commit delivery reports retained across completed runs.""" @@ -737,6 +746,18 @@ def installed_program_hash(self) -> str: def program_report(self) -> Any: return self._executor.program_report() + def program_accepted_state(self) -> bytes: + """Return the exact accepted AMR Program state owned by the native executor.""" + provider = getattr(self._executor, "program_accepted_state", None) + if not callable(provider): + raise NotImplementedError( + "this runtime provider does not expose accepted AMR Program state" + ) + state = provider() + if type(state) is not bytes: + raise TypeError("native accepted AMR Program state must be exact bytes") + return state + @property def amr(self) -> Any: """Read-only AMR hierarchy/report view supplied by an adaptive executor.""" @@ -757,6 +778,16 @@ def inspect(self) -> Any: "artifact_identity": self._install_plan.artifact.artifact_identity.to_data(), "plan_identity": self._install_plan.artifact.plan.plan_identity.to_data(), "layout_plan": self._layout_plan.inspect(), + "resolved_dimension": self._install_plan.artifact.resolved_dimension, + "supported_dimensions": list( + self._install_plan.artifact.platform_manifest.capabilities[ + "supported_dimensions" + ].require("artifact.platform.supported_dimensions") + ), + "native_spatial_layouts": { + layout_id: row.to_data() + for layout_id, row in self._install_plan.artifact.native_layouts.items() + }, "execution_context": self._execution_context.to_data(), "runtime_plan": self._runtime_plan.to_data(), "installed_components": [ @@ -1415,6 +1446,9 @@ def _run( "RuntimeInstance._run does not accept strategy= or cfl=; declare the controller " "with Program.step_strategy(...)" ) + require_observer_world = getattr(self._publisher, "require_observer_world_available", None) + if callable(require_observer_world): + require_observer_world() from pops.runtime._step_strategy import ( prepare_step_controller, resolve_run_strategy, @@ -1430,6 +1464,16 @@ def _run( self._step_transaction_methods() entry_temporal = copy.deepcopy(getattr(native, "_temporal_restart_state", None)) entry_controller = copy.deepcopy(getattr(native, "_step_controller", None)) + entry_consumer_fence = self._failed_run_effect_fence() + publisher_fence = getattr(self._publisher, "failed_run_effect_fence", None) + entry_publisher_fence = None + if callable(publisher_fence): + try: + entry_publisher_fence = publisher_fence() + except BaseException: + # Reopening is an optimization for an effect-free failed invocation. If its + # proof cannot be captured, retain the deterministic identity fail-closed. + entry_publisher_fence = None previous_root, self._output_root = self._output_root, output_dir steps = 0 rejected_steps = 0 @@ -1488,34 +1532,46 @@ def _run( f"accepted {steps} step(s), reached t={native.time()!r}, " f"requested t_end={t_end!r}" ) - if steps == 0: - self._fire_consumers(at_end=True) + # A zero-step run has no accepted final occurrence. Its start consumers were already + # fired above; do not fabricate an AtEnd/Always/When/Every transaction at that same + # native state. close_live = getattr(self._publisher, "close_live_visualizations", None) if callable(close_live): close_live(manifest.run_identity) except BaseException as error: - if manifest is not None: - close_live = getattr(self._publisher, "close_live_visualizations", None) - if callable(close_live): - before = len(self.post_commit_diagnostics) + seal_observer_loss = getattr(self._publisher, "seal_observer_collective_loss", None) + observer_world_lost = bool(callable(seal_observer_loss) and seal_observer_loss(error)) + if observer_world_lost: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note( + "post-commit cleanup was skipped because MPI_COMM_WORLD lost its " + "collective proof" + ) + local_seal = getattr( + self._publisher, "seal_observer_workers_after_world_loss", None + ) + if callable(local_seal): try: - close_live(manifest.run_identity, raise_on_failure=False) - except BaseException as close_error: - add_note = getattr(error, "add_note", None) - if callable(add_note): - add_note("post-commit consumer close also failed: %s" % close_error) - after = self.post_commit_diagnostics - if len(after) > before: - add_note = getattr(error, "add_note", None) - if callable(add_note): - add_note( - "post-commit consumer delivery diagnostics: %s" - % "; ".join(after[before:]) + sealed = local_seal(error) + if type(sealed) is not tuple or any( + type(message) is not str or not message for message in sealed + ): + raise TypeError( + "local post-commit worker sealing must return a tuple of " + "non-empty diagnostics" ) - # ``begin_run`` binds controller/strategy state before the first native transaction. - # If no macro-step commits, the complete failed call leaves the temporal authority at - # its entry boundary. After one or more accepted steps, each later failed transaction - # already restores the last accepted boundary and that progress must be retained. + local_seal_failures = cast(tuple[str, ...], sealed) + except BaseException as caught: + local_seal_failures = ( + "local post-commit worker sealing failed: %s" % caught, + ) + if local_seal_failures and callable(add_note): + add_note("; ".join(local_seal_failures)) + # Prove restoration of the complete run-entry authority before a consumer-free serial + # invocation is allowed to reuse its deterministic identity. Cleanup still runs when + # restoration fails, but the identity remains sealed fail-closed. + entry_restored = False if steps == 0: restore_error = None try: @@ -1526,11 +1582,43 @@ def _run( native._temporal_restart_state = entry_temporal if hasattr(native, "_step_controller"): native._step_controller = entry_controller + entry_restored = True except BaseException as caught: restore_error = caught add_note = getattr(error, "add_note", None) if restore_error is not None and callable(add_note): add_note("run-entry temporal rollback also failed: %s" % restore_error) + if manifest is not None and not observer_world_lost: + close_live = getattr(self._publisher, "close_live_visualizations", None) + close_failed_run = getattr(self._publisher, "close_failed_run_consumers", None) + if callable(close_live): + before = len(self.post_commit_diagnostics) + try: + if steps == 0 and callable(close_failed_run): + close_failed_run( + manifest.run_identity, + release_identity=( + entry_restored + and self._failed_run_effect_fence() == entry_consumer_fence + and not self._consumer_finalize_pending + and not self.consumer_recoveries + ), + entry_effect_fence=entry_publisher_fence, + ) + else: + close_live(manifest.run_identity, raise_on_failure=False) + except BaseException as close_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("post-commit consumer close also failed: %s" % close_error) + after = self.post_commit_diagnostics + if len(after) > before: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note( + "post-commit consumer delivery diagnostics: %s" + % "; ".join(after[before:]) + ) if console_session is not None: from pops.runtime._console_run import safe_console_failed @@ -1563,16 +1651,37 @@ def _run( safe_console_completed(console_session, report) return report - def _checkpoint_payload(self, path: Any) -> str: + def _checkpoint_payload(self, path: Any, *, transaction_receipt: Any = None) -> Any: from pops.output._checkpoint_collective import ( canonical_checkpoint_path, checkpoint_topology, consensus, - root_value, + root_attempt, + ) + from pops.output._restart_provider import ( + _CheckpointPayloadProof, + _CheckpointTransportFailure, + _CheckpointTransactionReceipt, + _raise_cleanup_failures, ) topology = checkpoint_topology(self) expected = canonical_checkpoint_path(path) + receipt_error = None + try: + if type(transaction_receipt) is not _CheckpointTransactionReceipt: + raise RuntimeError( + "RuntimeInstance checkpoint capture requires an authenticated private " + "transaction receipt; the path-only native ABI cannot prove creator ownership" + ) + if expected != transaction_receipt.staging_path: + raise RuntimeError("checkpoint staging path differs from its transaction receipt") + if topology.rank == 0 and not transaction_receipt.has_root_descriptor: + raise RuntimeError("rank zero lacks the checkpoint transaction descriptor") + except BaseException as error: + receipt_error = error + consensus(topology, "private transaction receipt", error=receipt_error) + target = None capture_error = None try: @@ -1584,14 +1693,32 @@ def _checkpoint_payload(self, path: Any) -> str: ) except BaseException as error: capture_error = error - rows = consensus( - topology, - "native capture", - error=capture_error, - value=None if target is None else str(target), - ) + try: + rows = consensus( + topology, + "native capture", + error=capture_error, + value=None if target is None else str(target), + ) + except BaseException as error: + # Consensus may itself be the failed transport. Never enter another collective from + # this state; only rank zero owns descriptors and compensates locally. + if topology.rank == 0: + try: + transaction_receipt.cleanup_owned() + except BaseException as cleanup_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise if any(row["value"] != str(expected) for row in rows): - raise RuntimeError("native checkpoint ranks returned different staged paths") + error = RuntimeError("native checkpoint ranks returned different staged paths") + if topology.rank == 0: + try: + transaction_receipt.cleanup_owned() + except BaseException as cleanup_error: + error.add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise error import numpy as np from ._checkpoint_manifest import ( @@ -1601,22 +1728,56 @@ def _checkpoint_payload(self, path: Any) -> str: seal_checkpoint_payload, ) - def seal_root() -> str: - if not expected.is_file(): - raise RuntimeError("native checkpoint did not create the shared staged file") - with np.load(expected, allow_pickle=False) as stored: - old_manifest = json.loads(str(stored[MANIFEST_KEY])) - runtime_kind = old_manifest.get("runtime_kind") - if not isinstance(runtime_kind, str) or not runtime_kind: - raise ValueError("native checkpoint manifest lacks its runtime kind") - # Authenticate every native byte before replacing its envelope with the - # RuntimeInstance consumer/cursor authority. - authenticate_checkpoint_payload(self, stored, runtime_kind=runtime_kind) - payload = { - name: np.asarray(stored[name]).copy() - for name in stored.files - if name not in {MANIFEST_KEY, IDENTITY_KEY} - } + entries: dict[str, Any] = { + "expected": None, + "temporary": None, + "candidate": None, + "proof": None, + } + + def inspect_entry(entry: Any) -> None: + with os.fdopen(entry.duplicate(), "rb") as stream: + # ``dup`` retains the same open-file description and therefore the writer's + # current offset. Rewind the retained authority before every authenticated read. + stream.seek(0) + self._inspect_checkpoint_payload(stream.read()) + + def seal_root() -> dict[str, Any]: + initial = transaction_receipt.take_native_entry() + entries["expected"] = initial + candidate = transaction_receipt.open_candidate_at(initial.name) + entries["candidate"] = candidate + try: + with os.fdopen(candidate.duplicate(), "rb") as stream: + with np.load(stream, allow_pickle=False) as stored: + old_manifest = json.loads(str(stored[MANIFEST_KEY])) + runtime_kind = old_manifest.get("runtime_kind") + if not isinstance(runtime_kind, str) or not runtime_kind: + raise ValueError("native checkpoint manifest lacks its runtime kind") + # Creator ownership is granted only after the native bytes authenticate. + # The retained fd prevents a path swap from changing the inspected payload. + authenticate_checkpoint_payload(self, stored, runtime_kind=runtime_kind) + payload = { + name: np.asarray(stored[name]).copy() + for name in stored.files + if name not in {MANIFEST_KEY, IDENTITY_KEY} + } + # Keep the candidate fd open across the path comparison. Only a valid payload + # written into the inode created by ``created_at`` can retain that authority. + # A provider that swaps the directory entry is rejected instead of granting + # ownership to an inode reacquired by path after capture. + transaction_receipt.authenticate_entry_at(candidate) + if candidate.owner != initial.owner: + raise RuntimeError( + "native checkpoint replaced its created-at staging inode" + ) + transaction_receipt.authenticate_entry_at(initial) + except BaseException: + raise + else: + entries["candidate"] = None + candidate.close() + payload["runtime_consumer_graph"] = np.asarray(self._consumer_graph.identity.token) cursors = self._checkpoint_cursor_override or self._consumer_cursors payload["runtime_consumer_cursors"] = np.asarray( @@ -1630,22 +1791,151 @@ def seal_root() -> str: ) ) seal_checkpoint_payload(self, payload, runtime_kind=runtime_kind) - temporary = expected.with_name(expected.name + ".runtime-instance.tmp") + temporary = transaction_receipt.create_unique_at(suffix=".runtime-instance.tmp") + entries["temporary"] = temporary + with os.fdopen(temporary.duplicate(), "wb") as stream: + np.savez_compressed(stream, **payload) + transaction_receipt.authenticate_entry_at(temporary) + # Validate the completed reseal before detaching the authenticated native entry. + inspect_entry(temporary) + + native = entries["expected"] + entries["expected"] = None + transaction_receipt.quarantine_entry_at(native, phase="runtime envelope replacement") try: - with open(temporary, "wb") as stream: - np.savez_compressed(stream, **payload) - os.replace(temporary, expected) - finally: - temporary.unlink(missing_ok=True) + resealed = transaction_receipt.rename_no_replace_at( + temporary, transaction_receipt._NATIVE_NAME + ) + except FileExistsError as error: + # Even an entry already hard-linked to the temporary inode was not created by + # this rename. Never infer directory-entry ownership merely from inode equality. + raise FileExistsError( + "runtime checkpoint staging path appeared during envelope publication: %s" + % expected + ) from error + proof = _CheckpointPayloadProof(transaction_receipt, resealed) + entries["proof"] = proof + entries["temporary"] = None # A staged checkpoint is not publishable until its final envelope has been read back # and authenticated by the same strict path used during restart. - self._inspect_checkpoint_file(expected) - return str(expected) + inspect_entry(resealed) + return proof.to_data() + + def cleanup_root() -> None: + failures = [] + for key, phase in ( + ("temporary", "failed runtime envelope temporary cleanup"), + ("expected", "failed runtime envelope staging cleanup"), + ): + entry = entries[key] + entries[key] = None + if entry is None: + continue + try: + transaction_receipt.quarantine_entry_at(entry, phase=phase) + except BaseException as cleanup_error: + failures.append(cleanup_error) + candidate = entries["candidate"] + entries["candidate"] = None + if candidate is not None: + try: + candidate.close() + except BaseException as cleanup_error: + failures.append(cleanup_error) + proof = entries["proof"] + entries["proof"] = None + if proof is not None: + try: + transaction_receipt.quarantine_entry_at( + proof.entry, + phase="failed runtime envelope handoff cleanup", + ) + except BaseException as cleanup_error: + failures.append(cleanup_error) + try: + transaction_receipt.cleanup_empty() + except BaseException as cleanup_error: + failures.append(cleanup_error) + _raise_cleanup_failures("runtime checkpoint cleanup failed", failures) + + attempt = root_attempt(topology, "runtime envelope sealing", seal_root) + if attempt.transport_error is not None: + error = _CheckpointTransportFailure( + "checkpoint transport failed during runtime envelope sealing: %s" + % attempt.transport_error + ) + if attempt.producer_error is not None: + error.add_note("rank-zero producer also failed: %s" % attempt.producer_error) + if topology.rank == 0: + try: + cleanup_root() + except BaseException as cleanup_error: + error.add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise error from attempt.transport_error + if attempt.producer_error is not None: + error = attempt.producer_error + cleanup_attempt = root_attempt( + topology, + "runtime envelope staging cleanup", + cleanup_root, + ) + cleanup_errors = tuple( + item + for item in ( + cleanup_attempt.producer_error, + cleanup_attempt.transport_error, + ) + if item is not None + ) + if cleanup_errors: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note( + "failed runtime checkpoint staging cleanup: " + + "; ".join(str(item) for item in cleanup_errors) + ) + raise error - sealed = Path(root_value(topology, "runtime envelope sealing", seal_root)) - if sealed != expected: - raise RuntimeError("rank zero sealed a different checkpoint staging path") - return str(expected) + proof_error = None + proof = None + try: + if topology.rank == 0: + proof = entries["proof"] + if type(proof) is not _CheckpointPayloadProof: + raise RuntimeError("rank zero lost its exact checkpoint payload proof") + if proof.to_data() != attempt.value: + raise RuntimeError("rank-zero payload proof differs from its broadcast") + else: + proof = _CheckpointPayloadProof.observed(transaction_receipt, attempt.value) + except BaseException as error: + proof_error = error + try: + consensus( + topology, + "runtime envelope payload proof", + error=proof_error, + value=attempt.value, + ) + except BaseException as error: + if topology.rank == 0: + try: + cleanup_root() + except BaseException as cleanup_error: + add_note = getattr(error, "add_note", None) + if callable(add_note): + add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise + if proof is None: + error = RuntimeError("checkpoint payload proof validation returned no proof") + if topology.rank == 0: + try: + cleanup_root() + except BaseException as cleanup_error: + error.add_note("rank-zero checkpoint cleanup also failed: %s" % cleanup_error) + raise error + if topology.rank == 0: + entries["proof"] = None + return proof def _restart_operation(self) -> Any: from pops.output._restart_provider import RestartAuthority @@ -1666,7 +1956,7 @@ def checkpoint(self, path: Any) -> str: snapshot = operation.snapshot(self, target.parent) operation.validate_snapshot(snapshot) try: - return str(operation.write(snapshot, target)) + produced = str(operation.write(snapshot, target)) except BaseException as error: discard = getattr(snapshot, "discard", None) if callable(discard): @@ -1677,6 +1967,10 @@ def checkpoint(self, path: Any) -> str: if callable(add_note): add_note("checkpoint staging cleanup also failed: %s" % cleanup_error) raise + finalize = getattr(snapshot, "finalize", None) + if callable(finalize): + finalize() + return produced finally: self._retry_consumer_finalizers() @@ -1700,10 +1994,6 @@ def _checkpoint_cursors_from_data(cursor_data: Any) -> ConsumerCursorSet: raise ValueError("restart consumer cursor rows are not canonical") return cursors - def _inspect_checkpoint_file(self, path: Any) -> ConsumerCursorSet: - """Rank-zero-only complete authentication; performs no native mutation.""" - return self._inspect_checkpoint_payload(Path(path).read_bytes()) - def _inspect_checkpoint_payload(self, payload: bytes) -> ConsumerCursorSet: """Authenticate exact in-memory bytes on rank zero without native mutation.""" from pops.output._checkpoint_collective import decode_checkpoint_bytes diff --git a/python/pops/runtime/_runtime_mesh_lowering.py b/python/pops/runtime/_runtime_mesh_lowering.py index 0c9358f98..bbae43601 100644 --- a/python/pops/runtime/_runtime_mesh_lowering.py +++ b/python/pops/runtime/_runtime_mesh_lowering.py @@ -13,38 +13,54 @@ def _uniform_system_values( - mesh: Any, + native_layout: Any, ) -> tuple[int, float, tuple[bool, bool], float, float]: """Project exactly the uniform mesh shapes representable by native ``SystemConfig``.""" - from pops.mesh.grid import CartesianGrid + from pops.mesh import NativeSpatialLayout + from pops.mesh._layout_plan_contracts import CARTESIAN_2D_COORDINATES - if type(mesh) is not CartesianGrid: + if type(native_layout) is not NativeSpatialLayout: + raise TypeError("native uniform lowering requires an exact NativeSpatialLayout") + if native_layout.dimension != 2 \ + or native_layout.coordinate_system != CARTESIAN_2D_COORDINATES \ + or native_layout.centering != "cell": raise NotImplementedError( - "native uniform System lowering requires an exact pops.mesh.CartesianGrid; " - "construct it from a framed pops.domain.Rectangle") - if mesh.cells[0] != mesh.cells[1]: + "native uniform SystemConfig currently supports only 2D cell-centered Cartesian " + "layouts") + shape = native_layout.shape + if shape[0] != shape[1]: raise NotImplementedError( "native SystemConfig has one n and cannot represent a rectangular CartesianGrid") - lengths = mesh.frame.lengths + lengths = tuple( + high - low + for low, high in zip(native_layout.lower, native_layout.upper, strict=True) + ) if lengths[0] != lengths[1]: raise NotImplementedError( "native SystemConfig has one L and cannot represent anisotropic CartesianGrid extents") - periodic_axes = mesh.topology.periodic_axes - periodic_indices = {axis.index for axis in periodic_axes} + decomposition = native_layout.decomposition + expected_box = { + "lower": (0, 0), + "upper_exclusive": shape, + } + boxes = decomposition.get("boxes") + if decomposition.get("kind") != "single_box" or tuple(boxes or ()) != (expected_box,): + raise NotImplementedError( + "native uniform SystemConfig currently supports one exact full-domain box") return ( - int(mesh.cells[0]), + int(shape[0]), float(lengths[0]), - (0 in periodic_indices, 1 in periodic_indices), - float(mesh.frame.lower[0]), - float(mesh.frame.lower[1]), + native_layout.periodicity, + float(native_layout.lower[0]), + float(native_layout.lower[1]), ) -def system_config_from_layout(layout: Any) -> Any: +def system_config_from_layout(native_layout: Any) -> Any: """Build the native uniform config from an authenticated layout descriptor.""" from pops._bootstrap import SystemConfig - n, extent, periodicity, xlo, ylo = _uniform_system_values(layout.mesh) + n, extent, periodicity, xlo, ylo = _uniform_system_values(native_layout) cfg = SystemConfig() cfg.n = n cfg.L = extent diff --git a/python/pops/runtime/_runtime_plan_io.py b/python/pops/runtime/_runtime_plan_io.py index 6633aa2b6..8fede833b 100644 --- a/python/pops/runtime/_runtime_plan_io.py +++ b/python/pops/runtime/_runtime_plan_io.py @@ -104,7 +104,8 @@ def proved_platform(plan: Any) -> tuple[Any, Any, tuple[str, ...], dict[str, Any "compute": platform.precision.compute.require("platform.precision.compute"), "accumulation": platform.precision.accumulation.require("platform.precision.accumulation"), "reduction": platform.precision.reduction.require("platform.precision.reduction"), - "dimensions": platform.capabilities["dimensions"].require("platform.capabilities.dimensions"), + "supported_dimensions": platform.capabilities["supported_dimensions"].require( + "platform.capabilities.supported_dimensions"), } spaces = platform.memory_spaces.require("platform.memory_spaces") except (KeyError, TypeError, ValueError) as exc: @@ -113,11 +114,22 @@ def proved_platform(plan: Any) -> tuple[Any, Any, tuple[str, ...], dict[str, Any if not isinstance(spaces, tuple) or not spaces or any(not isinstance(item, str) or not item for item in spaces) or len(spaces) != len(set(spaces)): refuse("invalid_memory_spaces", "platform.memory_spaces", "platform memory spaces must be a unique non-empty tuple", evidence=spaces) - dimensions = facts["dimensions"] - if not isinstance(dimensions, tuple) or len(dimensions) != 1 or isinstance(dimensions[0], bool) or not isinstance(dimensions[0], int): - refuse("ambiguous_platform_dimension", "platform.capabilities.dimensions", - "runtime planning requires exactly one selected dimension", evidence=dimensions) - facts["dimension"] = dimensions[0] + dimensions = facts["supported_dimensions"] + if not isinstance(dimensions, tuple) or not dimensions \ + or any(isinstance(value, bool) or not isinstance(value, int) for value in dimensions) \ + or len(dimensions) != len(set(dimensions)): + refuse("invalid_supported_dimensions", "platform.capabilities.supported_dimensions", + "platform dimensions must be a unique non-empty tuple", evidence=dimensions) + selected = getattr(plan.artifact.plan, "resolved_dimension", None) + if isinstance(selected, bool) or not isinstance(selected, int): + refuse("missing_resolved_dimension", "artifact.plan.resolved_dimension", + "runtime planning requires one exact layout-derived dimension", evidence=selected) + if selected not in dimensions: + refuse("unsupported_resolved_dimension", "artifact.plan.resolved_dimension", + "resolved layout dimension is not supported by the selected platform", + evidence={"resolved_dimension": selected, + "supported_dimensions": list(dimensions)}) + facts["dimension"] = selected return platform, context, spaces, facts diff --git a/python/pops/runtime/_system.py b/python/pops/runtime/_system.py index a9346686a..b5c529136 100644 --- a/python/pops/runtime/_system.py +++ b/python/pops/runtime/_system.py @@ -17,7 +17,11 @@ from pops._bootstrap import AmrSystemConfig # noqa: F401 (re-exported via this module) from pops.runtime import _threading from pops.runtime._lifecycle import ( - FROZEN_STRUCTURAL as _FROZEN_STRUCTURAL, freeze_error as _freeze_error, _LifecycleMixin) + FROZEN_STRUCTURAL as _FROZEN_STRUCTURAL, + RETIRED_NATIVE_PASSTHROUGH as _RETIRED_NATIVE_PASSTHROUGH, + freeze_error as _freeze_error, + _LifecycleMixin, +) from pops.runtime._amr_system import AmrSystem # noqa: F401 (re-exported via this module) from pops.runtime._system_aux_state import _SystemAuxState from pops.runtime._system_diagnostics import _SystemDiagnostics @@ -76,11 +80,11 @@ class System(_SystemInstall, _SystemUnifiedInstall, _SystemAuxState, Low-level runtime. The documented PUBLIC path is the typed ``pops.Case`` assembly lowered by ``pops.compile`` and wired by ``pops.bind`` -> ``pops.run(sim, ...)``; the per-step native methods - (and ``add_block`` / ``add_equation`` / ``set_poisson``) + (and ``add_equation`` / ``set_poisson``) are the low-level seam ``pops.bind`` builds on and the tests use, not the recommended front door. - ``add_block`` takes a private native ``ModelSpec`` plus private spatial and time adapters. + ``add_equation`` dispatches a private native ``ModelSpec`` or a compiled production package. Public authoring uses ``pops.Model`` through ``pops.Case``; discretization and reusable integration Programs live in ``pops.numerics`` and ``pops.lib.time`` respectively. Everything else (set_poisson, set_density, step, step_cfl, diagnostics, @@ -271,6 +275,11 @@ def __getattr__(self, attr: Any) -> Any: "with no AMR hierarchy. Declare layout=AMR(...) on the pops.Case for a refined run " "(its sim.amr returns an AmrRuntimeView), or pops.inspect(layout) for the " "static authoring report.") + if attr in _RETIRED_NATIVE_PASSTHROUGH: + raise AttributeError( + "System.%s is not an authoring route; declare the block with pops.Case.block(...)" + % attr + ) # RUNTIME FREEZE (ADC-592): once bound, refuse a native STRUCTURAL setter reached through the # passthrough (instance.install_program / ...) with the bind-vocabulary # RuntimeError -- NOT AttributeError -- so the bypass is closed even under a prebuilt .so whose diff --git a/python/pops/runtime/_system_contract.py b/python/pops/runtime/_system_contract.py index f62b9caa4..a3b1258cb 100644 --- a/python/pops/runtime/_system_contract.py +++ b/python/pops/runtime/_system_contract.py @@ -40,7 +40,6 @@ class _System: _execution_context: Any def add_equation(self, *args: Any, **kwargs: Any) -> Any: ... - def add_block(self, *args: Any, **kwargs: Any) -> Any: ... def set_poisson(self, *args: Any, **kwargs: Any) -> Any: ... def _set_poisson_native(self, *args: Any, **kwargs: Any) -> Any: ... def set_state(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/python/pops/runtime/_system_install.py b/python/pops/runtime/_system_install.py index b33ae6b92..2b386138f 100644 --- a/python/pops/runtime/_system_install.py +++ b/python/pops/runtime/_system_install.py @@ -1,7 +1,7 @@ -"""System install mixin (Spec-4 PR-F): block/equation/coupling installation. +"""System install mixin (Spec-4 PR-F): equation/coupling installation. -Holds the densest part of :class:`pops.runtime._system.System`: ``add_block`` / -``add_equation`` (direct native versus compiled production-package installation), +Holds the densest part of :class:`pops.runtime._system.System`: ``add_equation`` +(direct native versus compiled production-package installation), ``add_background``, ``add_elliptic_model`` and ``add_coupling``. Mixed into ``System`` via inheritance; methods operate on ``self._s`` (the compiled facade) and ``self._aux_field_index``. """ @@ -41,61 +41,25 @@ _System = object -class _SystemInstall(_System): - """Block/equation/coupling installation methods of System.""" +def _reject_unpublished_newton_diagnostics(time: Any, *, where: str) -> None: + if getattr(time, "newton_diagnostics", False): + raise ValueError( + f"{where}: newton_diagnostics=True is unavailable on the Program-only System " + "runtime because no typed implicit Program consumer publishes that report" + ) - def add_block(self, name: Any, model: Any, spatial: Any = None, time: Any = None, - evolve: bool = True) -> Any: - """Installs an evolved block composed of NATIVE BRICKS on the shared system Poisson. - Low-level runtime seam. The documented PUBLIC path is the typed - ``pops.Case(...).block(...)`` assembly passed through ``pops.resolve`` / ``pops.compile`` - and wired by ``pops.bind`` (which calls this method internally); ``add_block`` stays for that seam, - the native/AMR runtime, and the tests. - - Installs a private ``ModelSpec`` composed from native bricks. Public ``pops.Model`` - authoring enters through ``pops.Case`` and the lifecycle. For a compiled production model - or automatic dispatch on the engine value type, use add_equation. Arguments reach the C++ facade - (System::add_block), which validates the block (names / roles / implicit mask) against the model. - - @param name unique block name; indexes set_density(name) / mass(name) / density(name). - @param model private ``ModelSpec`` engine value. - @param spatial private engine adapter lowered from ``pops.numerics.FiniteVolume(...)`` - (default minmod + rusanov + conservative). Carries the limiter (none / minmod / - vanleer / weno5 -- - weno5 is exposed ONLY by this native path), the Riemann flux (rusanov / hll / hllc / - roe) and the reconstructed variables (conservative / primitive). positivity_floor is read - here (Zhang-Shu positivity limiter). - @param time private engine policy. Public authoring uses an explicit ``pops.Program`` or a - ``pops.lib.time`` factory. The lowered policy carries cadence, any implicit mask and - local Newton options; these values are forwarded as-is to C++. - @param evolve True (default) = block advances; False = frozen field (background) which still - contributes to the right-hand side of the system Poisson. - """ - _guard_assembling(self, "add_block") # frozen once pops.bind completes (ADC-592) - spatial = spatial if spatial is not None else Spatial() - time = time if time is not None else Explicit() - # Native ABI conversion happens here; descriptors above this seam stay exact. - rel_tol, abs_tol, fd_eps, damping, positivity_floor = native_block_scalars( - time, spatial, where="System.add_block") - self._s.add_block(name, model, spatial.limiter, spatial.flux, spatial.recon, time.kind, - getattr(time, "substeps", 1), evolve, getattr(time, "stride", 1), - getattr(time, "implicit_vars", []), getattr(time, "implicit_roles", []), - getattr(time, "newton_max_iters", NEWTON_DEFAULT_MAX_ITERS), - rel_tol, abs_tol, fd_eps, - getattr(time, "newton_diagnostics", False), - damping, - positivity_floor, - getattr(spatial, "wave_speed_cache", False), **_weno_kwargs(spatial)) +class _SystemInstall(_System): + """Equation/coupling installation methods of System.""" def add_equation(self, name: Any, model: Any, spatial: Any = None, time: Any = None, substeps: Any = None, names: Any = None, evolve: bool = True, stride: Any = None, _bind_params: Any = None) -> Any: """Install a native model or one compiled production package. - Low-level runtime seam. The documented PUBLIC path is the typed + Sole Python block-installation seam below ``pops.bind``. The documented PUBLIC path is the typed ``pops.Case(...).block(...)`` assembly passed through ``pops.resolve`` / ``pops.compile`` - and wired by ``pops.bind``; ``add_equation`` stays private to the native/AMR runtime. + and wired by ``pops.bind``; ``add_equation`` stays private to the native runtime. A ``ModelSpec`` uses the direct native brick path. A ``CompiledModel`` must be a production package; its complete resolved BindSchema vector is provided privately by @@ -117,6 +81,7 @@ def add_equation(self, name: Any, model: Any, spatial: Any = None, time: Any = N spatial = spatial if spatial is not None else Spatial() time = time if time is not None else Explicit() + _reject_unpublished_newton_diagnostics(time, where="System.add_equation") nsub = positive_int(substeps if substeps is not None else getattr(time, "substeps", 1), where="System.add_equation.substeps") nstride = positive_int(stride if stride is not None else getattr(time, "stride", 1), where="System.add_equation.stride") @@ -267,9 +232,10 @@ def add_equation(self, name: Any, model: Any, spatial: Any = None, time: Any = N def add_background(self, name: Any, model: Any, density: Any, spatial: Any = None) -> Any: """FROZEN species (not advanced): a fixed background that contributes to the system Poisson (and, - later, to coupled sources). density: n*n array. Equivalent to add_block(evolve=False) then - set_density (freeze ADC-592 enforced by the delegated, guarded add_block).""" - self.add_block(name, model, spatial=spatial, evolve=False) + later, to coupled sources). density: n*n array. Uses the same type-dispatched + ``add_equation(evolve=False)`` installation seam as evolved blocks, then sets density. + """ + self.add_equation(name, model, spatial=spatial, evolve=False) self.set_density(name, density) def set_poisson(self, rhs: Any = "charge_density", solver: Any = None, diff --git a/python/pops/runtime/_system_install_lowering.py b/python/pops/runtime/_system_install_lowering.py index 5f9153726..ce78c1c7c 100644 --- a/python/pops/runtime/_system_install_lowering.py +++ b/python/pops/runtime/_system_install_lowering.py @@ -61,10 +61,10 @@ def _lower_bc(bc: Any) -> Any: def _weno_kwargs(spatial): """ADC-645: WENO5(epsilon=...) rides along the Spatial; None (the default) forwards NOTHING so - the native add_block keeps its kWenoEpsilon default (byte-identical historical call).""" + the native ABI keeps its kWenoEpsilon default (byte-identical historical call).""" weps = getattr(spatial, "weno_epsilon", None) return {} if weps is None else { - "weno_epsilon": native_real(weps, where="System.add_block.weno_epsilon")} + "weno_epsilon": native_real(weps, where="System.add_equation.weno_epsilon")} def _mg_kwargs(rel_tol, max_cycles, min_coarse, pre_smooth, post_smooth, bottom_sweeps, diff --git a/python/pops/runtime/_system_unified_install.py b/python/pops/runtime/_system_unified_install.py index eaba0848b..6f9f28d33 100644 --- a/python/pops/runtime/_system_unified_install.py +++ b/python/pops/runtime/_system_unified_install.py @@ -426,6 +426,11 @@ def _install_compiled(self, compiled=None, *, instances=None, params=None, aux=N # NATIVE mode (compiled=None) deliberately installs no temporal authority. The blocks are # inspectable spatial carriers, but step/advance fail closed until a Program is installed. if so_path is not None: + component = getattr(compiled, "program", None) + authored = getattr(component, "program", component) + from pops.runtime._program_cadence_install import install_program_cadence + + install_program_cadence(self, authored) self.install_program(so_path) # (5a) HISTORY-PERSISTENCE POLICIES (ADC-626): the compiled Program records a per-ring # persistence policy (Dense / Interval / Revolve) on program._history_persistence. Attach the @@ -441,8 +446,6 @@ def _install_compiled(self, compiled=None, *, instances=None, params=None, aux=N # (5b) Program carriers were emitted with neutral values. Always install the complete # BindSchema projection after loading, including declaration defaults. self._install_program_params(compiled, bind_schema, params) - component = getattr(compiled, "program", None) - authored = getattr(component, "program", component) self._step_strategy = getattr(authored, "_step_strategy", None) self._step_transaction_plan = ( authored.transaction_plan() if authored is not None else None) diff --git a/python/pops/runtime/amr/_view.py b/python/pops/runtime/amr/_view.py index 705850a1f..29c2980de 100644 --- a/python/pops/runtime/amr/_view.py +++ b/python/pops/runtime/amr/_view.py @@ -172,7 +172,7 @@ def explain_ghosts(self) -> Any: per_level_depth=None, requirement_note=( "the reconstruction stencil sets the ghost depth " - "(minmod / vanleer -> 1, weno5 -> 3); the coarse-fine fine ghosts " + "(minmod / vanleer / mc / superbee -> 1, weno5 -> 3); the coarse-fine fine ghosts " "are re-derived per path on the AMR transport." ), notes=["per-level ghost depth is not exposed by this native build."], @@ -215,8 +215,10 @@ def explain_checkpoint(self) -> Any: "artifact-backed Program and unchanged MPI cardinality: it restores the exact accepted " "state first, then performs one scientific tag/regrid at the restored clock.", "RegridOnRestart() supports serial and exact-MPI-world rematerializable " - "shared-interface flux groups at unchanged MPI cardinality; it still refuses Uniform " - "and multi-layout runtimes, elliptic field providers, and bootstrap staggered caches.", + "depth-preserving shared-interface flux groups at unchanged MPI cardinality; it still " + "refuses Uniform and multi-layout runtimes, active-depth changes, unsupported " + "non-finest replacements at depth greater than two, elliptic field providers, and " + "bootstrap staggered caches.", "Its phase-local history consensus fingerprints are cold-restart collectives whose " "memory and communication cost scales with every dense history slot; they prove " "all-rank agreement per hierarchy, not bitwise equality across interpolation.", diff --git a/python/pops/runtime/amr_program_support.py b/python/pops/runtime/amr_program_support.py index 761d40356..27de63d93 100644 --- a/python/pops/runtime/amr_program_support.py +++ b/python/pops/runtime/amr_program_support.py @@ -108,34 +108,24 @@ def supports_shared_interface_fragments(self) -> bool: }, "coupled_solve": { "issue": None, - "op_source": "program_emit_kernels._AUX_OUTPUT_OPS['solve_fields_from_blocks']", + "op_source": "Program IR solve_fields_from_blocks -> program_emit_ops " + "ctx.solve_fields_from_blocks_at", "ir_ops": frozenset({"solve_fields_from_blocks"}), "header_methods": frozenset(), }, "named_field_solve": { "issue": None, - "op_source": "Program IR solve_fields -> program_emit_ops ctx.solve_fields_from_state", + "op_source": "Program IR solve_fields -> program_emit_ops " + "ctx.solve_fields_from_state_at", "ir_ops": frozenset({"solve_fields"}), "header_methods": frozenset(), }, - "unqualified_field_solve": { - "issue": None, - "op_source": "not representable in final Program IR (field identity is mandatory)", - "ir_ops": frozenset(), - "header_methods": frozenset({"solve_fields_from_state_default"}), - }, "unqualified_coupled_solve": { "issue": None, "op_source": "not representable in final Program IR (field identity is mandatory)", "ir_ops": frozenset(), "header_methods": frozenset({"solve_fields_from_blocks_default"}), }, - "fine_level_field_perturbation": { - "issue": None, - "op_source": "field-provider perturbation inside an implicit solve", - "ir_ops": frozenset(), - "header_methods": frozenset({"solve_fields_from_state_at_fine_level"}), - }, "scheduler": { "issue": None, "op_source": "program_emit_schedule (held / scheduled cache_* seams)", @@ -232,19 +222,12 @@ def _used_groups(program: Any, *, context: AMRProgramSupportContext) -> set: if op == "rhs" and _has_named_fluxes(attrs): used.add("named_flux") # The canonical IR op is solve_fields; code generation alone lowers that operation to the - # C++ AmrProgramContext::solve_fields_from_state seam. + # exact C++ AmrProgramContext::solve_fields_from_state_at seam. if op == "solve_fields" and attrs.get("field"): used.add("named_field_solve") # A held / scheduled node lowers to the deferred scheduler cache seams. if attrs.get("schedule") is not None: used.add("scheduler") - # A field-coupled finite-difference Jacobian re-solves the provider at a perturbed state. - # AmrProgramContext serves this on the coarse level, but cannot do so on a fine level until - # a composite stage solver exists. This is conditional on resolved hierarchy evidence, not - # a property that Program IR can decide alone. - if op == "rhs_jacvec" and attrs.get("field_coupled") is True \ - and context.refined_hierarchy: - used.add("fine_level_field_perturbation") return used diff --git a/python/pops/runtime/doctor.py b/python/pops/runtime/doctor.py index 2972134b9..8c0d5604f 100644 --- a/python/pops/runtime/doctor.py +++ b/python/pops/runtime/doctor.py @@ -17,14 +17,7 @@ # descriptor catalogs (see _descriptor_tokens); this only pins the display order so the audit # table reads the same every run (and the test_capabilities contract keeps its ordered lists). _RIEMANN_ORDER = ("rusanov", "hll", "hllc", "roe") -_LIMITER_ORDER = ("none", "minmod", "vanleer", "weno5") -# Riemann fluxes wired on the polar geometry: rusanov (any model) + hll (isothermal fluid declares -# wave_speeds). hllc/roe have no polar energy-flux brick (make_block_polar rejects them), so the -# polar row is the catalog intersected with this allow-list -- a removed flux cannot leave a phantom -# polar token, and an added flux is not silently advertised as polar-capable. -_POLAR_RIEMANN = ("rusanov", "hll") - - +_LIMITER_ORDER = ("none", "minmod", "vanleer", "weno5", "mc", "superbee") def _ordered(tokens: Any, order: Any) -> Any: """Tokens kept in canonical ``order`` first, then any extras sorted (deterministic display).""" present = set(tokens) @@ -40,12 +33,14 @@ def _descriptor_tokens() -> Any: the internal descriptor catalog report walks (riemann / limiter / reconstruction / elliptic solvers), so adding or retiring a descriptor cannot silently desync the doctor matrix from the introspectable capability matrix. Only descriptors that declare themselves available - are reported (a planned-but-not-native brick like ``mc`` / ``superbee`` is left out). Pure: no - ``_pops`` import, no numeric loop. + are reported; MC and Superbee are ordinary native limiter descriptors and therefore appear + through this same path without a doctor-specific allowlist. Pure: no ``_pops`` import, no + numeric loop. """ from pops.numerics.reconstruction import reconstruction from pops.numerics.reconstruction.limiters import limiters from pops.numerics.riemann import riemann + from pops.runtime._generated_component_routes import ROUTE_METADATA from pops.solvers.elliptic import FFT, GeometricMG def _available(namespace: Any) -> Any: @@ -81,7 +76,10 @@ def _available(namespace: Any) -> Any: return { "riemann": riemann_tokens, - "riemann_polar": [t for t in riemann_tokens if t in _POLAR_RIEMANN], + "riemann_polar": [ + token for token in riemann_tokens + if ROUTE_METADATA["riemann"].get(token, {}).get("polar_ok", False) + ], "dsl_limiters": dsl_limiters, "poisson": poisson, } @@ -305,7 +303,8 @@ def capabilities() -> Any: "scalar ExB (no wave_speeds) -- same gate as the cartesian one", "hllc": "model capability HasHLLCStructure required -- " "emitted by the DSL via m.enable_hllc() (roles + 'p', including 3-var non " - "Euler, passive advected scalars) ; the native Euler brick provides it. " + "Euler, passive advected scalars) ; native Euler and isothermal bricks provide it, " + "including the annular polar isothermal route. " "No component-count/layout inference and no fallback.", "roe": "model capability HasRoeDissipation required " "-- TWO DSL paths : (a) m.enable_roe() generated from the roles (roles + " @@ -313,9 +312,9 @@ def capabilities() -> Any: "c=sqrt(p/rho) Roe average, passive scalars on the entropy wave) ; (b) " "m.roe_dissipation(x=, y=) PROVIDED by the user (own eigenstructure, " "left()/right() of the two states, helper m.flux_jacobian auto-derived). Paths " - "exclusive (a single provider of the hook). has_roe covers both ; the native " - "Euler brick provides the hook. No component-count/layout inference and no " - "fallback.", + "exclusive (a single provider of the hook). has_roe covers both ; native Euler " + "and isothermal bricks provide the hook, including the annular polar route. " + "No component-count/layout inference and no fallback.", }, }, "time": { diff --git a/python/pops/runtime/inspection.py b/python/pops/runtime/inspection.py index dcc71a420..4b3d0b4c7 100644 --- a/python/pops/runtime/inspection.py +++ b/python/pops/runtime/inspection.py @@ -146,6 +146,17 @@ def build_runtime_inspection( cap_report = native_capability_report() cap_dict = cap_report.to_dict() options = _options(sim, runtime) + environment = runtime_environment_report() + if instance is not None: + selected = instance.get("resolved_dimension") + supported = instance.get("supported_dimensions") + if isinstance(selected, bool) or not isinstance(selected, int): + raise TypeError("runtime instance inspection requires one exact resolved_dimension") + if not isinstance(supported, list) or selected not in supported: + raise ValueError( + "runtime instance resolved_dimension is absent from supported_dimensions") + environment["dimension"] = selected + environment["supported_dimensions"] = list(supported) limitations = [ {"feature": row.feature, "status": row.status, "reason": row.limitation} for row in cap_report.routes @@ -155,7 +166,7 @@ def build_runtime_inspection( runtime=runtime, blocks=_block_names(sim), clock=_clock(sim), - runtime_environment=runtime_environment_report(), + runtime_environment=environment, capabilities=cap_dict, program=_program(sim), profile=PerformanceSummary(_profile_payload(sim)).to_dict(), @@ -204,17 +215,29 @@ def _program(sim: Any) -> Any: ("installed"/"hash") are preserved, with the richer transaction/block-map/parameter/history/cache summary folded in from the same report.""" - from pops.runtime.program_report import build_program_report - report = build_program_report(sim) + from pops.runtime.program_report import ProgramRuntimeReport, build_program_report + + provider = getattr(sim, "program_report", None) + report = provider() if callable(provider) else build_program_report(sim) + if type(report) is not ProgramRuntimeReport: + raise TypeError( + "runtime inspection requires the canonical ProgramRuntimeReport" + ) return { "installed": report.installed, "hash": report.program_hash, "step_transaction": dict(report.step_transaction), "block_map": list(report.block_map), "params": [dict(row) for row in report.params], + "diagnostics": dict(report.diagnostics), "histories": [dict(row) for row in report.histories], "cache": [dict(row) for row in report.cache], "profiler": dict(report.profiler), + "clocks": [dict(row) for row in report.clocks], + "level_relations": [dict(row) for row in report.level_relations], + "flux_ledger": [dict(row) for row in report.flux_ledger], + "synchronization": [dict(row) for row in report.synchronization], + "temporal": dict(report.temporal), } diff --git a/python/pops/runtime/program_report.py b/python/pops/runtime/program_report.py index 6194a5727..b21cbad5a 100644 --- a/python/pops/runtime/program_report.py +++ b/python/pops/runtime/program_report.py @@ -42,13 +42,28 @@ class ProgramRuntimeReport: sections); a bound program fills the sections from the C++ Program subsystem accessors. """ - schema_version = 3 + schema_version = 4 report_type = "program_runtime" - def __init__(self, *, installed: Any, program_hash: Any, step_transaction: Any, block_map: Any, - params: Any, diagnostics: Any, histories: Any, cache: Any, - profiler: Any, clocks: Any, level_relations: Any, - flux_ledger: Any, synchronization: Any, temporal: Any) -> None: + def __init__( + self, + *, + installed: Any, + program_hash: Any, + step_transaction: Any, + block_map: Any, + params: Any, + diagnostics: Any, + histories: Any, + cache: Any, + profiler: Any, + clocks: Any, + level_relations: Any, + flux_ledger: Any, + synchronization: Any, + temporal_partition: Any, + temporal: Any, + ) -> None: self.installed = bool(installed) self.program_hash = program_hash or "" self.step_transaction = dict(step_transaction) @@ -62,6 +77,7 @@ def __init__(self, *, installed: Any, program_hash: Any, step_transaction: Any, self.level_relations = [dict(row) for row in level_relations] self.flux_ledger = [dict(row) for row in flux_ledger] self.synchronization = [dict(row) for row in synchronization] + self.temporal_partition = dict(temporal_partition) self.temporal = dict(temporal) def to_dict(self) -> Any: @@ -81,6 +97,7 @@ def to_dict(self) -> Any: "level_relations": [dict(row) for row in self.level_relations], "flux_ledger": [dict(row) for row in self.flux_ledger], "synchronization": [dict(row) for row in self.synchronization], + "temporal_partition": dict(self.temporal_partition), "temporal": dict(self.temporal), } @@ -93,9 +110,12 @@ def to_json(self, path: Any = None, *, indent: int = 2) -> Any: return text def __repr__(self) -> Any: - return ("ProgramRuntimeReport(installed=%r, hash=%r, histories=%d, cache=%d)" - % (self.installed, self.program_hash or "(none)", len(self.histories), - len(self.cache))) + return "ProgramRuntimeReport(installed=%r, hash=%r, histories=%d, cache=%d)" % ( + self.installed, + self.program_hash or "(none)", + len(self.histories), + len(self.cache), + ) def __str__(self) -> Any: strategy = self.step_transaction.get("strategy", {}) @@ -112,6 +132,7 @@ def __str__(self) -> Any: lines.append(" clocks : %d cursor(s)" % len(self.clocks)) lines.append(" flux ledger : %d accepted contribution(s)" % len(self.flux_ledger)) lines.append(" sync : %d phase event(s)" % len(self.synchronization)) + lines.append(" partition : %s" % (self.temporal_partition.get("kind") or "(none)")) return "\n".join(lines) @@ -121,13 +142,18 @@ def _params(sim: Any) -> Any: count 0. The limit (ADC-610) surfaces the previously-hidden fixed-array capacity so a block's headroom is introspectable.""" from pops.physics.aux import max_runtime_params # lazy: keep the report import-light + limit = max_runtime_params() rows = [] block_map = list(_call(sim, "program_block_map", []) or []) prog_blocks = list(range(len(block_map))) if block_map else [0] for prog_block in prog_blocks: - rp = _call(sim, "program_params", None, prog_block) - count = getattr(rp, "count", None) if rp is not None else None + count = _call(sim, "program_param_count", None, prog_block) + if count is None: + # Compatibility for report-only authorities used by downstream integrations. Native + # System and AmrSystem expose program_param_count directly, without publishing values. + rp = _call(sim, "program_params", None, prog_block) + count = getattr(rp, "count", None) if rp is not None else None rows.append({"program_block": prog_block, "count": count, "limit": limit}) return rows @@ -135,36 +161,44 @@ def _params(sim: Any) -> Any: def _histories(sim: Any) -> Any: rows = [] for name in _call(sim, "history_names", []) or []: - rows.append({ - "name": name, - "depth": _call(sim, "history_depth", None, name), - "ncomp": _call(sim, "history_ncomp", None, name), - "initialized": _call(sim, "history_initialized", None, name), - }) + rows.append( + { + "name": name, + "depth": _call(sim, "history_depth", None, name), + "ncomp": _call(sim, "history_ncomp", None, name), + "initialized": _call(sim, "history_initialized", None, name), + } + ) return rows def _cache(sim: Any) -> Any: rows = [] for node_id in _call(sim, "program_cache_nodes", []) or []: - rows.append({ - "node_id": int(node_id), - "name": _call(sim, "program_cache_name", "", node_id), - "last_update_step": _call(sim, "program_cache_last_update_step", None, node_id), - "accumulated_dt": _call(sim, "program_cache_accumulated_dt", None, node_id), - }) + rows.append( + { + "node_id": int(node_id), + "name": _call(sim, "program_cache_name", "", node_id), + "last_update_step": _call(sim, "program_cache_last_update_step", None, node_id), + "accumulated_dt": _call(sim, "program_cache_accumulated_dt", None, node_id), + } + ) return rows -def _amr_temporal_report(sim: Any) -> tuple[Any, Any, Any, Any]: +def _amr_temporal_report(sim: Any) -> tuple[Any, Any, Any, Any, Any]: clocks = [] for row in _call(sim, "program_clock_manifest", []) or []: if row[0] == "level" and len(row) == 6: - clocks.append({ - "kind": "level", "level": int(row[1]), "macro_step": int(row[2]), - "phase": {"numerator": int(row[3]), "denominator": int(row[4])}, - "physical_time": float(row[5]), - }) + clocks.append( + { + "kind": "level", + "level": int(row[1]), + "macro_step": int(row[2]), + "phase": {"numerator": int(row[3]), "denominator": int(row[4])}, + "physical_time": float(row[5]), + } + ) elif row[0] == "logical" and len(row) == 3: clocks.append({"kind": "logical", "clock": row[1], "tick": int(row[2])}) else: @@ -173,33 +207,66 @@ def _amr_temporal_report(sim: Any) -> tuple[Any, Any, Any, Any]: for row in _call(sim, "checkpoint_temporal_relations", []) or []: if len(row) != 5: raise ValueError("native AMR temporal relation report has an invalid row") - relations.append({ - "parent_level": int(row[0]), "child_level": int(row[1]), - "temporal_ratio": {"numerator": int(row[2]), "denominator": int(row[3])}, - "remainder_policy": row[4], - }) + relations.append( + { + "parent_level": int(row[0]), + "child_level": int(row[1]), + "temporal_ratio": {"numerator": int(row[2]), "denominator": int(row[3])}, + "remainder_policy": row[4], + } + ) ledger = [] for row in _call(sim, "program_flux_ledger_manifest", []) or []: if len(row) != 13: raise ValueError("native AMR Program flux-ledger report has an invalid row") - ledger.append({ - "owner": row[0], "state": row[1], "rate": row[2], "flux": row[3], - "level": int(row[4]), "macro_step": int(row[5]), - "phase": {"numerator": int(row[6]), "denominator": int(row[7])}, - "stage_weight": {"numerator": int(row[8]), "denominator": int(row[9])}, - "orientation": row[10], "face_measure": float(row[11]), - "substep_duration": float(row[12]), - }) + ledger.append( + { + "owner": row[0], + "state": row[1], + "rate": row[2], + "flux": row[3], + "level": int(row[4]), + "macro_step": int(row[5]), + "phase": {"numerator": int(row[6]), "denominator": int(row[7])}, + "stage_weight": {"numerator": int(row[8]), "denominator": int(row[9])}, + "orientation": row[10], + "face_measure": float(row[11]), + "substep_duration": float(row[12]), + } + ) synchronization = [] for row in _call(sim, "program_sync_manifest", []) or []: if len(row) != 7: raise ValueError("native AMR Program synchronization report has an invalid row") - synchronization.append({ - "parent_level": int(row[0]), "child_level": int(row[1]), - "block": int(row[2]), "phase": row[3], "macro_step": int(row[4]), - "clock_phase": {"numerator": int(row[5]), "denominator": int(row[6])}, - }) - return clocks, relations, ledger, synchronization + synchronization.append( + { + "parent_level": int(row[0]), + "child_level": int(row[1]), + "block": int(row[2]), + "phase": row[3], + "macro_step": int(row[4]), + "clock_phase": {"numerator": int(row[5]), "denominator": int(row[6])}, + } + ) + temporal_partition = {} + for row in _call(sim, "program_temporal_partition_manifest", []) or []: + if row[0] == "summary" and len(row) == 7: + if temporal_partition: + raise ValueError("native temporal-partition report has duplicate summary rows") + temporal_partition = { + "kind": row[1], + "provider_identity": row[2], + "topology_epoch": int(row[3]), + "synchronization_tick": int(row[4]), + "tick_denominator": int(row[5]), + "cell_count": int(row[6]), + "rungs": [], + } + elif row[0] == "rung" and len(row) == 3 and temporal_partition: + temporal_partition["rungs"].append({"rung": int(row[1]), "cells": int(row[2])}) + else: + raise ValueError("native temporal-partition report has an invalid row") + return clocks, relations, ledger, synchronization, temporal_partition def build_program_report(sim: Any) -> Any: @@ -210,7 +277,7 @@ def build_program_report(sim: Any) -> Any: missing an accessor yields ``None`` for that field. """ program_hash = _call(sim, "installed_program_hash", "") or "" - clocks, relations, ledger, synchronization = _amr_temporal_report(sim) + clocks, relations, ledger, synchronization, temporal_partition = _amr_temporal_report(sim) temporal_state = getattr(sim, "_temporal_restart_state", None) temporal = temporal_state.to_data() if temporal_state is not None else {} return ProgramRuntimeReport( @@ -218,7 +285,8 @@ def build_program_report(sim: Any) -> Any: program_hash=program_hash, step_transaction=( sim._step_transaction_plan.to_data() - if getattr(sim, "_step_transaction_plan", None) is not None else {} + if getattr(sim, "_step_transaction_plan", None) is not None + else {} ), block_map=list(_call(sim, "program_block_map", []) or []), params=_params(sim), @@ -230,5 +298,6 @@ def build_program_report(sim: Any) -> Any: level_relations=relations, flux_ledger=ledger, synchronization=synchronization, + temporal_partition=temporal_partition, temporal=temporal, ) diff --git a/python/pops/runtime/routes.py b/python/pops/runtime/routes.py index aadad5e60..54abf6b5f 100644 --- a/python/pops/runtime/routes.py +++ b/python/pops/runtime/routes.py @@ -271,11 +271,14 @@ def route_registry_hash() -> str: RIEMANN_HLL = _REGISTRY["riemann"]["hll"] RIEMANN_HLLC = _REGISTRY["riemann"]["hllc"] RIEMANN_ROE = _REGISTRY["riemann"]["roe"] +RIEMANN_ROE_HLL_RUSANOV_RECOVERY = _REGISTRY["riemann"]["roe_hll_rusanov_recovery"] LIMITER_NONE = _REGISTRY["limiter"]["none"] LIMITER_MINMOD = _REGISTRY["limiter"]["minmod"] LIMITER_VANLEER = _REGISTRY["limiter"]["vanleer"] LIMITER_WENO5 = _REGISTRY["limiter"]["weno5"] +LIMITER_MC = _REGISTRY["limiter"]["mc"] +LIMITER_SUPERBEE = _REGISTRY["limiter"]["superbee"] RECON_CONSERVATIVE = _REGISTRY["recon"]["conservative"] RECON_PRIMITIVE = _REGISTRY["recon"]["primitive"] @@ -299,6 +302,22 @@ class _ModelRequirementPredicate: refusal: str +def _has_exact_riemann_provider(model: Any, capability: str) -> bool: + """Fail closed unless the model exposes authenticated provider evidence.""" + + from pops.numerics.riemann.providers import provider_evidence_of + + try: + evidence = provider_evidence_of(model) + except (TypeError, ValueError): + return False + if capability == "hllc": + return evidence.hllc_provider is not None + if capability == "roe": + return evidence.roe_provider is not None + raise ValueError("unknown Riemann provider capability %r" % capability) + + _RIEMANN_MODEL_REQUIREMENT_PREDICATES = MappingProxyType({ "wave_speeds": _ModelRequirementPredicate( lambda model: bool(getattr(model, "has_wave_speeds", False)), @@ -306,12 +325,12 @@ class _ModelRequirementPredicate: "typed axis (without pressure), or a primitive 'p' (m.primitive('p', ...))", ), "hllc_star_state": _ModelRequirementPredicate( - lambda model: bool(getattr(model, "has_hllc", False)), + lambda model: _has_exact_riemann_provider(model, "hllc"), "requires model capability 'hllc_star_state': call m.enable_hllc() on a generic model " "with fluid roles and primitive 'p'", ), "roe_dissipation": _ModelRequirementPredicate( - lambda model: bool(getattr(model, "has_roe", False)), + lambda model: _has_exact_riemann_provider(model, "roe"), "requires model capability 'roe_dissipation': call m.enable_roe(), " "m.roe_dissipation(...), or m.roe_from_jacobian(...) on the model", ), diff --git a/python/pops/solvers/elliptic/_prepared_field_providers.py b/python/pops/solvers/elliptic/_prepared_field_providers.py index 3c29c74a6..a5540df87 100644 --- a/python/pops/solvers/elliptic/_prepared_field_providers.py +++ b/python/pops/solvers/elliptic/_prepared_field_providers.py @@ -176,15 +176,26 @@ def _geometric_mg_resolver( def _validate_geometric_mg(use: Any, where: str) -> None: facts = use.facts + hierarchy = _hierarchy_policy_identity(facts, where=where) + levels = facts.layout.get("levels", 0) if use.options.get("fac") is not None and ( facts.target != "amr_system" - or _hierarchy_policy_identity(facts, where=where) - != _COMPOSITE_HIERARCHY_POLICY - or facts.layout.get("levels", 0) < 2 + or hierarchy != _COMPOSITE_HIERARCHY_POLICY + or levels < 2 ): raise ValueError( "%s authored CompositeFAC requires a composite multi-level AMR backend" % where ) + if ( + facts.target == "amr_system" + and levels > 1 + and hierarchy == _LEVEL_LOCAL_HIERARCHY_POLICY + and facts.boundary.get("iterate_dependent") + ): + raise ValueError( + "%s iterate-dependent multilevel AMR boundaries have no qualified nonlinear " + "transaction" % where + ) def _install_configured(context: Any, binding: Any) -> None: @@ -259,6 +270,11 @@ def _register_ready_providers() -> tuple[Any, Any]: "pops.field-hierarchy.level-local@1", "pops.field-hierarchy.composite@1", ), + "amr_boundary_dependencies": ( + "level-qualified-state@1", + "level-qualified-field@1", + "logical-timepoint@1", + ), }, _validate_geometric_mg, ), diff --git a/python/pops/time/_cadence.py b/python/pops/time/_cadence.py new file mode 100644 index 000000000..9a20d3329 --- /dev/null +++ b/python/pops/time/_cadence.py @@ -0,0 +1,59 @@ +"""Immutable macro-step cadence shared by Program authoring and graph IR.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +def _positive_int(value: Any, *, where: str) -> int: + if isinstance(value, bool) or type(value) is not int: + raise TypeError("%s must be an exact int" % where) + if value < 1: + raise ValueError("%s must be >= 1" % where) + return value + + +@dataclass(frozen=True, slots=True) +class ProgramCadence: + """Exact global Program executions within an accepted macro-step window.""" + + substeps: int = 1 + stride: int = 1 + + def __post_init__(self) -> None: + object.__setattr__( + self, + "substeps", + _positive_int(self.substeps, where="Program cadence substeps"), + ) + object.__setattr__( + self, + "stride", + _positive_int(self.stride, where="Program cadence stride"), + ) + + @property + def is_default(self) -> bool: + return self.substeps == 1 and self.stride == 1 + + def to_data(self) -> dict[str, int]: + return { + "schema_version": 1, + "substeps": self.substeps, + "stride": self.stride, + } + + @classmethod + def from_data(cls, data: Any) -> ProgramCadence: + if type(data) is not dict or set(data) != { + "schema_version", + "substeps", + "stride", + }: + raise TypeError("Program cadence data must contain the exact v1 schema") + if type(data["schema_version"]) is not int or data["schema_version"] != 1: + raise ValueError("Program cadence schema_version must be 1") + return cls(data["substeps"], data["stride"]) + + +__all__ = ["ProgramCadence"] diff --git a/python/pops/time/_graph/program.py b/python/pops/time/_graph/program.py index 42e11d64b..1fc22f305 100644 --- a/python/pops/time/_graph/program.py +++ b/python/pops/time/_graph/program.py @@ -11,6 +11,7 @@ from pops.time._graph.nodes import NODE_TYPES from pops.time._graph.validation import validate_nodes from pops.time.points import Clock +from pops.time._cadence import ProgramCadence GRAPH_NODE_TYPES = (*NODE_TYPES, Branch, Loop) @@ -23,9 +24,17 @@ class ProgramGraph: name: str clocks: tuple[Clock, ...] nodes: tuple[Any, ...] + cadence: ProgramCadence graph_hash: str - def __init__(self, name: str, nodes: Any, *, clocks: Any = None) -> None: + def __init__( + self, + name: str, + nodes: Any, + *, + clocks: Any = None, + cadence: Any = None, + ) -> None: object.__setattr__(self, "name", nonempty(name, where="ProgramGraph name")) frozen_nodes = tuple(nodes) if any(type(node) not in GRAPH_NODE_TYPES for node in frozen_nodes): @@ -38,6 +47,15 @@ def __init__(self, name: str, nodes: Any, *, clocks: Any = None) -> None: raise ValueError("ProgramGraph clocks must be unique") object.__setattr__(self, "clocks", declared) object.__setattr__(self, "nodes", frozen_nodes) + if cadence is None: + cadence = ProgramCadence() + elif isinstance(cadence, dict): + cadence = ProgramCadence.from_data(cadence) + if type(cadence) is not ProgramCadence: + raise TypeError( + "ProgramGraph cadence must be exact ProgramCadence data" + ) + object.__setattr__(self, "cadence", cadence) available: dict[int, Any] = {} validate_nodes(self.nodes, self.clocks, available, where="ProgramGraph") payload = json.dumps(self.to_data(), sort_keys=True, separators=(",", ":")) @@ -52,13 +70,16 @@ def ref(self, node: Any) -> ValueRef: return ValueRef(node.node_id) def to_data(self) -> dict[str, Any]: - return { + result = { "schema_version": 1, "kind": "pops.program-graph", "name": self.name, "clocks": [clock.to_data() for clock in self.clocks], "nodes": [node.to_data() for node in self.nodes], } + if not self.cadence.is_default: + result["cadence"] = self.cadence.to_data() + return result __all__ = ["ProgramGraph"] diff --git a/python/pops/time/_history/validation.py b/python/pops/time/_history/validation.py index bc1e81a55..b54fe1531 100644 --- a/python/pops/time/_history/validation.py +++ b/python/pops/time/_history/validation.py @@ -65,6 +65,7 @@ "hmin", "max_wave_speed", "record_scalar", + "record_balance_term", "reduce", "scalar_op", "compare", diff --git a/python/pops/time/_program/api.py b/python/pops/time/_program/api.py index 916f8df2b..b5de187c5 100644 --- a/python/pops/time/_program/api.py +++ b/python/pops/time/_program/api.py @@ -13,6 +13,7 @@ from pops.model.ownership import OwnerKind, OwnerPath from pops.time._program.contract import register_program_type +from pops.time._cadence import ProgramCadence from pops.time._program.authoring import _ProgramAuthoring from pops.time._program.condensed import _ProgramCondensed from pops.time._program.operations import _ProgramCore @@ -118,6 +119,12 @@ def __init__(self, name: Any) -> None: # ADC-666: explicit attempt controller. Runtime kwargs are validated against this descriptor; # a run-time CFL/dt/error-control option never silently selects a strategy. self._step_strategy = None + # The default executes once per accepted macro-step. A non-default cadence is an authored, + # immutable part of the Program identity and is installed before the runtime freezes. + self._cadence = None + # Optional bounded AMR cell-local execution authority. It is explicit, immutable after + # authoring and serialized into the Program hash; codegen never infers this route from dt. + self._cell_local_time = None self._transaction_stores = ALL_PROVISIONAL_STORES self._acceptance_guards = () # ADC-563 freeze: a Program is MUTABLE while authored and FROZEN by pops.compile. After @@ -218,6 +225,53 @@ def step_strategy( self._transaction_stores = stores return self + def cadence(self, *, substeps: Any = 1, stride: Any = 1) -> Any: + """Declare the global Program cadence once, before compile. + + ``stride`` accumulates accepted macro-step intervals and executes the Program when the + window closes. ``substeps`` divides that complete window into exact Program executions. + Off-cadence accepted steps sample-and-hold the last Program state. + """ + self._guard_mutable("set Program cadence") + if self._cadence is not None: + raise ValueError("Program.cadence may be declared only once") + self._cadence = ProgramCadence(substeps=substeps, stride=stride) + return self + + def cadence_contract(self) -> ProgramCadence: + """Return the immutable authored cadence, defaulting to one execution per macro-step.""" + cadence = self._cadence + if cadence is None: + return ProgramCadence() + if type(cadence) is not ProgramCadence: + raise TypeError("Program carries an invalid cadence contract") + return cadence + + def cell_local_time(self, *, tick_denominator: Any, rung: Any = 0) -> Any: + """Select the prepared cell-local AMR execution route. + + The current production provider is deliberately bounded to one host rank, one 2D block, + one level, one owned box and one common rung. Unsupported layouts fail during AMR install; + this method records only the exact integer time authority and never changes the Program IR. + """ + self._guard_mutable("set cell-local time contract") + if self._cell_local_time is not None: + raise ValueError("Program.cell_local_time may be declared only once") + from pops.time._program.cell_local_time import CellLocalTimeContract + + self._cell_local_time = CellLocalTimeContract( + tick_denominator=tick_denominator, rung=rung) + return self + + def cell_local_time_contract(self) -> Any: + """Return the authored cell-local contract, or ``None`` for global execution.""" + contract = self._cell_local_time + if contract is None: + return None + from pops.time._program.cell_local_time import require_cell_local_time_contract + + return require_cell_local_time_contract(contract) + def _register_acceptance_guard(self, guard: AcceptanceGuard) -> None: self._guard_mutable("register acceptance guard %r" % guard.name) if any(existing.name == guard.name for existing in self._acceptance_guards): diff --git a/python/pops/time/_program/authoring.py b/python/pops/time/_program/authoring.py index d295110cb..0cc1a4df7 100644 --- a/python/pops/time/_program/authoring.py +++ b/python/pops/time/_program/authoring.py @@ -359,6 +359,10 @@ def record_scalar(self, name: Any, value: Any) -> Any: to ``ctx.record_scalar("", )``.""" if not isinstance(name, str) or not name: raise ValueError("record_scalar: name must be a non-empty string") + if name.startswith("pops.balance-term"): + raise ValueError( + "record_scalar: pops.balance-term is reserved for Program.record_balance" + ) if not (isinstance(value, ProgramValue) and value.vtype == "scalar"): raise ValueError("record_scalar: value must be a Scalar value (e.g. P.norm2(R)); got %r" % (value,)) diff --git a/python/pops/time/_program/cell_local_time.py b/python/pops/time/_program/cell_local_time.py new file mode 100644 index 000000000..1621eca23 --- /dev/null +++ b/python/pops/time/_program/cell_local_time.py @@ -0,0 +1,40 @@ +"""Typed authoring contract for the bounded cell-local AMR execution route.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class CellLocalTimeContract: + """Exact integer clock selected for prepared cell-local AMR execution. + + This first production envelope intentionally exposes one common rung. The contract is still + explicit rather than inferred from ``dt`` so cache identity, checkpoint qualification and + unsupported-route diagnostics all observe the same authority. + """ + + tick_denominator: int + rung: int = 0 + + def __post_init__(self) -> None: + if type(self.tick_denominator) is not int or self.tick_denominator <= 0: + raise ValueError("Program.cell_local_time tick_denominator must be a positive int") + if type(self.rung) is not int or self.rung < 0 or self.rung > 30: + raise ValueError("Program.cell_local_time rung must be an int in [0, 30]") + + def to_data(self) -> dict[str, int]: + return { + "schema_version": 1, + "tick_denominator": self.tick_denominator, + "rung": self.rung, + } + + +def require_cell_local_time_contract(value: Any) -> CellLocalTimeContract: + if type(value) is not CellLocalTimeContract: + raise TypeError("Program carries an invalid cell-local time contract") + return value + + +__all__ = ["CellLocalTimeContract", "require_cell_local_time_contract"] diff --git a/python/pops/time/_program/constants.py b/python/pops/time/_program/constants.py index 767eedd44..dd1999f74 100644 --- a/python/pops/time/_program/constants.py +++ b/python/pops/time/_program/constants.py @@ -41,7 +41,8 @@ class _ProgramConstants: # Deliberately EXCLUDED (kept live): the buffer-writers schur_rhs / schur_explicit_flux / laplacian # / gradient / divergence / apply_laplacian_coeff / schur_coeffs / schur_reconstruct / schur_energy # (alias an input buffer); the side-effecting solve_fields[_from_blocks] / project / fill_boundary / - # store_history / record_scalar; solve_linear (reads its rhs by buffer identity); scalar_field / + # store_history / record_scalar / record_balance_term; solve_linear (reads its rhs by buffer + # identity); scalar_field / # state / history (scratch/state bindings other ops fill or alias); and the sub-block ops below. _REMOVABLE_OPS = frozenset({ "rhs", "source", "apply", "local_transform", "linear_combine", "linear_source", "solve_local_linear", diff --git a/python/pops/time/_program/contract.py b/python/pops/time/_program/contract.py index 1bc5ab26d..391f3e963 100644 --- a/python/pops/time/_program/contract.py +++ b/python/pops/time/_program/contract.py @@ -90,6 +90,8 @@ class _ProgramBase: _capture_source: bool _provenance_context: Any _step_strategy: Any + _cadence: Any + _cell_local_time: Any _transaction_stores: Any _acceptance_guards: tuple _frozen: bool @@ -144,6 +146,8 @@ def _region_for_block(self, block: Any) -> int: ... def _allow_region_capture(self, source: int, destination: int) -> None: ... def _register_acceptance_guard(self, guard: Any) -> None: ... def transaction_plan(self) -> Any: ... + def cadence(self, *, substeps: Any = 1, stride: Any = 1) -> Any: ... + def cadence_contract(self) -> Any: ... def state(self, state: Any, *, clock: Any = None) -> Any: ... def synchronize( self, value: Any, *, at: Any, relation: Any, name: Any = None @@ -198,6 +202,16 @@ def subcycle(self, state: Any, *, clock: Any, within: Any, def _compare(self, lhs: Any, rhs: Any, cmp: Any) -> Any: ... def _scalar_binop(self, a: Any, b: Any, fn: Any) -> Any: ... def record_scalar(self, name: Any, value: Any) -> Any: ... + def record_balance( + self, + ledger: Any, + *, + storage_change: Any, + outward_boundary_flux: Any, + sources: Any, + reflux: Any = None, + projection: Any = None, + ) -> tuple[Any, ...]: ... # --- solve / commit / board sugar (_ProgramSolve) --- def _solve_linear(self, *, operator: Any, rhs: Any, prepared: Any, properties: Any, @@ -231,6 +245,7 @@ def _live_value_ids(self) -> Any: ... def _rebuild(self, keep: Any, alias: Any = None, space_of: Any = None, **options: Any) -> Any: ... def _serialize(self) -> Any: ... def _ir_hash(self) -> Any: ... + def cell_local_time_contract(self) -> Any: ... def _block_indices(self) -> Any: ... def _validate_block(self, block: Any, outer_seen: Any) -> Any: ... def eliminate_dead_nodes(self) -> Any: ... diff --git a/python/pops/time/_program/diagnostics.py b/python/pops/time/_program/diagnostics.py index 86f61b4c5..16bf5a501 100644 --- a/python/pops/time/_program/diagnostics.py +++ b/python/pops/time/_program/diagnostics.py @@ -26,6 +26,136 @@ def record(self, name: Any, value: Any) -> ProgramValue: % (name, value)) return self.record_scalar(name, value) + @atomic_authoring + def record_balance( + self, + ledger: Any, + *, + storage_change: Any, + outward_boundary_flux: Any, + sources: Any, + reflux: Any = None, + projection: Any = None, + ) -> tuple[ProgramValue, ...]: + """Publish one exact five-term balance into the current native attempt. + + Every explicitly authored term is a signed, time-integrated increment for this Program + invocation and must be an additive global Program reduction (sum/dot), or scalar arithmetic + composed exclusively from such reductions and exact literals. A ledger that explicitly + delegates ``reflux`` or ``projection`` to its native producer requires the corresponding + argument to remain ``None``. The native mailbox accumulates all increments across cadence + substeps in the same public macro-step. Raw Python values, extrema/norm reductions, and + rank-local runtime scalars are rejected. The records are attempt-local: a rejected step or + consumer rollback cannot leave evidence for a later sample. + """ + from pops._balance_contract import ( + BALANCE_TERM_NAMES, + BalanceLedger, + balance_record_name, + ) + + if type(ledger) is not BalanceLedger: + raise TypeError( + "record_balance ledger must be an exact pops.diagnostics.BalanceLedger" + ) + supplied = { + "storage_change": storage_change, + "outward_boundary_flux": outward_boundary_flux, + "sources": sources, + "reflux": reflux, + "projection": projection, + } + + def require_reduced(value: Any, term: str, seen: set[int]) -> ProgramValue: + value = self._canonical_value(value) + if not isinstance(value, ProgramValue) or value.prog is not self \ + or value.vtype != "scalar": + raise TypeError( + "record_balance %s must be a scalar from this Program" % term + ) + if value.id in seen: + return value + seen.add(value.id) + if value.op == "reduce": + if value.attrs.get("kind") not in {"sum", "dot"}: + raise ValueError( + "record_balance %s requires additive sum/dot reductions; got %r" + % (term, value.attrs.get("kind")) + ) + return value + if value.op == "scalar_op" and value.inputs: + for item in value.inputs: + require_reduced(item, term, seen) + return value + raise ValueError( + "record_balance %s must be a global reduction or arithmetic composed " + "only from global reductions; got scalar op %r" % (term, value.op) + ) + + automatic = set(ledger.automatic_terms) + for name in automatic: + if supplied[name] is not None: + raise ValueError( + "record_balance %s is owned by the ledger's native automatic producer; " + "leave it as None" % name + ) + terms = { + name: require_reduced(supplied[name], name, set()) + for name in BALANCE_TERM_NAMES + if name not in automatic + } + if automatic: + expected_component = ledger.component + + def reduced_components( + value: ProgramValue, term: str, seen: set[int] + ) -> set[int]: + if value.id in seen: + return set() + seen.add(value.id) + if value.op == "reduce": + component = value.attrs.get("comp") + if value.attrs.get("kind") != "sum" or type(component) is not int: + raise ValueError( + "record_balance %s must use component-qualified sum reductions " + "when native terms are selected" % term + ) + return {component} + components: set[int] = set() + for item in value.inputs: + components.update(reduced_components(item, term, seen)) + return components + + for name, value in terms.items(): + components = reduced_components(value, name, set()) + if components != {expected_component}: + raise ValueError( + "record_balance %s selects components %s but the native ledger owns " + "component %d" % (name, sorted(components), expected_component) + ) + blocks = {value.block for value in terms.values()} + if None in blocks or len(blocks) != 1: + raise ValueError( + "record_balance terms must reduce one exact common physics block" + ) + route = ledger.route_identity(next(iter(blocks))) + return tuple( + self._new( + "scalar", + "record_balance_term", + (terms[name],), + { + "diagnostic": balance_record_name(route, name), + "route": route.token, + "term": name, + }, + balance_record_name(route, name), + terms[name].block, + ) + for name in BALANCE_TERM_NAMES + if name in terms + ) + @atomic_authoring def check_invariant(self, name: Any, before: Any = None, after: Any = None, tolerance: Any = 1e-10) -> ProgramValue: diff --git a/python/pops/time/_program/graph_conversion.py b/python/pops/time/_program/graph_conversion.py index e599da864..3b14141b9 100644 --- a/python/pops/time/_program/graph_conversion.py +++ b/python/pops/time/_program/graph_conversion.py @@ -347,6 +347,7 @@ def convert_values(values: Any) -> list[Any]: detached.name, nodes, clocks=_declared_clocks(nodes, detached.clock), + cadence=detached.cadence_contract(), ) # Detachment and graph conversion are read-only; authoring identity remains stable. if detached._ir_hash() != program._ir_hash(): diff --git a/python/pops/time/_program/passes.py b/python/pops/time/_program/passes.py index 9b974ccad..e2fe22fed 100644 --- a/python/pops/time/_program/passes.py +++ b/python/pops/time/_program/passes.py @@ -115,7 +115,7 @@ def eliminate_dead_nodes(self) -> Any: scalar_op, compare) AND no live op consumes its result. EVERY other op -- the buffer-writers that alias a caller-allocated input buffer (schur_rhs, laplacian, gradient, divergence, schur_*), the side-effecting ops (solve_fields, project, fill_boundary, store_history, - record_scalar), solve_linear, and the sub-block-owning ops (while/if/range, + record_scalar, record_balance_term), solve_linear, and the sub-block-owning ops (while/if/range, matrix_free_operator, solve_local_nonlinear) -- is treated as LIVE even when its result looks unconsumed, so an unknown/new op is NEVER wrongly dropped. The live set is reverse-reachability from the commits plus those non-removable nodes. The surviving nodes are renumbered to diff --git a/python/pops/time/_program/rebuild.py b/python/pops/time/_program/rebuild.py index 0a9fccc9d..d36db6e5f 100644 --- a/python/pops/time/_program/rebuild.py +++ b/python/pops/time/_program/rebuild.py @@ -84,6 +84,8 @@ def _keep_registry(_owner: Any) -> bool: object.__setattr__(out, "clock", Clock("macro", owner=out.owner_path)) out.dt = self.dt out._step_strategy = getattr(self, "_step_strategy", None) + out._cadence = getattr(self, "_cadence", None) + out._cell_local_time = getattr(self, "_cell_local_time", None) out._transaction_stores = tuple(getattr(self, "_transaction_stores", ())) out._acceptance_guards = tuple(getattr(self, "_acceptance_guards", ())) if project_states and (self._dt_bound is not None or out._acceptance_guards): diff --git a/python/pops/time/_program/serialization.py b/python/pops/time/_program/serialization.py index e74f4a736..e3c1fd109 100644 --- a/python/pops/time/_program/serialization.py +++ b/python/pops/time/_program/serialization.py @@ -213,6 +213,12 @@ def _serialize(self, *, include_provenance: bool = True) -> dict[str, Any]: transaction = self.transaction_plan() if transaction is not None: result["step_transaction"] = transaction.to_data() + cadence = self.cadence_contract() + if not cadence.is_default: + result["cadence"] = cadence.to_data() + cell_local_time = self.cell_local_time_contract() + if cell_local_time is not None: + result["cell_local_time"] = cell_local_time.to_data() if self._histories: result["histories"] = [ { diff --git a/python/pops/time/_schedule/api.py b/python/pops/time/_schedule/api.py index ea9aaea52..2e1820421 100644 --- a/python/pops/time/_schedule/api.py +++ b/python/pops/time/_schedule/api.py @@ -109,6 +109,14 @@ def consumer_due(self, coordinate: int, moment: Any) -> bool: "schedule trigger %s does not implement consumer_due()" % type(self).__name__ ) + def consumer_may_fire_at_start(self) -> bool: + """Whether this trigger can publish before the first accepted step. + + Unknown extension triggers are conservatively start-capable until they override this + planning capability. This lets accepted-step-only consumers fail closed at bind time. + """ + return True + def consumer_next_deadline(self, *, physical_time_hex: str) -> str | None: """Return the next hard physical-time boundary, if this trigger owns one. @@ -145,6 +153,9 @@ def consumer_due(self, coordinate: int, moment: Any) -> bool: del coordinate return not moment.at_start + def consumer_may_fire_at_start(self) -> bool: + return False + @stable_component_identity("pops://time/schedule/triggers/every") @dataclass(frozen=True, slots=True) @@ -169,6 +180,9 @@ def schedule_params(self) -> dict[str, Any]: def consumer_due(self, coordinate: int, moment: Any) -> bool: return not moment.at_start and coordinate % self.n == 0 + def consumer_may_fire_at_start(self) -> bool: + return False + def _canonical_binary64(value: Any, *, where: str, positive: bool = False) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): @@ -273,6 +287,9 @@ def consumer_due(self, coordinate: int, moment: Any) -> bool: target = _every_dt_lattice_time(nearest, self.interval) return math.isfinite(target) and now >= target and _same_physical_time(now, target) + def consumer_may_fire_at_start(self) -> bool: + return False + def consumer_occurrence_evidence( self, coordinate: int, moment: Any, ) -> dict[str, Any] | None: @@ -324,6 +341,9 @@ def consumer_due(self, coordinate: int, moment: Any) -> bool: del coordinate return moment.at_start + def consumer_may_fire_at_start(self) -> bool: + return True + @stable_component_identity("pops://time/schedule/triggers/at-end") @dataclass(frozen=True, slots=True) @@ -338,6 +358,9 @@ def consumer_due(self, coordinate: int, moment: Any) -> bool: del coordinate return not moment.at_start and moment.at_end + def consumer_may_fire_at_start(self) -> bool: + return False + @stable_component_identity("pops://time/schedule/triggers/when") @dataclass(frozen=True, slots=True) @@ -360,6 +383,9 @@ def consumer_due(self, coordinate: int, moment: Any) -> bool: raise UnresolvedScheduleCondition(self.condition) return self.condition + def consumer_may_fire_at_start(self) -> bool: + return False + @stable_component_identity("pops://time/schedule/off-policy") @dataclass(frozen=True, slots=True) @@ -518,6 +544,14 @@ def is_always(self) -> bool: raise TypeError("Trigger.is_always() must return an exact bool") return result + def consumer_may_fire_at_start(self) -> bool: + result = self.trigger.consumer_may_fire_at_start() + if type(result) is not bool: + raise TypeError( + "Trigger.consumer_may_fire_at_start() must return an exact bool" + ) + return result + def needs_cache(self) -> bool: if self.off is None: return False diff --git a/python/pops/time/points.py b/python/pops/time/points.py index 00c9737bc..9bfdd93f7 100644 --- a/python/pops/time/points.py +++ b/python/pops/time/points.py @@ -53,6 +53,21 @@ def to_data(self) -> dict[str, Any]: "owner": self.owner.to_data() if self.owner is not None else None, } + @classmethod + def from_data(cls, data: Any) -> Clock: + """Strict inverse of :meth:`to_data` for data-only clock consumers.""" + required = {"schema_version", "name", "owner"} + if not isinstance(data, Mapping) or set(data) != required: + raise TypeError("Clock data has an unsupported shape") + if data["schema_version"] != 1: + raise ValueError("Clock data uses an unsupported schema version") + owner_data = data["owner"] + owner = None if owner_data is None else OwnerPath.from_data(owner_data) + result = cls(data["name"], owner=owner) + if result.to_data() != dict(data): + raise ValueError("Clock data is not canonical") + return result + @dataclass(frozen=True, slots=True, init=False) class TimePoint: diff --git a/schemas/component_catalog.v2.json b/schemas/component_catalog.v2.json index 1860f366f..c0596163c 100644 --- a/schemas/component_catalog.v2.json +++ b/schemas/component_catalog.v2.json @@ -1,7 +1,7 @@ { "catalog_schema_version": 1, "component_manifest_schema_version": 2, - "route_registry_version": 2, + "route_registry_version": 3, "capability_vocabulary_version": 4, "interface_vocabulary": [ { @@ -158,6 +158,16 @@ "facets": ["stencil", "lowering"], "operations": ["apply"] }, + { + "id": 6, + "name": "reflux", + "uri": "pops://interfaces/reflux", + "version": 1, + "cpp_table": "PopsRefluxApiV1", + "hot_path": true, + "facets": ["stencil", "lowering", "effects"], + "operations": ["apply_interface_batch"] + }, { "id": 7, "name": "field_solver", @@ -187,6 +197,16 @@ "hot_path": false, "facets": ["provider", "report"], "operations": ["prepare_topology"] + }, + { + "id": 10, + "name": "boundary_flux", + "uri": "pops://interfaces/boundary-flux", + "version": 1, + "cpp_table": "PopsBoundaryFluxApiV1", + "hot_path": true, + "facets": ["provider", "lowering", "fallible_evaluation"], + "operations": ["transform_faces"] } ], "boundary_handle_native_routes": { @@ -206,6 +226,10 @@ "interface": "numerical_flux", "operation": "evaluate_faces" }, + "boundary_flux_provider": { + "interface": "boundary_flux", + "operation": "transform_faces" + }, "residual_operator": { "interface": "field_boundary_closure", "operation": "residual" @@ -478,15 +502,13 @@ "contact_speed", "hllc_star_state" ], - "limitations": [ - "polar metric provider not wired; requires exact HasHLLCStructure capability" - ], + "limitations": [], "aliases": [], "metadata": { "needs_wave_speeds": false, "needs_hllc_struct": true, "needs_roe_diss": false, - "polar_ok": false + "polar_ok": true } }, { @@ -500,12 +522,34 @@ "stability_bound", "roe_dissipation" ], + "limitations": [], + "aliases": [], + "metadata": { + "needs_wave_speeds": false, + "needs_hllc_struct": false, + "needs_roe_diss": true, + "polar_ok": true + } + }, + { + "token": "roe_hll_rusanov_recovery", + "wire_id": 4, + "cpp_id": "kRoeHllRusanovRecovery", + "native_entry": "pops::PreparedRiemannRecoveryPolicy", + "requirements": [ + "physical_flux", + "provider_pack", + "stability_bound", + "wave_speeds", + "roe_dissipation" + ], "limitations": [ - "polar metric provider not wired; requires exact HasRoeDissipation capability" + "fixed ordered policy Roe -> HLL -> Rusanov -> reject", + "annular polar route unavailable" ], "aliases": [], "metadata": { - "needs_wave_speeds": false, + "needs_wave_speeds": true, "needs_hllc_struct": false, "needs_roe_diss": true, "polar_ok": false @@ -575,6 +619,34 @@ "formal_order": 5, "muscl_compatible": false } + }, + { + "token": "mc", + "wire_id": 4, + "cpp_id": "kMc", + "native_entry": "pops::MC", + "requirements": [], + "limitations": [], + "aliases": [], + "metadata": { + "n_ghost": 2, + "formal_order": 2, + "muscl_compatible": true + } + }, + { + "token": "superbee", + "wire_id": 5, + "cpp_id": "kSuperbee", + "native_entry": "pops::Superbee", + "requirements": [], + "limitations": [], + "aliases": [], + "metadata": { + "n_ghost": 2, + "formal_order": 2, + "muscl_compatible": true + } } ] }, diff --git a/schemas/release_contract.v1.json b/schemas/release_contract.v2.json similarity index 81% rename from schemas/release_contract.v1.json rename to schemas/release_contract.v2.json index 3ef6d33b4..0913eda56 100644 --- a/schemas/release_contract.v1.json +++ b/schemas/release_contract.v2.json @@ -1,17 +1,19 @@ { - "release_contract_schema_version": 1, + "release_contract_schema_version": 2, "public_api_version": 1, "semantic_ir_version": 1, "normalization_version": 1, "component_catalog_schema_version": 1, "component_manifest_schema_version": 2, - "component_registry_version": 2, + "component_registry_version": 3, "capability_vocabulary_version": 4, "component_interface_abi_version": 1, "native_abi_version": 3, "checkpoint_envelope_schema_version": 1, "uniform_checkpoint_payload_version": 5, "amr_checkpoint_payload_version": 7, + "component_catalog_sha256": "b8801b403645d62afd4e9ea0dd92af8124f042f359aba9ad09ffa4ea6f4a8a66", + "component_catalog_semantic_sha256": "b4cab25a04533f5ebfec12d1814688b1cb81f9cc5e4473ed40bcfa553d8403f3", "supported_matrix": { "language": { "python": ["3.12"], diff --git a/scripts/ci_select_tests.py b/scripts/ci_select_tests.py index cd6c3fa93..8d40dcbfa 100755 --- a/scripts/ci_select_tests.py +++ b/scripts/ci_select_tests.py @@ -846,7 +846,11 @@ def validate_cpp_duration_catalogs(targets: Iterable[str]) -> None: raise SystemExit(f"C++ {label} duration catalog is invalid: {exc}") from exc catalogues.append((label, durations)) failures: list[str] = [] - for label, durations in catalogues: + for (label, durations), path in zip( + catalogues, + (CPP_BUILD_DURATIONS_JSON, CPP_DURATIONS_JSON), + strict=True, + ): actual = set(durations) missing = sorted(expected - actual) orphaned = sorted(actual - expected) @@ -857,6 +861,31 @@ def validate_cpp_duration_catalogs(targets: Iterable[str]) -> None: failures.append(f"{label} orphaned={orphaned}") if non_positive: failures.append(f"{label} non-positive={non_positive}") + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + failures.append(f"{label} metadata unreadable={exc}") + continue + metadata = raw.get("_meta") + if not isinstance(metadata, dict): + failures.append(f"{label} metadata missing") + continue + target_count = metadata.get("target_count") + if type(target_count) is not int or target_count != len(durations): + failures.append( + f"{label} target_count={target_count!r} expected={len(durations)}" + ) + estimated = metadata.get("estimated_targets") + if ( + not isinstance(estimated, list) + or any(not isinstance(name, str) or not name for name in estimated) + or estimated != sorted(set(estimated)) + ): + failures.append(f"{label} estimated_targets must be sorted unique names") + elif not set(estimated) <= actual: + failures.append( + f"{label} estimated_targets orphaned={sorted(set(estimated) - actual)}" + ) if failures: raise SystemExit("C++ duration catalog inventory mismatch: " + "; ".join(failures)) @@ -1009,7 +1038,8 @@ def verify_cpp_target_labels(args: argparse.Namespace) -> int: once rather than silently dropping them. """ targets = list(dict.fromkeys(args.targets)) - if not targets: + standalone_regex_file = getattr(args, "standalone_regex_file", None) + if not targets and not standalone_regex_file: raise SystemExit("C++ target-label verification requires at least one target") tests = _read_ctest_inventory(args.ctest_json) @@ -1072,7 +1102,6 @@ def verify_cpp_target_labels(args: argparse.Namespace) -> int: + details ) - standalone_regex_file = getattr(args, "standalone_regex_file", None) if standalone_regex_file: escaped = [re.escape(name) for name in standalone] standalone_regex = ( @@ -1807,7 +1836,7 @@ def main() -> int: cpp_target_labels = sub.add_parser("verify-cpp-target-labels") cpp_target_labels.add_argument("--ctest-json", required=True) - cpp_target_labels.add_argument("--targets", nargs="+", required=True) + cpp_target_labels.add_argument("--targets", nargs="*", required=True) cpp_target_labels.add_argument("--standalone-regex-file") cpp_target_labels.set_defaults(func=verify_cpp_target_labels) diff --git a/scripts/final_release_contract.py b/scripts/final_release_contract.py index 39658bcde..9995b659d 100644 --- a/scripts/final_release_contract.py +++ b/scripts/final_release_contract.py @@ -8,8 +8,10 @@ from __future__ import annotations +import ast import json from pathlib import Path +import tomllib FINAL_SPECIFICATION = Path("docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md") @@ -19,6 +21,79 @@ Path("examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py"), Path("examples/final/EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py"), ) +FINAL_EXAMPLE_ACCEPTANCE_TESTS = ( + "tests/python/integration/bindings/test_m1_scalar_advection_pipeline.py" + "::test_scalar_advection_final_example_runs_outputs_and_bit_identical_restart", + "tests/python/examples/final/test_multiphysics_core_example.py" + "::test_example_script_runs_outputs_and_restart_without_mock_or_fallback", + "tests/python/examples/final/test_imex_amr_final_example.py" + "::test_example_runs_and_every_scientific_format_reopens", + "tests/python/examples/final/test_hyqmom15_final_example.py" + "::test_hyqmom15_example_runs_outputs_and_restarts_bit_identically", +) +FINAL_EXAMPLE_QUALIFICATION_TESTS = ( + "tests/python/examples/final/test_scalar_advection_final_example.py" + "::test_target_has_one_authority_per_concern_and_no_legacy_path", + "tests/python/examples/final/test_multiphysics_core_example.py" + "::test_program_has_exact_field_context_and_transactional_implicit_join", + "tests/python/examples/final/test_imex_amr_final_example.py" + "::test_resolved_amr_lowering_report_covers_every_executed_authority", + "tests/python/unit/moments/test_hyqmom15_final_contract.py" + "::test_particle_number_diagnostic_integrates_m00_and_rejects_drift", +) +FINAL_EXAMPLE_REQUIRED_TESTS = ( + *FINAL_EXAMPLE_ACCEPTANCE_TESTS, + *FINAL_EXAMPLE_QUALIFICATION_TESTS, +) +FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS = { + FINAL_EXAMPLES[0]: { + "hdf5": ({ + "consumer_target": "state/tracer", + "artifact_root": "manual/accepted/state/tracer", + },), + "npz": (), + "paraview": ({ + "consumer_target": "solution/tracer", + "artifact_root": "manual/accepted/solution/tracer", + },), + }, + FINAL_EXAMPLES[1]: { + "hdf5": ({ + "consumer_target": "state/two_fluid", + "artifact_root": "accepted/state/two_fluid", + },), + "npz": (), + "paraview": ({ + "consumer_target": "visualization/two_fluid", + "artifact_root": "accepted/visualization/two_fluid", + },), + }, + FINAL_EXAMPLES[2]: { + "hdf5": ({ + "consumer_target": "hdf5/state", + "artifact_root": "manual/accepted/hdf5/state", + },), + "npz": ({ + "consumer_target": "npz/state", + "artifact_root": "manual/accepted/npz/state", + },), + "paraview": ({ + "consumer_target": "paraview/state", + "artifact_root": "manual/accepted/paraview/state", + },), + }, + FINAL_EXAMPLES[3]: { + "hdf5": ({ + "consumer_target": "state/hyqmom15", + "artifact_root": "accepted/state/hyqmom15", + },), + "npz": (), + "paraview": ({ + "consumer_target": "visualization/hyqmom15", + "artifact_root": "accepted/visualization/hyqmom15", + },), + }, +} REQUIRED_PROOF_MARKERS = ( "HDF5:", "ParaView:", @@ -32,9 +107,15 @@ "pops.runtime.integrate", "CartesianMesh", ) -# The published wheel matrix is CPU/Kokkos Serial without MPI or parallel HDF5. The full suite still -# runs; this supported-platform subset is repeated with a strict all-pass/no-hidden-skip policy. -PYTHON_REQUIRED_SELECTION = "not mpi and not hdf5" +# The complete source suite is authenticated by the release workflow's ``full-source-matrix`` job. +# The exact published wheel repeats the closed M4 Python ledger plus the final-example ledger; it +# must not serialize the complete suite a second time under a short release timeout. +PYTHON_CONFORMANCE_MANIFEST = Path("tests/gates/m4_runtime_io.toml") +PYTHON_REQUIRED_SELECTION = "m4-runtime-io-pytest+final-example-ledger" +INSTALLED_COMPONENT_PACKAGE_NODEID = ( + "tests/python/integration/native_loader/test_external_component_package.py" + "::test_source_component_executes_through_generic_native_loader_and_flux_consumer" +) REQUIRED_RELEASE_GATES = ( "official_build", "installed_wheel", @@ -51,6 +132,49 @@ ) +def required_python_conformance_nodeids(root: Path) -> tuple[str, ...]: + """Return the exact installed-wheel Python ledger for the final gate. + + MPI-only rows stay proved by ``full-source-matrix`` because the published wheel is Serial. + The external component row is executed separately with checkout headers explicitly cleared, + which is a strictly stronger installed-wheel proof than repeating it in the main lane. + """ + + path = root / PYTHON_CONFORMANCE_MANIFEST + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise ValueError("cannot read final Python conformance manifest: %s" % exc) from exc + if data.get("schema_version") != 1 or data.get("gate") != "m4-runtime-io": + raise ValueError("final Python conformance manifest identity drifted") + if data.get("deferred") != []: + raise ValueError("final Python conformance manifest must be closed") + checks = data.get("check") + if not isinstance(checks, list) or not checks: + raise ValueError("final Python conformance manifest has no executable checks") + + nodeids: list[str] = [] + manifest_nodeids: set[str] = set() + for row in checks: + if not isinstance(row, dict): + raise ValueError("final Python conformance manifest contains a malformed row") + if row.get("kind") != "pytest": + continue + nodeid = row.get("nodeid") + if not isinstance(nodeid, str) or "::" not in nodeid: + raise ValueError("final Python conformance manifest contains an invalid pytest nodeid") + if nodeid in manifest_nodeids: + raise ValueError("final Python conformance manifest contains duplicate pytest nodeids") + manifest_nodeids.add(nodeid) + if nodeid != INSTALLED_COMPONENT_PACKAGE_NODEID \ + and nodeid not in FINAL_EXAMPLE_REQUIRED_TESTS: + nodeids.append(nodeid) + nodeids.extend(FINAL_EXAMPLE_REQUIRED_TESTS) + if len(nodeids) != len(set(nodeids)): + raise ValueError("final Python conformance ledger contains duplicate nodeids") + return tuple(nodeids) + + def release_matrix_source_errors(root: Path) -> list[str]: """Return drift between the declared support matrix and its executable workflow proof. @@ -60,7 +184,7 @@ def release_matrix_source_errors(root: Path) -> list[str]: """ errors: list[str] = [] - contract_path = root / "schemas" / "release_contract.v1.json" + contract_path = root / "schemas" / "release_contract.v2.json" try: contract = json.loads(contract_path.read_text(encoding="utf-8")) matrix = contract["supported_matrix"] @@ -324,6 +448,8 @@ def source_contract_errors(root: Path) -> list[str]: expected = tuple(sorted(FINAL_EXAMPLES)) if actual != expected: errors.append("final examples must be exactly %s (found %s)" % (expected, actual)) + if set(FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS) != set(FINAL_EXAMPLES): + errors.append("final scientific-output ledger must cover exactly the final examples") for relative in FINAL_EXAMPLES: path = root / relative @@ -342,6 +468,192 @@ def source_contract_errors(root: Path) -> list[str]: errors.append( "%s imports transitional/internal authoring names %s" % (relative, forbidden) ) + formats = FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS.get(relative) + if not isinstance(formats, dict) or set(formats) != {"hdf5", "npz", "paraview"}: + errors.append("%s has no exact scientific-output format ledger" % relative) + continue + artifact_roots: set[str] = set() + for format_name, expectations in formats.items(): + if not isinstance(expectations, tuple): + errors.append( + "%s has a malformed %s scientific-output target ledger" + % (relative, format_name) + ) + continue + consumer_targets: set[str] = set() + for expectation in expectations: + if not isinstance(expectation, dict) or set(expectation) != { + "consumer_target", "artifact_root", + }: + errors.append( + "%s has a malformed %s scientific-output expectation" + % (relative, format_name) + ) + continue + consumer_target = expectation["consumer_target"] + artifact_root = expectation["artifact_root"] + if not isinstance(consumer_target, str) or not consumer_target \ + or not isinstance(artifact_root, str) or not artifact_root: + errors.append( + "%s has an invalid %s scientific-output expectation" + % (relative, format_name) + ) + continue + consumer_path = Path(consumer_target) + artifact_path = Path(artifact_root) + if consumer_path.is_absolute() or ".." in consumer_path.parts \ + or artifact_path.is_absolute() or ".." in artifact_path.parts: + errors.append( + "%s has an escaping %s scientific-output expectation" + % (relative, format_name) + ) + continue + if len(artifact_path.parts) < len(consumer_path.parts) or tuple( + artifact_path.parts[-len(consumer_path.parts):] + ) != consumer_path.parts: + errors.append( + "%s %s artifact root %s does not end with consumer target %s" + % (relative, format_name, artifact_root, consumer_target) + ) + continue + if consumer_target in consumer_targets or artifact_root in artifact_roots: + errors.append( + "%s has duplicate %s scientific-output expectations" + % (relative, format_name) + ) + continue + consumer_targets.add(consumer_target) + artifact_roots.add(artifact_root) + if 'target="%s"' % consumer_target not in text: + errors.append( + "%s lacks its exact %s scientific-output target %s" + % (relative, format_name, consumer_target) + ) + ledgers = ( + ("acceptance", FINAL_EXAMPLE_ACCEPTANCE_TESTS), + ("qualification", FINAL_EXAMPLE_QUALIFICATION_TESTS), + ) + required_nodeids = tuple(nodeid for _kind, ledger in ledgers for nodeid in ledger) + if len(set(required_nodeids)) != len(required_nodeids): + errors.append("final-example required test nodeids must be unique") + for proof_kind, ledger in ledgers: + if len(ledger) != len(FINAL_EXAMPLES): + errors.append( + "final examples and exact %s tests must have one-to-one coverage" + % proof_kind + ) + for example, nodeid in zip(FINAL_EXAMPLES, ledger, strict=False): + relative, separator, function_name = nodeid.partition("::") + if not separator or not relative or not function_name: + errors.append("invalid final-example %s nodeid %r" % (proof_kind, nodeid)) + continue + test_path = root / relative + if not test_path.is_file(): + errors.append("missing final-example %s test: %s" % (proof_kind, nodeid)) + continue + source = test_path.read_text(encoding="utf-8") + try: + tree = ast.parse(source, filename=str(test_path)) + except SyntaxError as exc: + errors.append( + "cannot parse final-example %s test %s: %s" + % (proof_kind, nodeid, exc) + ) + continue + functions = [ + node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == function_name + ] + if len(functions) != 1: + errors.append( + "final-example %s nodeid must resolve exactly once: %s" + % (proof_kind, nodeid) + ) + continue + function = functions[0] + fixture_names = { + argument.arg + for argument in ( + *function.args.posonlyargs, + *function.args.args, + *function.args.kwonlyargs, + ) + } + if fixture_names & {"mock", "mocker", "monkeypatch", "patch"}: + errors.append("%s uses a mock fixture" % nodeid) + forbidden_calls = [] + forbidden_imports = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and ( + node.module or "" + ).startswith(("unittest.mock", "pytest_mock")): + forbidden_imports.append(node.module or "") + elif isinstance(node, ast.Import) and any( + alias.name.startswith(("unittest.mock", "pytest_mock")) + for alias in node.names + ): + forbidden_imports.extend(alias.name for alias in node.names) + for node in ast.walk(function): + if not isinstance(node, ast.Call): + continue + call = node.func + parts = [] + while isinstance(call, ast.Attribute): + parts.append(call.attr) + call = call.value + if isinstance(call, ast.Name): + parts.append(call.id) + name = ".".join(reversed(parts)) + if name in { + "patch", + "pytest.importorskip", + "pytest.skip", + "pytest.xfail", + } or name.startswith(("mock.", "mocker.", "unittest.mock.")): + forbidden_calls.append(name) + decorators = [] + for decorator in function.decorator_list: + text = ast.unparse(decorator) + if "skip" in text or "xfail" in text: + decorators.append(text) + module_markers = [] + for statement in tree.body: + value = None + targets = () + if isinstance(statement, ast.Assign): + value = statement.value + targets = statement.targets + elif isinstance(statement, ast.AnnAssign): + value = statement.value + targets = (statement.target,) + if value is not None and any( + isinstance(target, ast.Name) and target.id == "pytestmark" + for target in targets + ): + text = ast.unparse(value) + if "skip" in text or "xfail" in text: + module_markers.append(text) + if forbidden_calls or forbidden_imports or decorators or module_markers: + errors.append( + "%s is optional: %s" + % ( + nodeid, + sorted( + set( + ( + *forbidden_calls, + *forbidden_imports, + *decorators, + *module_markers, + ) + ) + ), + ) + ) + if example.name not in source: + errors.append("%s is not bound to %s" % (nodeid, example)) return errors diff --git a/scripts/generate_component_catalog.py b/scripts/generate_component_catalog.py index 90d861deb..65604ef7b 100644 --- a/scripts/generate_component_catalog.py +++ b/scripts/generate_component_catalog.py @@ -649,6 +649,22 @@ def _render_component_abi(catalog: dict[str, Any], digest: str) -> str: % (row["name"].upper(), row["version"], row["cpp_table"]) for row in catalog["native_interface_abis"] ) + table_complete_rows = "\n".join( + """ case POPS_NATIVE_INTERFACE_%s_V%d: { + if (table_size < sizeof(%s)) return false; + const auto* api = static_cast(table); + return %s; + }""" + % ( + row["name"].upper(), + row["version"], + row["cpp_table"], + row["cpp_table"], + " && ".join("api->%s != nullptr" % operation + for operation in row["operations"]), + ) + for row in catalog["native_interface_abis"] + ) return f'''#pragma once // Generated by scripts/generate_component_catalog.py; DO NOT EDIT. @@ -925,6 +941,35 @@ def _render_component_abi(catalog: dict[str, Any], digest: str) -> str: PopsApplyRegionBatchFnV1 apply_region_batch; }} PopsGhostBoundaryApiV1; +typedef struct PopsBoundaryFluxRequestV1 {{ + uint32_t struct_size; + const char* provider_identity; + const char* state_identity; + PopsConstFieldViewV1 base_outward_normal_flux; + PopsConstFieldViewV1 coordinates; + PopsConstFieldViewV1 outward_normals; + const double* face_measures; + PopsBoundaryRegionV1 region; + size_t dependency_count; + const PopsQualifiedConstFieldV1* dependencies; + size_t parameter_count; + const PopsQualifiedScalarV1* parameters; + PopsLogicalTimeV1 logical_time; + PopsExecutionContextV1 execution; +}} PopsBoundaryFluxRequestV1; +typedef struct PopsBoundaryFluxResultV1 {{ + uint32_t struct_size; + PopsFieldViewV1 outward_normal_flux; + PopsComponentActionV1* actions; + PopsComponentStatusV1 status; +}} PopsBoundaryFluxResultV1; +typedef int32_t (*PopsTransformBoundaryFacesFnV1)( + void*, const PopsBoundaryFluxRequestV1*, PopsBoundaryFluxResultV1*); +typedef struct PopsBoundaryFluxApiV1 {{ + PopsComponentTableHeaderV1 header; + PopsTransformBoundaryFacesFnV1 transform_faces; +}} PopsBoundaryFluxApiV1; + typedef struct PopsFieldBoundaryRequestV1 {{ uint32_t struct_size; const char* closure_identity; @@ -1081,6 +1126,41 @@ def _render_component_abi(catalog: dict[str, Any], digest: str) -> str: PopsTransferApplyFnV1 apply; }} PopsTransferApiV1; +// Reflux providers are patch-local numerical kernels only. PoPS retains sole ownership of the +// time-integrated flux ledger, interface topology, MPI reduction, transaction and state update. +// Each face contains coarse/fine fluxes already integrated in time and averaged onto the same +// coarse face. The provider writes, but never applies, side*(fine-coarse)/dx into `correction`. +typedef enum PopsRefluxFaceSideV1 {{ + POPS_REFLUX_FACE_LOW_V1 = -1, + POPS_REFLUX_FACE_HIGH_V1 = 1 +}} PopsRefluxFaceSideV1; +typedef struct PopsRefluxFaceV1 {{ + uint32_t struct_size; + const char* interface_identity; + int32_t axis; + PopsRefluxFaceSideV1 side; + double inverse_coarse_cell_spacing; + PopsConstFieldViewV1 coarse_integrated_flux; + PopsConstFieldViewV1 fine_integrated_flux; + PopsFieldViewV1 correction; +}} PopsRefluxFaceV1; +typedef struct PopsRefluxRequestV1 {{ + uint32_t struct_size; + const char* transition_identity; + int32_t parent_level; + int32_t child_level; + size_t face_count; + const PopsRefluxFaceV1* faces; + PopsLogicalTimeV1 logical_time; + PopsExecutionContextV1 execution; +}} PopsRefluxRequestV1; +typedef int32_t (*PopsRefluxApplyInterfaceBatchFnV1)( + void*, const PopsRefluxRequestV1*, PopsComponentStatusV1*); +typedef struct PopsRefluxApiV1 {{ + PopsComponentTableHeaderV1 header; + PopsRefluxApplyInterfaceBatchFnV1 apply_interface_batch; +}} PopsRefluxApiV1; + typedef struct PopsFieldPatchMetadataV1 {{ uint32_t struct_size; size_t global_patch_index; @@ -1364,6 +1444,15 @@ def _render_component_abi(catalog: dict[str, Any], digest: str) -> str: }} return nullptr; }} +inline bool generated_native_interface_table_is_complete( + PopsNativeInterfaceIdV1 id, const void* table, size_t table_size) noexcept {{ + if (table == nullptr) + return false; + switch (id) {{ +{table_complete_rows} + }} + return false; +}} }} // namespace pops::component #endif // clang-format on diff --git a/scripts/generate_release_contract.py b/scripts/generate_release_contract.py index 67b3f7ffe..125297b06 100644 --- a/scripts/generate_release_contract.py +++ b/scripts/generate_release_contract.py @@ -19,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[1] -SOURCE = ROOT / "schemas" / "release_contract.v1.json" +SOURCE = ROOT / "schemas" / "release_contract.v2.json" +COMPONENT_SOURCE = ROOT / "schemas" / "component_catalog.v2.json" CMAKE = ROOT / "CMakeLists.txt" PYTHON = ROOT / "python" / "pops" / "_generated_release_contract.py" CPP = ROOT / "include" / "pops" / "runtime" / "config" / "generated_release_contract.hpp" @@ -40,6 +41,10 @@ "uniform_checkpoint_payload_version", "amr_checkpoint_payload_version", ) +_DIGEST_FIELDS = ( + "component_catalog_sha256", + "component_catalog_semantic_sha256", +) class ContractError(ValueError): @@ -58,15 +63,38 @@ def _canonical(data: Any) -> bytes: ensure_ascii=True).encode("utf-8") +def _component_catalog_digests() -> tuple[str, str]: + data = json.loads(COMPONENT_SOURCE.read_text(encoding="utf-8")) + full = hashlib.sha256(json.dumps( + data, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8")).hexdigest() + semantic = json.loads(json.dumps(data)) + for family in semantic["route_families"]: + for route in family["routes"]: + route.pop("limitations", None) + route["metadata"].pop("summary", None) + semantic_digest = hashlib.sha256(json.dumps( + semantic, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8")).hexdigest() + return full, semantic_digest + + def _load() -> tuple[dict[str, Any], str, str]: data = json.loads(SOURCE.read_text(encoding="utf-8")) - expected = set(_VERSION_FIELDS) | {"supported_matrix"} + expected = set(_VERSION_FIELDS) | set(_DIGEST_FIELDS) | {"supported_matrix"} if not isinstance(data, dict) or set(data) != expected: raise ContractError("release contract fields must be exactly %s" % sorted(expected)) for name in _VERSION_FIELDS: value = data[name] if type(value) is not int or value < 1: raise ContractError("%s must be an integer >= 1" % name) + expected_digests = dict(zip(_DIGEST_FIELDS, _component_catalog_digests(), strict=True)) + for name, expected_digest in expected_digests.items(): + value = data[name] + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value): + raise ContractError("%s must be one lowercase SHA-256 digest" % name) + if value != expected_digest: + raise ContractError("%s drifted from component_catalog.v2.json" % name) matrix = data["supported_matrix"] if not isinstance(matrix, dict) or set(matrix) != { "language", "kokkos", "distributed", "source_builds", "wheels", "not_promised", @@ -92,6 +120,8 @@ def _python_text(data: dict[str, Any], package_version: str, digest: str) -> str ] for name in _VERSION_FIELDS: constants.append("%s = %d" % (name.upper(), data[name])) + for name in _DIGEST_FIELDS: + constants.append("%s = %r" % (name.upper(), data[name])) constants.extend([ "RELEASE_CONTRACT_SHA256 = %r" % digest, "_SUPPORTED_MATRIX_DATA = %s" % pprint.pformat( @@ -136,6 +166,12 @@ def _cpp_text(data: dict[str, Any], package_version: str, digest: str) -> str: ] lines.extend("inline constexpr int %s = %d;" % (names[name], data[name]) for name in _VERSION_FIELDS) + lines.extend([ + 'inline constexpr const char* kComponentCatalogSha256 = "%s";' + % data["component_catalog_sha256"], + 'inline constexpr const char* kComponentCatalogSemanticSha256 = "%s";' + % data["component_catalog_semantic_sha256"], + ]) lines.extend([ 'inline constexpr const char* kContractSha256 = "%s";' % digest, "} // namespace pops::release_contract", diff --git a/scripts/prove_public_api_parity.py b/scripts/prove_public_api_parity.py new file mode 100644 index 000000000..256b02c4b --- /dev/null +++ b/scripts/prove_public_api_parity.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Prove that the release wheel and source checkout expose one pure-Python API.""" + +from __future__ import annotations + +import argparse +from collections.abc import Mapping, Sequence +from email import policy +from email.parser import BytesParser +import hashlib +import importlib.metadata +import json +from pathlib import Path, PurePosixPath +import subprocess +import sys +import tempfile +from typing import Any +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_PACKAGE = ROOT / "python" / "pops" +PROOF_SCHEMA_VERSION = 3 +TYPED_PAYLOAD_SUFFIXES = (".py", ".pyi") +PUBLIC_ROOT = ( + "Model", + "Program", + "Case", + "RunReport", + "RunStopReason", + "ExecutionContext", + "set_threads", + "validate", + "inspect", + "explain", + "resolve", + "compile", + "bind", + "run", + "__version__", +) + +_SNAPSHOT_PROGRAM = r""" +import hashlib +import inspect as _inspect +import json +from pathlib import Path +import sys + +package_parent = Path(sys.argv[1]).resolve() +sys.path.insert(0, str(package_parent)) +import pops + +expected_retired = ( + "Problem", + "RuntimePolicies", + "OutputPolicy", + "CheckpointPolicy", + "System", + "AmrSystem", + "ModelSpec", + "BindInputs", + "SystemConfig", + "AmrSystemConfig", + "CompiledTime", + "compile_library", + "read_library_manifest", + "LibraryManifest", +) +expected_public = ( + "Model", + "Program", + "Case", + "RunReport", + "RunStopReason", + "ExecutionContext", + "set_threads", + "validate", + "inspect", + "explain", + "resolve", + "compile", + "bind", + "run", + "__version__", +) +if tuple(pops.__all__) != expected_public: + raise RuntimeError("root public API does not match the final contract") +if "pops._pops" in sys.modules: + raise RuntimeError("root import loaded pops._pops") +if not isinstance(pops.Case, type) or "__getattr__" in pops.Case.__dict__: + raise RuntimeError("Case is not one explicit public type") +if "__getattr__" in pops.__dict__: + raise RuntimeError("root package uses a dynamic public facade") +if any(hasattr(pops, name) for name in expected_retired): + raise RuntimeError("root package still exposes a replaced public name") +if not (Path(pops.__file__).resolve().parent / "py.typed").is_file(): + raise RuntimeError("package has no py.typed marker") + +model = pops.Model("parity") +state = model.state("U", components=("u",)) +case = pops.Case("two_instances") +left = case.block("left", model) +right = case.block("right", model) +left_state = case.qualify(state, block=left) +right_state = case.qualify(state, block=right) +if left_state == right_state or left_state.block_ref != left or right_state.block_ref != right: + raise RuntimeError("qualified handles do not disambiguate repeated Model instances") +if pops.validate(case) is not case or not case.frozen: + raise RuntimeError("pure-Python validation did not freeze the exact Case") +report = pops.inspect(case) +if report["name"] != "two_instances" or set(report["blocks"]) != {"left", "right"}: + raise RuntimeError("pure-Python inspection did not preserve qualified blocks") +if "pops._pops" in sys.modules: + raise RuntimeError("authoring, validation, or inspection loaded pops._pops") + +def _annotation(value): + if isinstance(value, str): + return value + module = getattr(value, "__module__", None) + qualname = getattr(value, "__qualname__", None) + if module and qualname: + return module + "." + qualname + return repr(value) + +def _symbol(name): + value = getattr(pops, name) + if _inspect.isclass(value): + kind = "class" + elif _inspect.isfunction(value): + kind = "function" + else: + kind = type(value).__name__ + try: + call_signature = str(_inspect.signature(value, eval_str=False)) + except (TypeError, ValueError): + call_signature = None + annotations = getattr(value, "__annotations__", {}) + return { + "kind": kind, + "module": getattr(value, "__module__", None), + "qualname": getattr(value, "__qualname__", None), + "signature": call_signature, + "annotations": { + key: _annotation(annotation) + for key, annotation in sorted(annotations.items()) + }, + } + +public = list(pops.__all__) +snapshot = { + "public": public, + "symbols": {name: _symbol(name) for name in public}, + "package_version": pops.__version__, + "case_is_explicit_type": True, + "qualified_handles": True, + "pure_authoring": True, + "py_typed": True, +} +print(json.dumps(snapshot, sort_keys=True, separators=(",", ":"))) +""" + + +class PublicApiParityError(RuntimeError): + """The source checkout and release wheel do not expose one exact public API.""" + + +def _sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _sha256(path: Path) -> str: + return _sha256_bytes(path.read_bytes()) + + +def _is_typed_payload(relative: str) -> bool: + path = PurePosixPath(relative) + return path.name == "py.typed" or path.suffix in TYPED_PAYLOAD_SUFFIXES + + +def _typed_manifest(package: Path, *, label: str) -> dict[str, str]: + if not package.is_dir(): + raise PublicApiParityError("%s package is absent: %s" % (label, package)) + manifest = { + path.relative_to(package).as_posix(): _sha256(path) + for path in sorted(package.rglob("*")) + if path.is_file() + and "__pycache__" not in path.parts + and _is_typed_payload(path.relative_to(package).as_posix()) + } + required = {"__init__.py", "_pops.pyi", "py.typed"} + if not required.issubset(manifest): + raise PublicApiParityError("%s package lacks its root API or typing payload" % label) + return manifest + + +def _wheel_manifest(archive: zipfile.ZipFile) -> dict[str, str]: + members = [ + info + for info in archive.infolist() + if not info.is_dir() and info.filename.startswith("pops/") + ] + names = [info.filename for info in members] + if len(names) != len(set(names)): + raise PublicApiParityError("release wheel contains duplicate pops package members") + manifest = { + info.filename.removeprefix("pops/"): _sha256_bytes(archive.read(info)) + for info in members + if _is_typed_payload(info.filename.removeprefix("pops/")) + } + required = {"__init__.py", "_pops.pyi", "py.typed"} + if not required.issubset(manifest): + raise PublicApiParityError("release wheel lacks its root API or typing payload") + return manifest + + +def _distribution_identity(payload: bytes, *, label: str) -> dict[str, str]: + try: + metadata = BytesParser(policy=policy.default).parsebytes(payload) + except (TypeError, ValueError) as exc: + raise PublicApiParityError("%s distribution METADATA is unreadable" % label) from exc + name = metadata.get("Name") + version = metadata.get("Version") + if not isinstance(name, str) or not name.strip() \ + or not isinstance(version, str) or not version.strip(): + raise PublicApiParityError( + "%s distribution METADATA has no exact Name/Version" % label) + normalized = name.strip().lower().replace("_", "-").replace(".", "-") + if normalized != "pops": + raise PublicApiParityError("%s distribution name is not PoPS" % label) + return { + "name": name.strip(), + "version": version.strip(), + "metadata_sha256": _sha256_bytes(payload), + } + + +def _wheel_distribution_identity(archive: zipfile.ZipFile) -> dict[str, str]: + names = [info.filename for info in archive.infolist() if not info.is_dir()] + if len(names) != len(set(names)): + raise PublicApiParityError("release wheel contains duplicate members") + metadata_names = [name for name in names if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise PublicApiParityError("release wheel has no unique distribution METADATA") + return _distribution_identity( + archive.read(metadata_names[0]), label="wheel") + + +def _safe_extract(archive: zipfile.ZipFile, destination: Path) -> None: + for info in archive.infolist(): + relative = PurePosixPath(info.filename) + if relative.is_absolute() or ".." in relative.parts: + raise PublicApiParityError("release wheel contains an unsafe member path") + archive.extractall(destination) + + +def _snapshot(package_parent: Path) -> dict[str, Any]: + completed = subprocess.run( + [sys.executable, "-I", "-c", _SNAPSHOT_PROGRAM, str(package_parent.resolve())], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + env={"PYTHONDONTWRITEBYTECODE": "1"}, + ) + if completed.returncode: + raise PublicApiParityError( + "public API snapshot failed for %s:\n%s" + % (package_parent, completed.stdout[-4000:]) + ) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise PublicApiParityError( + "public API snapshot was not JSON for %s" % package_parent + ) from exc + if not isinstance(payload, dict): + raise PublicApiParityError("public API snapshot is not an object") + return payload + + +def _canonical_sha256(payload: Mapping[str, Any]) -> str: + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("utf-8") + return _sha256_bytes(encoded) + + +def _require_manifest_parity( + reference: Mapping[str, str], + candidate: Mapping[str, str], + *, + label: str, +) -> None: + if candidate == reference: + return + missing = sorted(set(reference) - set(candidate)) + extra = sorted(set(candidate) - set(reference)) + changed = sorted( + name + for name in set(reference) & set(candidate) + if reference[name] != candidate[name] + ) + raise PublicApiParityError( + "%s Python/typing payload differs from source " + "(missing=%s, extra=%s, changed=%s)" + % (label, missing[:8], extra[:8], changed[:8]) + ) + + +def _installed_package_from_distribution() -> tuple[Path, dict[str, str]]: + try: + distribution = importlib.metadata.distribution("PoPS") + except importlib.metadata.PackageNotFoundError as exc: + raise PublicApiParityError("the PoPS distribution is not installed") from exc + files = distribution.files + if files is None: + raise PublicApiParityError("the installed PoPS distribution has no file inventory") + metadata_files = [ + row + for row in files + if PurePosixPath(str(row)).as_posix().endswith(".dist-info/METADATA") + ] + if len(metadata_files) != 1: + raise PublicApiParityError( + "the installed PoPS distribution has no unique METADATA") + metadata_path = Path(distribution.locate_file(metadata_files[0])).resolve() + if not metadata_path.is_file(): + raise PublicApiParityError("the installed PoPS distribution METADATA is absent") + identity = _distribution_identity(metadata_path.read_bytes(), label="installed") + package_initializers = [ + row for row in files if PurePosixPath(str(row)).as_posix() == "pops/__init__.py" + ] + if len(package_initializers) != 1: + raise PublicApiParityError( + "the installed PoPS distribution has no unique pops/__init__.py") + package = Path(distribution.locate_file(package_initializers[0])).resolve().parent + if not package.is_dir(): + raise PublicApiParityError("the installed PoPS package directory is absent") + try: + package.relative_to(ROOT) + except ValueError: + return package, identity + raise PublicApiParityError( + "the installed-package proof resolved inside the source checkout: %s" % package) + + +def build_proof( + wheel: Path, + *, + installed_package: Path | None = None, + installed_distribution: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Compare one exact wheel archive with the current source checkout.""" + retained = wheel.expanduser().resolve() + if retained.suffix != ".whl" or not retained.is_file(): + raise PublicApiParityError("release artifact is not one readable wheel") + source_manifest = _typed_manifest(SOURCE_PACKAGE, label="source") + installed = None if installed_package is None else installed_package.expanduser().resolve() + if installed_distribution is not None and installed is None: + raise PublicApiParityError( + "installed distribution identity requires an installed package") + if installed is not None: + try: + installed.relative_to(ROOT) + except ValueError: + pass + else: + raise PublicApiParityError( + "the installed-package proof resolved inside the source checkout: %s" % installed) + try: + with tempfile.TemporaryDirectory(prefix="pops-public-api-") as temporary: + extracted = Path(temporary) + with zipfile.ZipFile(retained) as archive: + wheel_distribution = _wheel_distribution_identity(archive) + wheel_manifest = _wheel_manifest(archive) + _require_manifest_parity( + source_manifest, wheel_manifest, label="wheel") + _safe_extract(archive, extracted) + source_snapshot = _snapshot(SOURCE_PACKAGE.parent) + wheel_snapshot = _snapshot(extracted) + if installed is not None: + installed_manifest = _typed_manifest(installed, label="installed") + _require_manifest_parity( + source_manifest, installed_manifest, label="installed") + installed_snapshot = _snapshot(installed.parent) + except (OSError, zipfile.BadZipFile) as exc: + raise PublicApiParityError("release wheel is unreadable: %s" % exc) from exc + if wheel_snapshot != source_snapshot: + raise PublicApiParityError("wheel and source public API snapshots differ") + if source_snapshot.get("package_version") != wheel_distribution["version"]: + raise PublicApiParityError( + "source public API version differs from wheel distribution METADATA") + if installed is not None and installed_snapshot != source_snapshot: + raise PublicApiParityError("installed and source public API snapshots differ") + if installed_distribution is not None: + exact_installed_distribution = dict(installed_distribution) + if set(exact_installed_distribution) != {"name", "version", "metadata_sha256"}: + raise PublicApiParityError("installed distribution identity is malformed") + if exact_installed_distribution != wheel_distribution: + raise PublicApiParityError( + "installed distribution identity differs from wheel METADATA") + if tuple(source_snapshot["public"]) != PUBLIC_ROOT: + raise PublicApiParityError("public API snapshot differs from the final root contract") + proof = { + "schema_version": PROOF_SCHEMA_VERSION, + "producer": { + "script": "scripts/prove_public_api_parity.py", + "sha256": _sha256(Path(__file__).resolve()), + }, + "wheel_path": str(retained), + "wheel_sha256": _sha256(retained), + "distribution": wheel_distribution, + "typed_payload_files": len(source_manifest), + "typed_payload_sha256": _canonical_sha256(source_manifest), + "public_api_sha256": _canonical_sha256(source_snapshot), + "public_names": source_snapshot["public"], + "pure_authoring": source_snapshot["pure_authoring"], + "qualified_handles": source_snapshot["qualified_handles"], + "py_typed": source_snapshot["py_typed"], + "installed": installed is not None, + "installed_distribution": ( + None if installed_distribution is None else dict(installed_distribution) + ), + } + if installed is not None: + proof.update({ + "installed_package": str(installed), + "installed_typed_payload_sha256": _canonical_sha256(installed_manifest), + "installed_public_api_sha256": _canonical_sha256(installed_snapshot), + }) + return proof + + +def _write_evidence(path: Path, proof: Mapping[str, Any]) -> None: + destination = path.expanduser().resolve() + try: + destination.relative_to(ROOT) + except ValueError: + pass + else: + raise PublicApiParityError("evidence path must be outside the checkout") + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + raise PublicApiParityError("refusing to overwrite public API evidence: %s" % destination) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=destination.parent, delete=False + ) as stream: + json.dump(proof, stream, sort_keys=True, indent=2) + stream.write("\n") + temporary = Path(stream.name) + temporary.replace(destination) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", required=True, type=Path) + parser.add_argument( + "--installed", + action="store_true", + help="also prove the importlib.metadata-resolved installed distribution outside checkout", + ) + parser.add_argument("--evidence", type=Path) + args = parser.parse_args(argv) + try: + if args.installed: + installed, installed_distribution = _installed_package_from_distribution() + else: + installed, installed_distribution = None, None + proof = build_proof( + args.wheel, + installed_package=installed, + installed_distribution=installed_distribution, + ) + if args.evidence is not None: + _write_evidence(args.evidence, proof) + except (PublicApiParityError, OSError, ValueError) as exc: + print("public API parity proof failed: %s" % exc, file=sys.stderr) + return 1 + print(json.dumps(proof, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_preflight.py b/scripts/release_preflight.py index f11a73784..bb6ee9ac3 100644 --- a/scripts/release_preflight.py +++ b/scripts/release_preflight.py @@ -17,13 +17,18 @@ import sys import tomllib from typing import Any +import xml.etree.ElementTree as ET import zipfile from final_release_contract import ( FINAL_EXAMPLES, + FINAL_EXAMPLE_REQUIRED_TESTS, + FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS, + INSTALLED_COMPONENT_PACKAGE_NODEID, PYTHON_REQUIRED_SELECTION, REQUIRED_PROOF_MARKERS, REQUIRED_RELEASE_GATES, + required_python_conformance_nodeids, require_release_matrix_source_contract, require_source_contract, ) @@ -32,7 +37,8 @@ ROOT = Path(__file__).resolve().parents[1] GENERATED = ROOT / "python" / "pops" / "_generated_release_contract.py" REQUIRED_GATES = REQUIRED_RELEASE_GATES -EVIDENCE_SCHEMA_VERSION = 7 +EVIDENCE_SCHEMA_VERSION = 11 +PUBLIC_API_EVIDENCE_SCHEMA_VERSION = 3 class PreflightError(RuntimeError): @@ -81,7 +87,7 @@ def _static_contract(contract: Any) -> list[str]: provider["regex"], (ROOT / "CMakeLists.txt").read_text(encoding="utf-8")): raise PreflightError("wheel version provider does not resolve the CMake version") - source = json.loads((ROOT / "schemas" / "release_contract.v1.json").read_text()) + source = json.loads((ROOT / "schemas" / "release_contract.v2.json").read_text()) catalog = json.loads((ROOT / "schemas" / "component_catalog.v2.json").read_text()) exact = { "component_catalog_schema_version": catalog["catalog_schema_version"], @@ -93,6 +99,23 @@ def _static_contract(contract: Any) -> list[str]: for name, value in exact.items(): if source[name] != value: raise PreflightError("release contract %s drifted from component catalog" % name) + component_generated = ROOT / "python" / "pops" / "model" / "_generated_component_schema.py" + component_spec = importlib.util.spec_from_file_location( + "_release_component_schema", component_generated + ) + if component_spec is None or component_spec.loader is None: + raise PreflightError("cannot load generated component schema") + component_contract = importlib.util.module_from_spec(component_spec) + component_spec.loader.exec_module(component_contract) + component_digests = { + "component_catalog_sha256": component_contract.COMPONENT_CATALOG_SHA256, + "component_catalog_semantic_sha256": ( + component_contract.COMPONENT_CATALOG_SEMANTIC_SHA256 + ), + } + for name, value in component_digests.items(): + if source[name] != value or getattr(contract, name.upper()) != value: + raise PreflightError("release contract %s drifted from component catalog" % name) native = (ROOT / "include" / "pops" / "runtime" / "module_capabilities.hpp").read_text() match = re.search(r"kAbiVersion\s*=\s*(\d+)", native) if match is None or int(match.group(1)) != source["native_abi_version"]: @@ -285,6 +308,96 @@ def _wheel_evidence(directory: Path, gates: dict[str, Any], contract: Any) -> No raise PreflightError("release wheel name/version disagrees with the release contract") +def _public_api_evidence( + path: Path, + release_evidence: dict[str, Any], + contract: Any, +) -> None: + resolved = path.expanduser().resolve() + if _inside(ROOT, resolved) or not resolved.is_file(): + raise PreflightError( + "installed public API evidence must be one file outside the checkout") + try: + payload = json.loads(resolved.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise PreflightError("installed public API evidence is unreadable") from exc + expected = { + "schema_version", + "producer", + "wheel_path", + "wheel_sha256", + "distribution", + "typed_payload_files", + "typed_payload_sha256", + "public_api_sha256", + "public_names", + "pure_authoring", + "qualified_handles", + "py_typed", + "installed", + "installed_distribution", + "installed_package", + "installed_typed_payload_sha256", + "installed_public_api_sha256", + } + if not isinstance(payload, dict) or set(payload) != expected \ + or payload["schema_version"] != PUBLIC_API_EVIDENCE_SCHEMA_VERSION: + raise PreflightError("installed public API evidence has an unknown schema") + producer = { + "script": "scripts/prove_public_api_parity.py", + "sha256": hashlib.sha256( + (ROOT / "scripts" / "prove_public_api_parity.py").read_bytes() + ).hexdigest(), + } + if payload["producer"] != producer: + raise PreflightError("installed public API evidence has another producer") + wheel = release_evidence["gates"]["official_build"]["evidence"]["wheel"] + if payload["wheel_sha256"] != wheel["sha256"]: + raise PreflightError("installed public API evidence belongs to another wheel") + distribution = payload["distribution"] + installed_distribution = payload["installed_distribution"] + if not isinstance(distribution, dict) or set(distribution) != { + "name", "version", "metadata_sha256"}: + raise PreflightError("public API wheel distribution identity is malformed") + if installed_distribution != distribution: + raise PreflightError("installed distribution identity differs from the release wheel") + if not isinstance(distribution["name"], str) \ + or not isinstance(distribution["version"], str) \ + or distribution["name"].lower() != "pops" \ + or distribution["version"] != contract.PACKAGE_VERSION: + raise PreflightError("public API distribution identity disagrees with the release") + digests = ( + distribution["metadata_sha256"], + payload["wheel_sha256"], + payload["typed_payload_sha256"], + payload["installed_typed_payload_sha256"], + payload["public_api_sha256"], + payload["installed_public_api_sha256"], + ) + if any(not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None + for value in digests): + raise PreflightError("installed public API evidence contains an invalid digest") + if payload["installed_typed_payload_sha256"] != payload["typed_payload_sha256"] \ + or payload["installed_public_api_sha256"] != payload["public_api_sha256"]: + raise PreflightError("installed public API or typing digest differs from source") + if payload["installed"] is not True or payload["pure_authoring"] is not True \ + or payload["qualified_handles"] is not True or payload["py_typed"] is not True: + raise PreflightError("installed public API evidence did not prove the final contract") + if not isinstance(payload["typed_payload_files"], int) \ + or payload["typed_payload_files"] <= 0 \ + or not isinstance(payload["public_names"], list) \ + or not payload["public_names"] \ + or not all(isinstance(name, str) and name for name in payload["public_names"]): + raise PreflightError("installed public API evidence has an empty public surface") + if not isinstance(payload["installed_package"], str): + raise PreflightError("installed public API package path is malformed") + installed_package = Path(payload["installed_package"]).resolve() + runtime_package = Path(release_evidence["runtime"]["pops_file"]).resolve().parent + if installed_package != runtime_package: + raise PreflightError( + "public API parity was not proven on the authenticated installed runtime") + + def _installed_wheel_evidence( directory: Path, gates: dict[str, Any], @@ -469,16 +582,62 @@ def _examples_evidence( reopened = reopen["examples"][key] if not isinstance(reopened, dict) or set(reopened) != {"hdf5", "npz", "paraview"}: raise PreflightError("release evidence reopen record is malformed for %s" % key) + expected_outputs = FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS[example] for format_name in ("hdf5", "npz", "paraview"): artifacts = reopened[format_name] - if not isinstance(artifacts, list) or not artifacts: - raise PreflightError("release evidence has no %s output for %s" % (format_name, key)) + if not isinstance(artifacts, list): + raise PreflightError( + "release evidence %s output ledger is malformed for %s" + % (format_name, key) + ) + expectations = expected_outputs[format_name] + if bool(artifacts) != bool(expectations): + raise PreflightError( + "release evidence %s output coverage drifted for %s" + % (format_name, key) + ) + artifact_roots = tuple( + Path(expectation["artifact_root"]) for expectation in expectations + ) + covered_roots: dict[Path, set[str]] = { + artifact_root: set() for artifact_root in artifact_roots + } for artifact in artifacts: if not isinstance(artifact, dict) or set(artifact) != {"path", "sha256"}: raise PreflightError("release evidence %s output is malformed for %s" % (format_name, key)) + if not isinstance(artifact["path"], str) or not isinstance( + artifact["sha256"], str + ): + raise PreflightError( + "release evidence %s output identity is malformed for %s" + % (format_name, key) + ) + relative_artifact = Path(artifact["path"]) + containing_roots = tuple( + artifact_root for artifact_root in artifact_roots + if relative_artifact.is_relative_to(artifact_root) + ) + if len(containing_roots) != 1: + raise PreflightError( + "release evidence %s output escaped its exact artifact root for %s" + % (format_name, key) + ) + covered_roots[containing_roots[0]].add(relative_artifact.suffix) _artifact_file(output_root, artifact["path"], artifact["sha256"], label="%s %s" % (format_name, key)) + required_suffixes = { + "hdf5": {".h5"}, + "npz": {".npz"}, + "paraview": {".pvd", ".vtu"}, + }[format_name] + for artifact_root, suffixes in covered_roots.items(): + if not required_suffixes.issubset(suffixes): + raise PreflightError( + "release evidence %s output root %s lacks %s for %s" + % (format_name, artifact_root, + sorted(required_suffixes - suffixes), key) + ) restarted = restart["examples"][key] if not isinstance(restarted, dict) or set(restarted) != { "checkpoint", "tree_sha256", "proof_markers"}: @@ -492,7 +651,133 @@ def _examples_evidence( raise PreflightError("release evidence restart proof markers drifted for %s" % key) -def _evidence(path: Path, contract: Any, commit: str, runtime: dict[str, str]) -> None: +def _final_example_test_evidence(evidence: dict[str, Any]) -> None: + """Require the exact reviewed tests from the authenticated Python lane.""" + + if evidence.get("final_example_nodeids") != list(FINAL_EXAMPLE_REQUIRED_TESTS): + raise PreflightError("release evidence final-example test ledger drifted") + + +def _junit_evidence( + report: Path, + lane: dict[str, Any], + *, + required_nodeids: tuple[str, ...] = (), +) -> None: + """Re-authenticate one retained JUnit report instead of trusting its JSON summary.""" + + try: + root = ET.parse(report).getroot() + except (OSError, ET.ParseError) as exc: + raise PreflightError("release evidence JUnit report is invalid: %s" % exc) from exc + cases = tuple(root.iter("testcase")) + failed = tuple( + case + for case in cases + if case.find("failure") is not None or case.find("error") is not None + ) + skipped = tuple(case for case in cases if case.find("skipped") is not None) + actual = { + "tests": len(cases), + "failures": len(failed), + "skips_or_xfails": len(skipped), + } + reported = {name: lane[name] for name in actual} + if actual != reported: + raise PreflightError( + "release evidence JUnit summary drifted: reported=%s actual=%s" + % (reported, actual) + ) + if not cases or failed or skipped: + raise PreflightError( + "release evidence JUnit lane is not all-pass: " + "tests=%d failures=%d skips_or_xfails=%d" + % (len(cases), len(failed), len(skipped)) + ) + for nodeid in required_nodeids: + relative, function_name = nodeid.split("::", 1) + expected_class = str(Path(relative).with_suffix("")).replace("/", ".") + matches = [ + case + for case in cases + if case.attrib.get("name", "").split("[", 1)[0] == function_name + and case.attrib.get("classname", "").endswith(expected_class) + ] + if len(matches) != 1: + raise PreflightError( + "release evidence required final-example test %s appears %d times in JUnit" + % (nodeid, len(matches)) + ) + + +def _installed_component_package_evidence( + directory: Path, + python_conformance: dict[str, Any], +) -> None: + component = python_conformance["evidence"]["installed_component_package"] + if not isinstance(component, dict) or set(component) != {"nodeid", "headers", "lane"}: + raise PreflightError("release evidence installed component package lane is malformed") + if component["nodeid"] != INSTALLED_COMPONENT_PACKAGE_NODEID \ + or component["headers"] != "installed-wheel": + raise PreflightError("release evidence installed component package authority drifted") + lane = component["lane"] + if not isinstance(lane, dict) or set(lane) != { + "path", "sha256", "tests", "failures", "skips_or_xfails"}: + raise PreflightError("release evidence installed component package JUnit is malformed") + if lane["tests"] != 1 or lane["failures"] != 0 or lane["skips_or_xfails"] != 0: + raise PreflightError("release evidence installed component package lane is not all-pass") + component_report = Path(lane["path"]).resolve() + if not _inside(directory, component_report): + raise PreflightError( + "release evidence installed component package JUnit path escapes its directory") + _artifact_file( + directory, + component_report.relative_to(directory).as_posix(), + lane["sha256"], + label="installed component package JUnit", + ) + _junit_evidence( + component_report, + lane, + required_nodeids=(INSTALLED_COMPONENT_PACKAGE_NODEID,), + ) + component_commands = [ + command for command in python_conformance["commands"] + if INSTALLED_COMPONENT_PACKAGE_NODEID in command["argv"] + ] + if len(component_commands) != 1: + raise PreflightError( + "release evidence must execute the installed component package node exactly once") + component_argv = component_commands[0]["argv"] + include_assignments = [ + argument for argument in component_argv if argument.startswith("POPS_INCLUDE=") + ] + if include_assignments != ["POPS_INCLUDE="] \ + or "POPS_PROVE_INSTALLED_COMPONENT_PACKAGE=1" not in component_argv: + raise PreflightError( + "installed component package proof must use only wheel-owned headers") + expected_suffix = [ + "python", + "-m", + "pytest", + "-q", + "-s", + "-o", + "xfail_strict=true", + INSTALLED_COMPONENT_PACKAGE_NODEID, + "--junitxml", + lane["path"], + ] + if component_argv[-len(expected_suffix):] != expected_suffix: + raise PreflightError("installed component package proof command drifted") + + +def _evidence( + path: Path, + contract: Any, + commit: str, + runtime: dict[str, str], +) -> dict[str, Any]: payload = json.loads(path.read_text(encoding="utf-8")) expected = {"schema_version", "producer", "commit_sha", "package_version", "contract_sha256", "artifact_directory", "runtime", "gates"} @@ -540,7 +825,13 @@ def _evidence(path: Path, contract: Any, commit: str, runtime: dict[str, str]) - for name in ("native_conformance", "python_conformance"): evidence = gates[name]["evidence"] expected = {"required_lane"} if name == "native_conformance" \ - else {"required_lane", "selection"} + else { + "required_lane", + "selection", + "nodeids", + "final_example_nodeids", + "installed_component_package", + } if not isinstance(evidence, dict) or set(evidence) != expected: raise PreflightError("release evidence %s lane is malformed" % name) lane = evidence["required_lane"] @@ -553,11 +844,49 @@ def _evidence(path: Path, contract: Any, commit: str, runtime: dict[str, str]) - report = Path(lane["path"]).resolve() if not _inside(directory, report): raise PreflightError("release evidence %s JUnit path escapes its directory" % name) - _artifact_file(directory, report.relative_to(directory), lane["sha256"], + _artifact_file(directory, report.relative_to(directory).as_posix(), lane["sha256"], label="%s JUnit" % name) - if gates["python_conformance"]["evidence"]["selection"] != PYTHON_REQUIRED_SELECTION: + _junit_evidence( + report, + lane, + required_nodeids=( + required_python_conformance_nodeids(ROOT) + if name == "python_conformance" + else () + ), + ) + python_evidence = gates["python_conformance"]["evidence"] + if python_evidence["selection"] != PYTHON_REQUIRED_SELECTION: raise PreflightError("release evidence Python required-lane selection drifted") + expected_python_nodeids = list(required_python_conformance_nodeids(ROOT)) + if python_evidence["nodeids"] != expected_python_nodeids: + raise PreflightError("release evidence Python conformance ledger drifted") + python_lane = python_evidence["required_lane"] + python_commands = [ + command + for command in gates["python_conformance"]["commands"] + if python_lane["path"] in command["argv"] + ] + if len(python_commands) != 1: + raise PreflightError("release evidence must execute one exact Python conformance lane") + expected_python_suffix = [ + "python", + "-m", + "pytest", + "-q", + "-s", + "-o", + "xfail_strict=true", + *expected_python_nodeids, + "--junitxml", + python_lane["path"], + ] + if python_commands[0]["argv"][-len(expected_python_suffix):] != expected_python_suffix: + raise PreflightError("release evidence Python conformance command drifted") + _final_example_test_evidence(python_evidence) + _installed_component_package_evidence(directory, gates["python_conformance"]) _examples_evidence(directory, gates, runtime) + return payload def main() -> int: @@ -566,10 +895,18 @@ def main() -> int: parser.add_argument("--tag") parser.add_argument("--installed", action="store_true") parser.add_argument("--evidence", type=Path) + parser.add_argument("--public-api-evidence", type=Path) args = parser.parse_args() try: - if args.release and (not args.tag or not args.installed or args.evidence is None): - raise PreflightError("--release requires --tag, --installed and --evidence") + if args.release and ( + not args.tag + or not args.installed + or args.evidence is None + or args.public_api_evidence is None + ): + raise PreflightError( + "--release requires --tag, --installed, --evidence and " + "--public-api-evidence") contract = _generated() checks = _static_contract(contract) if args.release: @@ -578,8 +915,16 @@ def main() -> int: if _run("git", "status", "--porcelain"): raise PreflightError("release checkout is dirty") runtime = _installed_contract(contract) - _evidence(args.evidence, contract, commit, runtime) - checks.extend(("tag", "changelog", "installed", "evidence", "clean")) + release_evidence = _evidence(args.evidence, contract, commit, runtime) + _public_api_evidence(args.public_api_evidence, release_evidence, contract) + checks.extend(( + "tag", + "changelog", + "installed", + "evidence", + "public_api_parity", + "clean", + )) elif args.tag: _tag_contract(contract.PACKAGE_VERSION, args.tag) checks.extend(("tag", "changelog")) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py new file mode 100755 index 000000000..3f4ce1737 --- /dev/null +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Validate and run the bounded ADC-757 prepared-numerics evidence gate.""" + +from __future__ import annotations + +import argparse +import ast +from collections import Counter, defaultdict +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import tomllib +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST = ROOT / "tests/gates/adc757_prepared_numerics.toml" +TEST_MANIFEST = ROOT / "tests/test_manifest.toml" +HARDWARE_VERIFIER = ROOT / "benchmarks/adc757/verify.py" +EXPECTED_HARDWARE_REQUIREMENTS = ( + "gpu_backend_execution", + "accelerator_stream_partitioning", + "performance_baselines_and_regression_thresholds", +) +EXPECTED_REQUIREMENTS = { + "prepared_local_nonlinear", + "typed_fallible_evaluation", + "transactional_recovery_publication", + "allocation_aware_cell_hot_path", + "prepared_boundary_publication", + "post_riemann_boundary_flux", + "qualified_flux_provider_pack", + "capability_driven_riemann", + "mpi_collective_execution", + "typed_flux_recovery_consumption", + "prepared_riemann_recovery_policy", + "public_prepared_riemann_recovery", + "runtime_recovery_consumer_publication", + "uniform_recovery_warm_start", + "analytic_initial_recovery_publication", + "fallible_primitive_to_conservative_publication", + "amr_regrid_recovery_publication", + "amr_restriction_recovery_publication", + "amr_bootstrap_recovery_publication", + "amr_history_recovery_publication", + "physical_boundary_trace_recovery_publication", + "terminal_source_recovery_publication", + "type_erased_recovery_method_identity", + "model_declared_admissibility", + "prepared_limiter_provider", + "cell_local_temporal_partition_authority", + "cell_local_temporal_scientific_provider", + "python_ir_generated_abi_and_restart_parity", + "host_workspace_reentrancy", + "native_spatial_provider_dimension_matrix", + "metric_spatial_provider_geometry_matrix", + "characteristic_boundary_geometry_matrix", + "polar_metric_spatial_provider_matrix", + "measured_load_balance_decision", + "amr_rebalance_migration_and_restart_coherence", + "bounded_cell_local_program_runtime", + "prepared_boundary_plan_only_transport_authority", + "polar_persistent_prepared_boundary_plan", + "prepared_batch_recovery_only_runtime_authority", + *EXPECTED_HARDWARE_REQUIREMENTS, +} +EXPECTED_DEFERRED = ( + "remaining_runtime_nd_metric_eb_characteristic_execution", + "remaining_multirank_multibox_amr_local_time_execution", +) +GTEST_PATTERN = re.compile(r"\bTEST(?:_F)?\(\s*([A-Za-z_]\w*)\s*,\s*([A-Za-z_]\w*)\s*\)") +FULL_GIT_REVISION = re.compile(r"[0-9a-f]{40}") +EXPECTED_HARDWARE_EVIDENCE = { + "kind": "authenticated_hardware_report", + "polarity": "positive", + "report_schema": "pops.adc757.heterogeneous-numerics.v1", + "verifier": "benchmarks/adc757/verify.py", + "requirements": list(EXPECTED_HARDWARE_REQUIREMENTS), +} + + +def _cpp_suites() -> dict[str, dict]: + data = tomllib.loads(TEST_MANIFEST.read_text(encoding="utf-8")) + return {str(row["name"]): row for row in data.get("cpp", {}).get("suite", ())} + + +def _python_files() -> set[str]: + data = tomllib.loads(TEST_MANIFEST.read_text(encoding="utf-8")) + files: set[str] = set() + for suite in data.get("python", {}).get("suite", ()): + relative_root = suite.get("path") + if not isinstance(relative_root, str): + continue + root = ROOT / relative_root + if not root.is_dir(): + continue + files.update( + source.relative_to(ROOT).as_posix() + for source in root.rglob("test_*.py") + if source.is_file() + ) + return files + + +def _declared_gtests(suite: dict) -> tuple[dict[str, bool], list[str]]: + tests: dict[str, bool] = {} + errors: list[str] = [] + for relative in suite.get("sources", ()): + source = ROOT / relative + if not source.is_file(): + errors.append("missing source %s" % relative) + continue + text = source.read_text(encoding="utf-8") + matches = list(GTEST_PATTERN.finditer(text)) + for index, match in enumerate(matches): + suite_name, test_name = match.groups() + name = "%s.%s" % (suite_name, test_name) + if name in tests: + errors.append("duplicate declared GTest %s" % name) + continue + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body = text[match.start():end] + tests[name] = ( + suite_name.startswith("DISABLED_") + or test_name.startswith("DISABLED_") + or "GTEST_SKIP" in body + ) + return tests, errors + + +def _declared_pytests(relative: str) -> tuple[dict[str, ast.FunctionDef], list[str]]: + source = ROOT / relative + if not source.is_file(): + return {}, ["missing source %s" % relative] + try: + tree = ast.parse(source.read_text(encoding="utf-8"), filename=relative) + except (OSError, SyntaxError) as exc: + return {}, ["cannot parse %s: %s" % (relative, exc)] + tests = { + node.name: node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_") + } + return tests, [] + + +def _pytest_is_skipped(test: ast.FunctionDef) -> bool: + blocked_decorators = ("pytest.mark.skip", "pytest.mark.skipif", "pytest.mark.xfail") + if any( + any(blocked in ast.unparse(decorator) for blocked in blocked_decorators) + for decorator in test.decorator_list + ): + return True + return any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "pytest" + and node.func.attr in {"skip", "xfail"} + for node in ast.walk(test) + ) + + +def _validate_hardware_evidence(data: dict, errors: list[str]) -> tuple[str, ...]: + """Validate the one external report route and return its positive requirements.""" + evidence = data.get("hardware_evidence") + if not isinstance(evidence, dict): + errors.append("hardware_evidence must be one authenticated report table") + return () + if evidence != EXPECTED_HARDWARE_EVIDENCE: + errors.append( + "hardware_evidence must bind exactly one authenticated report to %s" + % list(EXPECTED_HARDWARE_REQUIREMENTS) + ) + requirements = evidence.get("requirements") + if not isinstance(requirements, list): + return () + if any(not isinstance(requirement, str) for requirement in requirements): + errors.append("hardware_evidence requirements must be strings") + return () + if len(set(requirements)) != len(requirements): + errors.append("hardware_evidence requirements must be unique") + return tuple( + requirement + for requirement in requirements + if requirement in EXPECTED_HARDWARE_REQUIREMENTS + ) + + +def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: + """Return the manifest and deterministic source-only validation errors.""" + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + return {}, ["cannot read ADC-757 gate manifest %s: %s" % (path, exc)] + + errors: list[str] = [] + expected_fields = { + "schema_version", + "gate", + "issue", + "evidence_from", + "deferred", + "hardware_evidence", + "check", + } + if set(data) != expected_fields: + errors.append("manifest fields must be exactly %s" % sorted(expected_fields)) + if data.get("schema_version") != 3: + errors.append("schema_version must be exactly 3") + if data.get("gate") != "adc757-prepared-numerics-slice": + errors.append("gate must be exactly 'adc757-prepared-numerics-slice'") + if data.get("issue") != "ADC-757": + errors.append("issue must be exactly ADC-757") + expected_evidence = [ + "ADC-682", + "ADC-711", + "ADC-733", + "ADC-737", + "ADC-749", + "ADC-750", + "ADC-751", + "ADC-752", + "ADC-753", + "ADC-754", + "ADC-755", + "ADC-756", + ] + if data.get("evidence_from") != expected_evidence: + errors.append("evidence_from must be exactly %s" % expected_evidence) + if data.get("deferred") != list(EXPECTED_DEFERRED): + errors.append("deferred must enumerate every deliberately unproved family exactly") + hardware_requirements = _validate_hardware_evidence(data, errors) + + checks = data.get("check") + if not isinstance(checks, list) or not checks: + errors.append("manifest must contain [[check]] rows") + checks = [] + suites = _cpp_suites() + python_files = _python_files() + coverage: dict[str, set[str]] = defaultdict(set) + for requirement in hardware_requirements: + coverage[requirement].add("positive") + identities = Counter() + mpi_checks: Counter[str] = Counter() + for index, row in enumerate(checks, 1): + where = "check[%d]" % index + kind = row.get("kind", "ctest") + if kind == "pytest": + expected_row_fields = {"requirement", "polarity", "kind", "path", "test"} + else: + expected_row_fields = {"requirement", "polarity", "target", "test_regex"} + if kind == "mpi_ctest": + expected_row_fields.update({"kind", "nproc"}) + if set(row) != expected_row_fields: + errors.append("%s has unknown or missing fields" % where) + continue + requirement = row.get("requirement") + polarity = row.get("polarity") + target = row.get("target") + selector = row.get("test_regex") + if requirement not in EXPECTED_REQUIREMENTS: + errors.append("%s has unknown requirement %r" % (where, requirement)) + if polarity not in {"positive", "refusal"}: + errors.append("%s polarity must be positive or refusal" % where) + else: + coverage[str(requirement)].add(str(polarity)) + if requirement in EXPECTED_HARDWARE_REQUIREMENTS and polarity == "positive": + errors.append( + "%s hardware positive evidence must come only from hardware_evidence" % where + ) + if kind == "pytest": + relative = row.get("path") + test_name = row.get("test") + identity = (kind, relative, test_name) + identities[identity] += 1 + if relative not in python_files: + errors.append("%s references unknown Python test file %r" % (where, relative)) + continue + declared, source_errors = _declared_pytests(str(relative)) + errors.extend("%s: %s" % (where, error) for error in source_errors) + if test_name not in declared: + errors.append( + "%s references unknown top-level pytest %r in %r" + % (where, test_name, relative) + ) + continue + if _pytest_is_skipped(declared[str(test_name)]): + errors.append("%s pytest proof %r is skipped or xfailed" % (where, test_name)) + continue + identity = (kind, target, selector) + identities[identity] += 1 + if ( + not isinstance(selector, str) + or not selector.startswith("^") + or not selector.endswith("$") + ): + errors.append("%s must use one anchored exact CTest regex" % where) + continue + if target not in suites: + errors.append("%s references unknown CTest target %r" % (where, target)) + continue + suite = suites[target] + labels = {str(label) for label in suite.get("labels", ())} + if kind == "mpi_ctest": + mpi_checks[str(requirement)] += 1 + nproc = row.get("nproc") + if "mpi" not in labels: + errors.append("%s mpi_ctest target %r lacks the mpi label" % (where, target)) + if ( + isinstance(nproc, bool) + or not isinstance(nproc, int) + or nproc < 1 + or nproc not in suite.get("mpi_nproc", ()) + ): + errors.append( + "%s nproc must be one exact rank count declared by %r" % (where, target) + ) + expected_selector = "^%s_np%s$" % (target, nproc) + if selector != expected_selector: + errors.append( + "%s mpi_ctest selector must be exactly %r" % (where, expected_selector) + ) + continue + if kind != "ctest": + errors.append("%s has unknown check kind %r" % (where, kind)) + continue + if "mpi" in labels or "gpu" in labels: + errors.append("%s ordinary CTest claims a deferred MPI/GPU target %r" % (where, target)) + declared, source_errors = _declared_gtests(suites[target]) + errors.extend("%s: %s" % (where, error) for error in source_errors) + try: + matches = sorted(name for name in declared if re.fullmatch(selector, name)) + except re.error as exc: + errors.append("%s has invalid test_regex: %s" % (where, exc)) + continue + if len(matches) != 1: + errors.append( + "%s must resolve to exactly one declared GTest; got %s" % (where, matches) + ) + elif declared[matches[0]]: + errors.append("%s selected CTest %r is skipped or disabled" % (where, matches[0])) + + duplicates = sorted(identity for identity, count in identities.items() if count > 1) + if duplicates: + errors.append("duplicate executable checks: %s" % duplicates) + if mpi_checks["mpi_collective_execution"] != 2: + errors.append( + "the closed mpi_collective_execution family requires exactly two MPI CTests" + ) + for requirement in sorted(EXPECTED_REQUIREMENTS): + missing = {"positive", "refusal"} - coverage[requirement] + if missing: + errors.append("%s lacks %s coverage" % (requirement, "/".join(sorted(missing)))) + return data, errors + + +def _run_ctest(build_dir: Path, target: str, selector: str) -> None: + listed = subprocess.run( + ["ctest", "--test-dir", str(build_dir), "-N", "-R", selector], + cwd=ROOT, + check=True, + text=True, + capture_output=True, + ) + if "Total Tests: 0" in listed.stdout or "Test #" not in listed.stdout: + raise RuntimeError( + "ADC-757 proof target %r (%s) is not built in %s" % (target, selector, build_dir) + ) + command = [ + "ctest", + "--test-dir", + str(build_dir), + "--output-on-failure", + "-R", + selector, + ] + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, check=True) + + +def _pytest_skip_count(report: Path) -> int: + if not report.is_file(): + raise RuntimeError("ADC-757 pytest did not produce its mandatory JUnit report") + try: + root = ET.parse(report).getroot() + except ET.ParseError as exc: + raise RuntimeError("ADC-757 pytest produced an invalid JUnit report") from exc + return len(root.findall(".//skipped")) + + +def _run_pytest(relative: str, test_name: str) -> None: + environment = os.environ.copy() + environment["POPS_REQUIRE_MPI_TESTS"] = "1" + environment["POPS_REQUIRE_NATIVE_TESTS"] = "1" + with tempfile.TemporaryDirectory(prefix="pops-adc757-gate-") as temporary: + report = Path(temporary) / "pytest.xml" + command = [ + sys.executable, + "-m", + "pytest", + "-q", + "--strict-markers", + "-o", + "xfail_strict=true", + "--junitxml", + str(report), + "%s::%s" % (relative, test_name), + ] + print( + "+ POPS_REQUIRE_MPI_TESTS=1 POPS_REQUIRE_NATIVE_TESTS=1", + " ".join(command), + flush=True, + ) + completed = subprocess.run( + command, + cwd=ROOT, + env=environment, + check=False, + ) + skipped = _pytest_skip_count(report) + if skipped: + raise RuntimeError( + "ADC-757 pytest reported %d skipped/xfail proof(s); every proof is mandatory" + % skipped + ) + if completed.returncode != 0: + raise subprocess.CalledProcessError(completed.returncode, command) + + +def _run_hardware_evidence( + evidence: dict, report: Path, expected_revision: str +) -> tuple[str, ...]: + if FULL_GIT_REVISION.fullmatch(expected_revision) is None: + raise RuntimeError("ADC-757 closure requires one full lowercase 40-hex Git revision") + if not report.is_file(): + raise RuntimeError("ADC-757 hardware report does not exist") + verifier = ROOT / evidence["verifier"] + if verifier.resolve() != HARDWARE_VERIFIER.resolve(): + raise RuntimeError("ADC-757 hardware evidence selected an unauthenticated verifier") + command = [ + sys.executable, + str(verifier), + "--input", + str(report), + "--expected-revision", + expected_revision, + ] + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, check=True) + requirements = tuple(evidence["requirements"]) + if requirements != EXPECTED_HARDWARE_REQUIREMENTS: + raise RuntimeError("ADC-757 hardware report is not bound to the exact requirements") + return requirements + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--build-dir", type=Path, default=ROOT / "build") + parser.add_argument("--check-only", action="store_true") + parser.add_argument( + "--closure", + action="store_true", + help="require every source proof plus revision-matched heterogeneous hardware evidence", + ) + parser.add_argument( + "--hardware-report", + type=Path, + help="real GPU/MPI/ABBA report consumed only by --closure", + ) + parser.add_argument( + "--expected-revision", + help="exact candidate commit recorded by the hardware report", + ) + args = parser.parse_args(argv) + + data, errors = validate_manifest(args.manifest) + if errors: + print("ADC-757 prepared-numerics gate is invalid:", file=sys.stderr) + for error in errors: + print(" -", error, file=sys.stderr) + return 2 + print( + "ADC-757 prepared-numerics slice: OK " + "(%d executable proofs, %d authenticated hardware positives required, " + "%d explicitly deferred families)" + % ( + len(data["check"]), + len(data["hardware_evidence"]["requirements"]), + len(data["deferred"]), + ) + ) + if args.closure: + if data["deferred"]: + print( + "ADC-757 closure refused: %d required families remain deferred" + % len(data["deferred"]), + file=sys.stderr, + ) + return 3 + if args.hardware_report is None or not args.expected_revision: + print( + "ADC-757 closure refused: --hardware-report and --expected-revision are mandatory", + file=sys.stderr, + ) + return 4 + try: + proved = _run_hardware_evidence( + data["hardware_evidence"], args.hardware_report, args.expected_revision + ) + except (OSError, RuntimeError, subprocess.CalledProcessError) as error: + print("ADC-757 closure refused: %s" % error, file=sys.stderr) + return 4 + print( + "ADC-757 authenticated hardware report proves: %s" + % ", ".join(proved), + flush=True, + ) + if args.check_only: + return 0 + checks = sorted( + data["check"], + key=lambda value: ( + value.get("kind", "ctest"), + value.get("target", value.get("path", "")), + value.get("test_regex", value.get("test", "")), + ), + ) + for row in checks: + if row.get("kind") == "pytest": + _run_pytest(row["path"], row["test"]) + else: + _run_ctest(args.build_dir, row["target"], row["test_regex"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_final_gate.py b/scripts/run_final_gate.py index 4980627b5..638b7e694 100644 --- a/scripts/run_final_gate.py +++ b/scripts/run_final_gate.py @@ -2,8 +2,8 @@ """Produce reproducible, integrity-checked evidence for the final PoPS release. The gate has no success switches. It first verifies the exact final source -contract, then builds the installed package, exercises the complete native and -Python conformance suites, executes every final example, independently reopens +contract, then builds the installed package, exercises complete native conformance and the exact +M4/final-example Python release ledger, executes every final example, independently reopens their scientific artifacts, checks their restart evidence, and writes an attestation outside the checkout. ``release_preflight.py --release`` verifies the attestation again against the live installed extension. @@ -16,6 +16,7 @@ import hashlib import importlib.util import json +import math import os from pathlib import Path import re @@ -28,17 +29,21 @@ from final_release_contract import ( FINAL_EXAMPLES, + FINAL_EXAMPLE_REQUIRED_TESTS, + FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS, FINAL_SPECIFICATION, + INSTALLED_COMPONENT_PACKAGE_NODEID, PYTHON_REQUIRED_SELECTION, REQUIRED_PROOF_MARKERS, REQUIRED_RELEASE_GATES, + required_python_conformance_nodeids, require_release_matrix_source_contract, require_source_contract, ) ROOT = Path(__file__).resolve().parents[1] -EVIDENCE_SCHEMA_VERSION = 7 +EVIDENCE_SCHEMA_VERSION = 11 REQUIRED_GATES = REQUIRED_RELEASE_GATES @@ -93,7 +98,11 @@ def _outside_checkout(path: Path) -> Path: raise FinalGateError("--evidence must be outside the checkout: %s" % resolved) -def _conda_command(arguments: Sequence[str]) -> list[str]: +def _conda_command( + arguments: Sequence[str], + *, + pops_include: Path | None = ROOT / "include", +) -> list[str]: """Run inside the same conda installation selected by the gate process. A login shell is deliberately forbidden here: user startup files may rewrite ``PATH`` and @@ -142,7 +151,7 @@ def _conda_command(arguments: Sequence[str]) -> list[str]: "PYTHONPATH=", "PYTHONNOUSERSITE=1", "POPS_REQUIRE_NATIVE_TESTS=1", - "POPS_INCLUDE=" + str((ROOT / "include").resolve()), + "POPS_INCLUDE=" + ("" if pops_include is None else str(pops_include.resolve())), *arguments, ] @@ -327,6 +336,36 @@ def _junit_summary(path: Path) -> dict[str, Any]: } +def _require_junit_nodeids( + path: Path, + required: Sequence[str], +) -> list[str]: + """Authenticate exact pytest tests inside an already all-pass JUnit lane.""" + + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError) as exc: + raise FinalGateError("invalid JUnit report %s: %s" % (path, exc)) from exc + cases = tuple(root.iter("testcase")) + authenticated = [] + for nodeid in required: + relative, function_name = nodeid.split("::", 1) + expected_class = str(Path(relative).with_suffix("")).replace("/", ".") + matches = [ + case + for case in cases + if case.attrib.get("name", "").split("[", 1)[0] == function_name + and case.attrib.get("classname", "").endswith(expected_class) + ] + if len(matches) != 1: + raise FinalGateError( + "required final-example test %s appears %d times in %s" + % (nodeid, len(matches), path) + ) + authenticated.append(nodeid) + return authenticated + + def _require_no_hidden_skip(stdout: str) -> None: """Reject script-style tests which print a skip reason but return success.""" matches = [line.strip() for line in stdout.splitlines() @@ -340,22 +379,88 @@ def _require_no_hidden_skip(stdout: str) -> None: def _reopen_outputs( output_dir: Path, *, example: Path, ) -> tuple[dict[str, Any], tuple[Path, ...], tuple[Path, ...]]: - hdf5_paths = sorted(path for path in output_dir.rglob("*.h5") if path.is_file() and path.stat().st_size) - npz_paths = sorted(path for path in output_dir.rglob("*.npz") if path.is_file() and path.stat().st_size) - paraview_paths = sorted(path for path in output_dir.rglob("*.vtu") if path.is_file() and path.stat().st_size) - if not hdf5_paths or not npz_paths or not paraview_paths: - raise FinalGateError( - "%s did not produce non-empty HDF5, NPZ and ParaView artifacts" % example) + expectations = FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS.get(example) + if expectations is None: + raise FinalGateError("%s has no scientific-output release ledger" % example) + output_root = output_dir.resolve() + + def roots(format_name: str) -> tuple[Path, ...]: + resolved = tuple( + (output_dir / expectation["artifact_root"]).resolve() + for expectation in expectations[format_name] + ) + if any(not root.is_relative_to(output_root) for root in resolved): + raise FinalGateError( + "%s has an escaping %s scientific artifact root" + % (example, format_name) + ) + return resolved + + def files(format_name: str, suffix: str) -> list[Path]: + paths = [] + for expectation, artifact_root in zip( + expectations[format_name], roots(format_name), strict=True, + ): + matches = sorted( + path + for path in artifact_root.rglob("*" + suffix) + if path.is_file() and path.stat().st_size + ) + if not matches: + raise FinalGateError( + "%s did not produce a non-empty %s scientific artifact %s in %s" + % (example, format_name, suffix, expectation["artifact_root"]) + ) + paths.extend(matches) + return paths + + hdf5_paths = files("hdf5", ".h5") + npz_paths = files("npz", ".npz") + paraview_vtu_paths = files("paraview", ".vtu") + paraview_pvd_paths = files("paraview", ".pvd") + paraview_paths = sorted((*paraview_vtu_paths, *paraview_pvd_paths)) for path in hdf5_paths: if path.read_bytes()[:8] != b"\x89HDF\r\n\x1a\n": raise FinalGateError("HDF5 artifact has an invalid signature: %s" % path) - for path in paraview_paths: + for path in paraview_vtu_paths: try: root = ET.parse(path).getroot() except ET.ParseError as exc: raise FinalGateError("invalid ParaView XML %s: %s" % (path, exc)) from exc - if root.tag != "VTKFile": - raise FinalGateError("ParaView artifact is not a VTKFile: %s" % path) + if root.tag != "VTKFile" or root.attrib.get("type") != "UnstructuredGrid": + raise FinalGateError("ParaView artifact is not an UnstructuredGrid VTKFile: %s" % path) + expected_vtu_paths = {path.resolve() for path in paraview_vtu_paths} + paraview_roots = roots("paraview") + for path in paraview_pvd_paths: + try: + root = ET.parse(path).getroot() + except ET.ParseError as exc: + raise FinalGateError("invalid ParaView collection XML %s: %s" % (path, exc)) from exc + collection = root.find("Collection") + datasets = () if collection is None else tuple(collection.findall("DataSet")) + if root.tag != "VTKFile" or root.attrib.get("type") != "Collection" or not datasets: + raise FinalGateError("ParaView artifact is not a non-empty PVD collection: %s" % path) + containing_roots = tuple( + artifact_root for artifact_root in paraview_roots + if path.resolve().is_relative_to(artifact_root) + ) + if len(containing_roots) != 1: + raise FinalGateError("ParaView collection has an ambiguous artifact root: %s" % path) + for dataset in datasets: + relative = dataset.attrib.get("file") + timestep = dataset.attrib.get("timestep") + try: + time_value = float(timestep) if timestep is not None else float("nan") + except ValueError: + time_value = float("nan") + if not relative or not math.isfinite(time_value): + raise FinalGateError("ParaView collection has an invalid DataSet row: %s" % path) + referenced = (path.parent / relative).resolve() + if not referenced.is_relative_to(containing_roots[0]) \ + or referenced not in expected_vtu_paths: + raise FinalGateError( + "ParaView collection references an absent or escaping VTU: %s" % referenced + ) for path in npz_paths: if path.read_bytes()[:4] != b"PK\x03\x04": raise FinalGateError("NPZ artifact has an invalid ZIP signature: %s" % path) @@ -430,7 +535,8 @@ def _run_examples( reopened[example.as_posix()], hdf5_paths, npz_paths = _reopen_outputs( destination, example=example) _reopen_hdf5_with_installed_runtime(recorder, hdf5_paths) - _reopen_npz_with_installed_runtime(recorder, npz_paths) + if npz_paths: + _reopen_npz_with_installed_runtime(recorder, npz_paths) restarted[example.as_posix()] = { "checkpoint": str(checkpoint), "tree_sha256": _tree_hash(checkpoint), @@ -557,17 +663,51 @@ def main(argv: Sequence[str] | None = None) -> int: recorder.rows["native_conformance"]["evidence"] = { "required_lane": _junit_summary(native_junit), } - recorder.run("python_conformance", _conda_command( - ["python", "-m", "pytest", "-q"])) + python_nodeids = required_python_conformance_nodeids(ROOT) python_junit = evidence_root / "reports" / "python-required-conformance.xml" required_stdout = recorder.run("python_conformance", _conda_command([ - "python", "-m", "pytest", "-q", "-s", "-m", PYTHON_REQUIRED_SELECTION, + "python", "-m", "pytest", "-q", "-s", + "-o", "xfail_strict=true", + *python_nodeids, "--junitxml", str(python_junit), ])) _require_no_hidden_skip(required_stdout) + authenticated_python_nodeids = _require_junit_nodeids( + python_junit, python_nodeids + ) + installed_component_junit = ( + evidence_root / "reports" / "installed-component-package.xml" + ) + installed_component_stdout = recorder.run( + "python_conformance", + _conda_command( + [ + "POPS_PROVE_INSTALLED_COMPONENT_PACKAGE=1", + "python", + "-m", + "pytest", + "-q", + "-s", + "-o", + "xfail_strict=true", + INSTALLED_COMPONENT_PACKAGE_NODEID, + "--junitxml", + str(installed_component_junit), + ], + pops_include=None, + ), + ) + _require_no_hidden_skip(installed_component_stdout) recorder.rows["python_conformance"]["evidence"] = { "required_lane": _junit_summary(python_junit), "selection": PYTHON_REQUIRED_SELECTION, + "nodeids": authenticated_python_nodeids, + "final_example_nodeids": list(FINAL_EXAMPLE_REQUIRED_TESTS), + "installed_component_package": { + "nodeid": INSTALLED_COMPONENT_PACKAGE_NODEID, + "headers": "installed-wheel", + "lane": _junit_summary(installed_component_junit), + }, } signed_runtime_sha256 = _signed_runtime_sha256( recorder.rows["codesign"]["evidence"], diff --git a/scripts/run_m4_gate.py b/scripts/run_m4_gate.py new file mode 100644 index 000000000..615bbbd92 --- /dev/null +++ b/scripts/run_m4_gate.py @@ -0,0 +1,843 @@ +#!/usr/bin/env python3 +"""Audit and run the fail-closed M4 native-runtime/IO conformance matrix.""" + +from __future__ import annotations + +import argparse +import ast +from collections import Counter, defaultdict +from collections.abc import Iterable +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +import xml.etree.ElementTree as ET + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST = ROOT / "tests/gates/m4_runtime_io.toml" +TEST_MANIFEST = ROOT / "tests/test_manifest.toml" +EXPECTED_ISSUES = tuple("ADC-%d" % number for number in range(679, 688)) +REQUIRED_POLARITIES = { + "component_manifest": {"positive", "refusal"}, + "generated_registry": {"positive", "refusal"}, + "external_package": {"positive", "refusal"}, + "external_flux": {"positive"}, + "external_boundary": {"positive"}, + "external_tagger": {"positive"}, + "external_transfer": {"positive"}, + "external_solver": {"positive"}, + "external_writer": {"positive"}, + "native_interfaces": {"positive", "refusal"}, + "flux_contract": {"positive", "refusal"}, + "platform_execution": {"positive", "refusal"}, + "runtime_instance": {"positive", "refusal"}, + "consumer_graph": {"positive", "refusal"}, + "accepted_publication": {"positive", "refusal"}, + "exact_npz": {"positive", "refusal"}, + "exact_hdf5": {"positive", "refusal"}, + "exact_paraview": {"positive", "refusal"}, + "collective_hdf5": {"positive"}, + "strict_checkpoint": {"positive", "refusal"}, + "diagnostics": {"positive", "refusal"}, + "tamper_capability_abi": {"refusal"}, + "legacy_stepper_retirement": {"positive"}, + "gate_execution": {"positive"}, +} +REQUIREMENT_ISSUES = { + "component_manifest": {"ADC-679"}, + "generated_registry": {"ADC-679"}, + "external_package": {"ADC-680"}, + "external_flux": {"ADC-680"}, + "native_interfaces": {"ADC-681"}, + "external_boundary": {"ADC-681"}, + "external_tagger": {"ADC-681"}, + "flux_contract": {"ADC-682"}, + "platform_execution": {"ADC-683"}, + "runtime_instance": {"ADC-684"}, + "external_transfer": {"ADC-684"}, + "external_writer": {"ADC-685"}, + "consumer_graph": {"ADC-685"}, + "accepted_publication": {"ADC-685"}, + "exact_npz": {"ADC-686"}, + "exact_hdf5": {"ADC-686"}, + "exact_paraview": {"ADC-686"}, + "collective_hdf5": {"ADC-686"}, + "strict_checkpoint": {"ADC-686"}, + "diagnostics": {"ADC-686"}, + "external_solver": {"ADC-687"}, + "legacy_stepper_retirement": {"ADC-687"}, + "gate_execution": {"ADC-687"}, + "tamper_capability_abi": {"ADC-679", "ADC-680", "ADC-683", "ADC-687"}, +} +NATIVE_PYTEST_PREFIXES = ( + "tests/python/integration/amr/", + "tests/python/integration/io/", + "tests/python/integration/mpi/", + "tests/python/integration/native_loader/", + "tests/python/integration/runtime/", +) +_GTEST_DECLARATION = re.compile( + r"\bTEST(?:_F)?\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*" + r"([A-Za-z_][A-Za-z0-9_]*)\s*\)" +) +_CPP_RAW_STRING_START = re.compile(r'(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(') +_MOCK_FIXTURES = {"monkeypatch", "mocker", "mock", "patch"} +_FORBIDDEN_CALLS = { + "pytest.skip", + "pytest.xfail", + "pytest.importorskip", + "unittest.mock.patch", + "mock.patch", + "require_mpi_or_skip", +} + + +def _dotted_name(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + prefix = _dotted_name(node.value) + return "%s.%s" % (prefix, node.attr) if prefix else node.attr + if isinstance(node, ast.Call): + return _dotted_name(node.func) + return "" + + +def _forbidden_python_markers(node: ast.AST) -> list[str]: + markers: list[str] = [] + for decorator in getattr(node, "decorator_list", ()): + name = _dotted_name(decorator) + if name.endswith((".skip", ".skipif", ".xfail")) or name in { + "skip", + "skipif", + "xfail", + }: + markers.append(name) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + fixtures = { + argument.arg + for argument in ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ) + } + markers.extend("fixture:%s" % name for name in sorted(fixtures & _MOCK_FIXTURES)) + for child in ast.walk(node): + if isinstance(child, ast.Call): + name = _dotted_name(child.func) + if name in _FORBIDDEN_CALLS or name.endswith( + (".importorskip", ".skip", ".xfail", ".mock", ".patch") + ): + markers.append(name) + elif isinstance(child, (ast.Import, ast.ImportFrom)): + module = (child.module or "") if isinstance(child, ast.ImportFrom) else "" + names = [alias.name for alias in child.names] + if module.startswith(("unittest.mock", "pytest_mock")) or any( + name.startswith(("unittest.mock", "pytest_mock")) for name in names + ): + markers.append("mock-import") + elif isinstance(child, ast.Try): + for handler in child.handlers: + caught = _dotted_name(handler.type) if handler.type is not None else "" + if caught in {"ImportError", "ModuleNotFoundError"}: + markers.append("optional-import-fallback") + return markers + + +def _has_authenticated_mpi_guard(module: ast.Module) -> bool: + return any( + isinstance(node, ast.ImportFrom) + and node.module == "tests.python.support.requirements" + and any(alias.name == "require_mpi_or_skip" for alias in node.names) + for node in ast.walk(module) + ) + + +def _ctest_suites() -> dict[str, dict]: + data = tomllib.loads(TEST_MANIFEST.read_text(encoding="utf-8")) + return {str(row["name"]): row for row in data.get("cpp", {}).get("suite", ())} + + +def _python_suites() -> tuple[dict, ...]: + data = tomllib.loads(TEST_MANIFEST.read_text(encoding="utf-8")) + return tuple(data.get("python", {}).get("suite", ())) + + +def _python_mpi_entrypoints() -> dict[str, int]: + entries: dict[str, int] = {} + for suite in _python_suites(): + for row in suite.get("mpi_entrypoints", ()): + path = str(row.get("path", "")) + nproc = row.get("nproc") + if not path or isinstance(nproc, bool) or not isinstance(nproc, int) or nproc < 1: + raise ValueError("invalid Python MPI entrypoint %r" % row) + if path in entries: + raise ValueError("duplicate Python MPI entrypoint %s" % path) + entries[path] = nproc + return entries + + +def _python_mpi_orchestrators() -> set[str]: + orchestrators: set[str] = set() + for suite in _python_suites(): + for row in suite.get("mpi_orchestrators", ()): + if not isinstance(row, dict) or set(row) != {"path"}: + raise ValueError( + "invalid Python MPI orchestrator %r; expected exactly one path field" + % row + ) + path = row["path"] + if not isinstance(path, str) or not path: + raise ValueError("invalid Python MPI orchestrator path %r" % path) + if path in orchestrators: + raise ValueError("duplicate Python MPI orchestrator %s" % path) + orchestrators.add(path) + return orchestrators + + +def _python_suite_owns(relative: str) -> bool: + path = Path(relative) + return any( + path == Path(str(suite.get("path", ""))) + or Path(str(suite.get("path", ""))) in path.parents + for suite in _python_suites() + ) + + +def _cpp_code_only(source: str) -> str: + """Mask comments and literals while preserving source positions and newlines.""" + code = list(source) + size = len(source) + + def mask(begin: int, end: int) -> None: + for offset in range(begin, end): + if code[offset] != "\n": + code[offset] = " " + + index = 0 + while index < size: + if source.startswith("//", index): + end = source.find("\n", index + 2) + end = size if end < 0 else end + mask(index, end) + index = end + continue + if source.startswith("/*", index): + end = source.find("*/", index + 2) + end = size if end < 0 else end + 2 + mask(index, end) + index = end + continue + raw = _CPP_RAW_STRING_START.match(source, index) + if raw is not None: + terminator = ")" + raw.group(1) + '"' + end = source.find(terminator, raw.end()) + end = size if end < 0 else end + len(terminator) + mask(index, end) + index = end + continue + if source[index] in {'"', "'"}: + quote = source[index] + end = index + 1 + while end < size: + if source[end] == "\\": + end = min(size, end + 2) + continue + end += 1 + if source[end - 1] == quote: + break + mask(index, end) + index = end + continue + index += 1 + return "".join(code) + + +def _registered_gtest_cases(source: str) -> set[str]: + return { + "%s.%s" % declaration + for declaration in _GTEST_DECLARATION.findall(_cpp_code_only(source)) + } + + +def _registered_ctest_cases(target: str, suite: dict) -> set[str]: + cases: set[str] = set() + for relative in suite.get("sources", ()): + source = ROOT / relative + if source.is_file(): + cases.update(_registered_gtest_cases(source.read_text(encoding="utf-8"))) + for field in ("mpi_nproc", "mpi_rank_parity", "mpi_variants"): + cases.update( + "%s_np%d" % (target, nproc) + for nproc in suite.get(field, ()) + if not isinstance(nproc, bool) and isinstance(nproc, int) and nproc > 0 + ) + return cases + + +def _validate_exact_ctest_selector( + selector: object, + target: str, + suite: dict, + where: str, + errors: list[str], +) -> None: + if not isinstance(selector, str) or not selector: + errors.append("%s CTest row requires a non-empty test_regex" % where) + return + exact = { + "^%s$" % re.escape(case) + for case in _registered_ctest_cases(target, suite) + } + if selector not in exact: + errors.append( + "%s CTest selector %r is not one exact source-registered case for target %r" + % (where, selector, target) + ) + + +def _validate_python_nodeid( + nodeid: object, + where: str, + errors: list[str], + *, + mpi_entrypoint: bool = False, +) -> str | None: + if not isinstance(nodeid, str) or nodeid.count("::") != 1: + errors.append("%s must contain one exact file::test nodeid" % where) + return None + relative, function_name = nodeid.split("::") + test_path = ROOT / relative + if not test_path.is_file(): + errors.append("%s references missing test file %s" % (where, relative)) + return None + if not _python_suite_owns(relative): + errors.append("%s is not owned by tests/test_manifest.toml" % relative) + tree = ast.parse(test_path.read_text(encoding="utf-8"), filename=str(test_path)) + functions = { + node.name: node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + function = functions.get(function_name) + if function is None: + errors.append("%s references missing test function %s" % (where, nodeid)) + return None + module_nodes = [ + node + for node in tree.body + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + ] + markers = _forbidden_python_markers(function) + module = ast.Module(body=module_nodes, type_ignores=[]) + module_markers = _forbidden_python_markers(module) + if mpi_entrypoint and "require_mpi_or_skip" in module_markers: + if not _has_authenticated_mpi_guard(module): + errors.append( + "%s uses an unauthenticated MPI prerequisite guard" % nodeid + ) + module_markers = [ + marker for marker in module_markers + if marker != "require_mpi_or_skip" + ] + markers.extend(module_markers) + if markers: + errors.append( + "%s is not an unconditional real proof; found %s" + % (nodeid, sorted(set(markers))) + ) + return relative + + +def _validate_deferred( + data: dict, errors: list[str] +) -> set[tuple[str, str, str]]: + rows = data.get("deferred") + if not isinstance(rows, list): + errors.append("deferred must be an array of explicit gap tables") + return set() + gaps: set[tuple[str, str, str]] = set() + identities = Counter() + for index, row in enumerate(rows, 1): + where = "deferred[%d]" % index + expected = { + "issue", + "requirement", + "polarity", + "reason", + "evidence_paths", + } + if not isinstance(row, dict) or set(row) != expected: + errors.append( + "%s must contain issue/requirement/polarity/reason/evidence_paths" + % where + ) + continue + issue = row.get("issue") + requirement = row.get("requirement") + polarity = row.get("polarity") + reason = row.get("reason") + evidence_paths = row.get("evidence_paths") + if issue not in EXPECTED_ISSUES: + errors.append("%s has unknown issue %r" % (where, issue)) + if requirement not in REQUIRED_POLARITIES: + errors.append("%s has unknown requirement %r" % (where, requirement)) + elif issue not in REQUIREMENT_ISSUES[requirement]: + errors.append( + "%s requirement %r cannot be deferred under %r" + % (where, requirement, issue) + ) + if polarity not in {"positive", "refusal"}: + errors.append("%s polarity must be positive or refusal" % where) + elif ( + requirement in REQUIRED_POLARITIES + and polarity not in REQUIRED_POLARITIES[requirement] + ): + errors.append( + "%s requirement %r has no %r polarity" + % (where, requirement, polarity) + ) + if not isinstance(reason, str) or len(reason.strip()) < 20: + errors.append("%s requires a precise non-empty reason" % where) + if not isinstance(evidence_paths, list) or not evidence_paths: + errors.append("%s requires at least one evidence path" % where) + else: + for relative in evidence_paths: + if not isinstance(relative, str) or not relative: + errors.append("%s has an invalid evidence path %r" % (where, relative)) + elif not (ROOT / relative).exists(): + errors.append( + "%s gap evidence path no longer exists: %s" % (where, relative) + ) + identity = (str(issue), str(requirement), str(polarity)) + identities[identity] += 1 + gaps.add(identity) + duplicates = sorted(identity for identity, count in identities.items() if count > 1) + if duplicates: + errors.append("duplicate deferred gaps: %s" % duplicates) + return gaps + + +def audit_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: + """Return source-only structural errors without pretending deferred gaps are closed.""" + errors: list[str] = [] + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + return {}, ["cannot read M4 gate manifest %s: %s" % (path, exc)] + + if data.get("schema_version") != 1: + errors.append("schema_version must be exactly 1") + if data.get("gate") != "m4-runtime-io": + errors.append("gate must be exactly 'm4-runtime-io'") + if set(data) != {"schema_version", "gate", "issues", "deferred", "check"}: + errors.append("manifest fields must be schema_version/gate/issues/deferred/check") + if data.get("issues") != list(EXPECTED_ISSUES): + errors.append("issues must list ADC-679..ADC-687 exactly once") + + deferred_gaps = _validate_deferred(data, errors) + checks = data.get("check") + if not isinstance(checks, list) or not checks: + errors.append("manifest must contain [[check]] rows") + checks = [] + + identities = Counter() + issue_coverage: dict[str, set[str]] = defaultdict(set) + requirement_coverage: dict[str, set[str]] = defaultdict(set) + native_positive_issues: set[str] = set() + cpp_suites = _ctest_suites() + try: + mpi_entrypoints = _python_mpi_entrypoints() + mpi_orchestrators = _python_mpi_orchestrators() + except (OSError, tomllib.TOMLDecodeError, ValueError) as exc: + errors.append("cannot read Python MPI ownership: %s" % exc) + mpi_entrypoints = {} + mpi_orchestrators = set() + + for index, row in enumerate(checks, 1): + where = "check[%d]" % index + base = {"issue", "requirement", "polarity", "kind", "target"} + kind = row.get("kind") if isinstance(row, dict) else None + expected = ( + base | {"nodeid", "nproc"} + if kind == "mpi_python" + else base | ({"nodeid"} if kind == "pytest" else {"test_regex"}) + ) + if not isinstance(row, dict) or set(row) != expected: + errors.append("%s has unknown or missing fields: %s" % (where, sorted(row))) + continue + + issue = row.get("issue") + requirement = row.get("requirement") + polarity = row.get("polarity") + target = row.get("target") + if issue not in EXPECTED_ISSUES: + errors.append("%s has unknown issue %r" % (where, issue)) + if requirement not in REQUIRED_POLARITIES: + errors.append("%s has unknown requirement %r" % (where, requirement)) + elif issue not in REQUIREMENT_ISSUES[requirement]: + errors.append( + "%s requirement %r cannot be attributed to %r" + % (where, requirement, issue) + ) + if kind != "ctest" and target != requirement: + errors.append( + "%s target must equal its exact requirement %r" % (where, requirement) + ) + if polarity not in {"positive", "refusal"}: + errors.append("%s polarity must be positive or refusal" % where) + else: + issue_coverage[str(issue)].add(polarity) + requirement_coverage[str(requirement)].add(polarity) + + identity = (kind, row.get("nodeid", row.get("test_regex"))) + identities[identity] += 1 + if kind == "pytest": + relative = _validate_python_nodeid(row.get("nodeid"), where, errors) + if ( + relative is not None + and relative.startswith("tests/python/integration/mpi/") + and relative not in mpi_orchestrators + ): + errors.append( + "%s is not a manifest-owned serial MPI orchestrator" % relative + ) + if ( + polarity == "positive" + and relative is not None + and relative.startswith(NATIVE_PYTEST_PREFIXES) + ): + native_positive_issues.add(str(issue)) + elif kind == "mpi_python": + relative = _validate_python_nodeid( + row.get("nodeid"), + where, + errors, + mpi_entrypoint=True, + ) + nproc = row.get("nproc") + if isinstance(nproc, bool) or not isinstance(nproc, int) or nproc < 1: + errors.append("%s MPI Python row requires a positive integer nproc" % where) + elif relative is not None: + expected_nproc = mpi_entrypoints.get(relative) + if expected_nproc is None: + errors.append("%s is not a manifest-owned MPI Python entrypoint" % relative) + elif expected_nproc != nproc: + errors.append( + "%s requires nproc=%d, not %d" + % (relative, expected_nproc, nproc) + ) + if polarity == "positive": + native_positive_issues.add(str(issue)) + elif kind == "ctest": + target_name = row.get("target") + # CTest rows still carry the semantic requirement in target, so the + # build target is encoded as "requirement@ctest-target". + if not isinstance(target_name, str) or "@" not in target_name: + errors.append( + "%s CTest target must be requirement@manifest-suite" % where + ) + continue + semantic, suite_name = target_name.split("@", 1) + if semantic != requirement: + errors.append( + "%s CTest target must start with requirement %r" % (where, requirement) + ) + suite = cpp_suites.get(suite_name) + if suite is None: + errors.append("%s references unknown CTest target %r" % (where, suite_name)) + continue + _validate_exact_ctest_selector( + row.get("test_regex"), suite_name, suite, where, errors + ) + for relative in suite.get("sources", ()): + source = ROOT / relative + if not source.is_file(): + errors.append( + "%s target %r has missing source %s" + % (where, suite_name, relative) + ) + else: + text = source.read_text(encoding="utf-8") + if "DISABLED_" in text: + errors.append( + "%s target %r contains a disabled test" % (where, suite_name) + ) + if polarity == "positive": + native_positive_issues.add(str(issue)) + else: + errors.append("%s kind must be pytest, mpi_python, or ctest" % where) + + duplicates = sorted(identity for identity, count in identities.items() if count > 1) + if duplicates: + errors.append("duplicate executable checks: %s" % duplicates) + for issue in EXPECTED_ISSUES: + missing = {"positive", "refusal"} - issue_coverage[issue] + unresolved = { + polarity + for polarity in missing + if not any( + deferred_issue == issue and deferred_polarity == polarity + for deferred_issue, _requirement, deferred_polarity in deferred_gaps + ) + } + if unresolved: + errors.append( + "%s lacks %s coverage" + % (issue, "/".join(sorted(unresolved))) + ) + if issue not in native_positive_issues: + errors.append("%s lacks a mandatory native positive proof" % issue) + for requirement, required in sorted(REQUIRED_POLARITIES.items()): + missing = required - requirement_coverage[requirement] + unresolved = { + polarity + for polarity in missing + if not any( + deferred_requirement == requirement + and deferred_polarity == polarity + for _issue, deferred_requirement, deferred_polarity in deferred_gaps + ) + } + if unresolved: + errors.append( + "%s lacks %s coverage" + % (requirement, "/".join(sorted(unresolved))) + ) + return data, errors + + +def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: + """Fail closed when even one structurally valid M4 requirement is deferred.""" + data, errors = audit_manifest(path) + if errors: + return data, errors + for row in data["deferred"]: + errors.append( + "%s/%s/%s remains deferred: %s" + % ( + row["issue"], + row["requirement"], + row["polarity"], + row["reason"], + ) + ) + return data, errors + + +def _run(command: list[str], *, env: dict[str, str] | None = None) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, check=True, env=env) + + +def _required_environment() -> dict[str, str]: + environment = os.environ.copy() + environment["POPS_REQUIRE_MPI_TESTS"] = "1" + environment["POPS_REQUIRE_NATIVE_TESTS"] = "1" + root = str(ROOT) + inherited = environment.get("PYTHONPATH", "") + python_path = [root] + python_path.extend( + entry + for entry in inherited.split(os.pathsep) + if entry and entry != root + ) + environment["PYTHONPATH"] = os.pathsep.join(python_path) + return environment + + +def _mpi_python_command(mpi_exec: str, nproc: int, relative: str) -> list[str]: + if shutil.which(mpi_exec) is None: + raise RuntimeError("required MPI launcher %r is unavailable" % mpi_exec) + return [mpi_exec, "-n", str(nproc), sys.executable, str(ROOT / relative)] + + +def _junit_skip_count(report: Path, producer: str) -> int: + if not report.is_file(): + raise RuntimeError("M4 %s did not produce its mandatory JUnit report" % producer) + try: + root = ET.parse(report).getroot() + except ET.ParseError as exc: + raise RuntimeError("M4 %s produced an invalid JUnit report" % producer) from exc + return len(root.findall(".//skipped")) + + +def _run_required_pytest(nodeids: list[str]) -> None: + environment = _required_environment() + with tempfile.TemporaryDirectory(prefix="pops-m4-gate-") as temporary: + report = Path(temporary) / "pytest.xml" + command = [ + sys.executable, + "-m", + "pytest", + "-q", + "--strict-markers", + "-o", + "xfail_strict=true", + "--junitxml", + str(report), + *nodeids, + ] + print( + "+ POPS_REQUIRE_MPI_TESTS=1 POPS_REQUIRE_NATIVE_TESTS=1", + " ".join(command), + flush=True, + ) + completed = subprocess.run( + command, + cwd=ROOT, + env=environment, + check=False, + ) + skipped = _junit_skip_count(report, "pytest") + if skipped: + raise RuntimeError( + "M4 pytest reported %d skipped/xfail proof(s); every proof is mandatory" + % skipped + ) + if completed.returncode != 0: + raise subprocess.CalledProcessError(completed.returncode, command) + + +def _chunks(values: list[str], size: int) -> Iterable[list[str]]: + for index in range(0, len(values), size): + yield values[index : index + size] + + +def _run_ctest(build_dir: Path, target: str, selector: str) -> None: + listed = subprocess.run( + ["ctest", "--test-dir", str(build_dir), "-N", "-R", selector], + cwd=ROOT, + check=True, + text=True, + capture_output=True, + ) + if "Total Tests: 0" in listed.stdout or "Test #" not in listed.stdout: + raise RuntimeError( + "M4 CTest target %r (%s) is not built in %s" + % (target, selector, build_dir) + ) + with tempfile.TemporaryDirectory(prefix="pops-m4-ctest-") as temporary: + report = Path(temporary) / "ctest.xml" + command = [ + "ctest", + "--test-dir", + str(build_dir), + "--output-on-failure", + "--output-junit", + str(report), + "-R", + selector, + ] + print("+", " ".join(command), flush=True) + completed = subprocess.run(command, cwd=ROOT, check=False) + skipped = _junit_skip_count(report, "CTest") + if skipped: + raise RuntimeError( + "M4 CTest %r reported %d skipped proof(s)" % (selector, skipped) + ) + if completed.returncode != 0: + raise subprocess.CalledProcessError(completed.returncode, command) + + +def _required_ctest_targets(checks: Iterable[dict]) -> tuple[str, ...]: + """Return the exact native build targets needed by the selected CTest proofs.""" + return tuple( + sorted( + { + row["target"].split("@", 1)[1] + for row in checks + if row["kind"] == "ctest" + } + ) + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--audit-only", + action="store_true", + help="verify exact evidence and explicit gaps without claiming M4 closure", + ) + mode.add_argument("--check-only", action="store_true") + mode.add_argument( + "--list-ctest-targets", + action="store_true", + help="print the exact native targets required by a closed manifest", + ) + parser.add_argument("--python-only", action="store_true") + parser.add_argument("--build-dir", type=Path, default=ROOT / "build-mpi") + parser.add_argument("--mpi-exec", default="mpiexec") + args = parser.parse_args(argv) + + if args.audit_only: + data, errors = audit_manifest(args.manifest) + else: + data, errors = validate_manifest(args.manifest) + if errors: + print("M4 gate is incomplete or invalid:", file=sys.stderr) + for error in errors: + print(" -", error, file=sys.stderr) + return 2 + + checks = data["check"] + if args.list_ctest_targets: + targets = _required_ctest_targets(checks) + if not targets: + print("M4 gate selects no CTest build target", file=sys.stderr) + return 2 + print("\n".join(targets)) + return 0 + print( + "M4 gate source matrix: %s (%d executable, %d deferred)" + % ( + ( + "AUDITED OPEN" + if data["deferred"] + else "AUDITED CLOSED" + ) + if args.audit_only + else "CLOSED", + len(checks), + len(data["deferred"]), + ) + ) + if args.audit_only or args.check_only: + return 0 + + nodeids = [row["nodeid"] for row in checks if row["kind"] == "pytest"] + for chunk in _chunks(nodeids, 24): + _run_required_pytest(chunk) + mpi_entrypoints = sorted( + { + (row["nodeid"].split("::", 1)[0], row["nproc"]) + for row in checks + if row["kind"] == "mpi_python" + } + ) + for relative, nproc in mpi_entrypoints: + _run( + _mpi_python_command(args.mpi_exec, nproc, relative), + env=_required_environment(), + ) + if not args.python_only: + for row in sorted( + (row for row in checks if row["kind"] == "ctest"), + key=lambda value: (value["target"], value["test_regex"]), + ): + _semantic, target = row["target"].split("@", 1) + _run_ctest(args.build_dir, target, row["test_regex"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1bc8a7f13..81b624d60 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -37,6 +37,7 @@ set(POPS_RUNTIME_SYSTEM_SOURCES set(POPS_RUNTIME_AMR_SOURCES runtime/amr/amr_field_solver_builtin.cpp + runtime/amr/amr_field_solver_component.cpp runtime/amr/amr_system.cpp runtime/builders/amr/block/compressible/amr_block_compressible.cpp ${POPS_RUNTIME_AMR_GENERATED_SEAMS}) diff --git a/src/runtime/amr/amr_field_solver_builtin.cpp b/src/runtime/amr/amr_field_solver_builtin.cpp index 17d02fa5d..669ef132a 100644 --- a/src/runtime/amr/amr_field_solver_builtin.cpp +++ b/src/runtime/amr/amr_field_solver_builtin.cpp @@ -99,8 +99,7 @@ bool fully_refines_every_level(const AmrFieldSolverBuildRequest& request) { return false; int parent_refinement = 1; for (int level = 0; level + 1 < request.hierarchy.nlev(); ++level) { - const int ratio = - request.hierarchy.refinement_ratios.at(static_cast(level)); + const int ratio = request.hierarchy.refinement_ratios.at(static_cast(level)); if (ratio <= 0) return false; const Geometry parent_geometry = request.geometry.refine(parent_refinement); @@ -108,8 +107,7 @@ bool fully_refines_every_level(const AmrFieldSolverBuildRequest& request) { std::uint64_t covered_cells = 0; for (int patch = 0; patch < children.size(); ++patch) { const Box2D footprint = children[patch].coarsen(ratio); - if (footprint.refine(ratio) != children[patch] || - !parent_geometry.domain.contains(footprint)) + if (footprint.refine(ratio) != children[patch] || !parent_geometry.domain.contains(footprint)) return false; for (int previous = 0; previous < patch; ++previous) if (!footprint.intersect(children[previous].coarsen(ratio)).empty()) @@ -216,12 +214,15 @@ class PreparedGeometricMgFieldSolver final : public AmrPreparedFieldSolver { for (auto& solver : level_solvers_) solver->set_boundary_context(context); } - void set_boundary_context_for_level(int level, - const FieldBoundaryExecutionContext& context) override { + void set_boundary_context_at_level(int level, + const FieldBoundaryExecutionContext& context) override { if (fac_) { - fac_->set_boundary_context_for_level(level, context); + fac_->set_boundary_context_at_level(level, context); return; } + if (level < 0 || level >= level_count()) + throw std::out_of_range( + "geometric-MG boundary context level is outside the prepared hierarchy"); level_solvers_.at(static_cast(level))->set_boundary_context(context); } SolveReport solve() override { @@ -282,7 +283,7 @@ class GeometricMgFieldSolverProvider final : public AmrFieldSolverProvider { "pops.amr.field-solver.geometric-mg.distributed-coarse@1", "pops.amr.field-solver.geometric-mg.dynamic-boundary@1", "pops.amr.field-solver.geometric-mg.exact-preparation@1", - "pops.amr.field-solver.geometric-mg.level-qualified-linear-boundary@1", + "pops.amr.field-solver.geometric-mg.level-qualified-dynamic-boundary@1", "pops.amr.field-solver.geometric-mg.level-local-hierarchy@1", "pops.amr.field-solver.geometric-mg.nonlinear-boundary@1", "pops.amr.field-solver.geometric-mg.reaction@1", diff --git a/src/runtime/amr/amr_field_solver_component.cpp b/src/runtime/amr/amr_field_solver_component.cpp new file mode 100644 index 000000000..63293b546 --- /dev/null +++ b/src/runtime/amr/amr_field_solver_component.cpp @@ -0,0 +1,721 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { +namespace { + +using runtime::field::PreparedFieldSolverSpec; + +constexpr std::string_view kExternalOptionsSchema = "pops.external.field-solver-request@2"; +constexpr std::string_view kCompositePolicy = "pops.field-hierarchy.composite"; + +std::string hashed_identity(std::string_view domain, std::string_view payload) { + const std::vector bytes(payload.begin(), payload.end()); + return "pops." + std::string(domain) + ".v1:sha256:" + identity::sha256_hex(bytes); +} + +std::string exact_external_provider_contract(const PreparedFieldSolverSpec& spec) { + ExactContractBuilder contract; + contract.text("pops.runtime.external-amr-field-solver-provider") + .scalar(std::uint32_t{1}) + .text(spec.provider_slot) + .text(spec.topology_component_id) + .text(spec.topology_manifest_identity) + .scalar(spec.topology_interface_version) + .text(spec.topology_parameters_json) + .text(spec.solver_component_id) + .text(spec.solver_manifest_identity) + .scalar(spec.solver_interface_version) + .text(spec.solver_parameters_json) + .text(spec.source_layout_identity) + .text(spec.topology_recipe_identity) + .text(spec.boundary_contract_json) + .scalar(spec.relative_tolerance) + .scalar(spec.absolute_tolerance) + .scalar(spec.max_iterations) + .scalar(spec.component_pair_declares_mpi) + .text(spec.execution == nullptr ? "" : spec.execution->identity()); + return std::move(contract).release(); +} + +AmrFieldSolverOptions external_options(const PreparedFieldSolverSpec& spec) { + return {std::string(kExternalOptionsSchema), + {{"absolute_tolerance", spec.absolute_tolerance}, + {"max_iterations", static_cast(spec.max_iterations)}, + {"relative_tolerance", spec.relative_tolerance}}}; +} + +bool exact_external_options(const AmrFieldSolverOptions& options, + const PreparedFieldSolverSpec& spec) noexcept { + try { + if (options.schema_identity != kExternalOptionsSchema || options.values.size() != 3) + return false; + return std::get(options.values.at("relative_tolerance")) == spec.relative_tolerance && + std::get(options.values.at("absolute_tolerance")) == spec.absolute_tolerance && + std::get(options.values.at("max_iterations")) == spec.max_iterations; + } catch (...) { + return false; + } +} + +bool exact_composite_policy(const AmrFieldHierarchyPolicyAuthority& authority) noexcept { + return authority.policy_id == kCompositePolicy && authority.interface_version == 1 && + authority.options.schema_identity == "pops.field-hierarchy.options.empty@1" && + authority.options.values.empty(); +} + +bool paired_periodic_boundary(const BCRec& boundary) noexcept { + return (boundary.xlo == BCType::Periodic) == (boundary.xhi == BCType::Periodic) && + (boundary.ylo == BCType::Periodic) == (boundary.yhi == BCType::Periodic); +} + +std::uint32_t periodic_axes(const BCRec& boundary) { + if (!paired_periodic_boundary(boundary)) + throw std::invalid_argument( + "external AMR field solver requires paired periodic boundary faces"); + return (boundary.xlo == BCType::Periodic ? 1u : 0u) | + (boundary.ylo == BCType::Periodic ? 2u : 0u); +} + +void validate_external_execution(const PreparedFieldSolverSpec& spec) { + if (spec.execution == nullptr) + throw std::invalid_argument("external AMR field solver requires an execution authority"); + const PopsExecutionContextV1 execution = spec.execution->view(); + component::validate_execution_context(execution); + if (execution.memory_space != POPS_MEMORY_SPACE_HOST_V1 || + (std::string_view(execution.device_identity) != "host" && + std::string_view(execution.device_identity) != "cpu")) + throw std::invalid_argument("external AMR field solver supports host-resident execution only"); + const std::string_view communicator(execution.communicator_identity); + if (communicator == "serial") { + if (n_ranks() != 1) + throw std::invalid_argument( + "external AMR field solver cannot use a serial component context on multiple ranks"); + return; + } + if (communicator != "MPI_COMM_WORLD" || !spec.component_pair_declares_mpi) + throw std::invalid_argument( + "external AMR field solver MPI requires an exact declared MPI_COMM_WORLD component pair"); +#ifdef POPS_HAS_MPI + int initialized = 0; + detail::require_mpi_success(MPI_Initialized(&initialized), "MPI_Initialized(external field)"); + if (initialized == 0) + throw std::invalid_argument( + "external AMR field solver received MPI authority before MPI initialization"); + int relation = MPI_UNEQUAL; + detail::require_mpi_success( + MPI_Comm_compare(MPI_Comm_f2c(static_cast(execution.communicator_f_handle)), + MPI_COMM_WORLD, &relation), + "MPI_Comm_compare(external field)"); + if (relation != MPI_IDENT || + MPI_Type_f2c(static_cast(execution.communicator_datatype_f_handle)) != MPI_DOUBLE) + throw std::invalid_argument( + "external AMR field solver execution handles are not exact MPI_COMM_WORLD/MPI_DOUBLE"); +#else + throw std::invalid_argument( + "external AMR field solver cannot install MPI execution in a serial PoPS build"); +#endif +} + +SolveStatus solve_status(std::int32_t status) { + switch (status) { + case POPS_SOLVE_SOLVED_V2: + return SolveStatus::kSolved; + case POPS_SOLVE_SINGULAR_V2: + return SolveStatus::kSingular; + case POPS_SOLVE_BREAKDOWN_V2: + return SolveStatus::kBreakdown; + case POPS_SOLVE_ITERATION_LIMIT_V2: + return SolveStatus::kIterationLimit; + case POPS_SOLVE_INVALID_EVALUATION_V2: + return SolveStatus::kInvalidEvaluation; + case POPS_SOLVE_CAPABILITY_FAILURE_V2: + return SolveStatus::kCapabilityFailure; + case POPS_SOLVE_INVALID_INPUT_V2: + return SolveStatus::kInvalidInput; + case POPS_SOLVE_INCOMPATIBLE_RHS_V2: + return SolveStatus::kIncompatibleRhs; + } + throw std::invalid_argument("FieldSolver@2 returned an unknown solve status"); +} + +SolveAction solve_action(std::int32_t action) { + switch (action) { + case POPS_SOLVE_ACTION_NONE_V2: + return SolveAction::kNone; + case POPS_SOLVE_ACTION_FAIL_RUN_V2: + return SolveAction::kFailRun; + case POPS_SOLVE_ACTION_REJECT_ATTEMPT_V2: + return SolveAction::kRejectAttempt; + } + throw std::invalid_argument("FieldSolver@2 returned an unknown solve action"); +} + +const Real* valid_data(const Fab2D& fab, const Box2D& valid) { + const ConstArray4 view = fab.const_array(); + return view.p + static_cast(valid.lo[1] - view.jg0) * view.nx_tot + + (valid.lo[0] - view.ig0); +} + +Real* valid_data(Fab2D& fab, const Box2D& valid) { + const Array4 view = fab.array(); + return view.p + static_cast(valid.lo[1] - view.jg0) * view.nx_tot + + (valid.lo[0] - view.ig0); +} + +PopsConstFieldViewV1 const_view(const Fab2D& fab, const Box2D& valid, const char* layout, + const char* patch) { + const ConstArray4 storage = fab.const_array(); + return {sizeof(PopsConstFieldViewV1), + valid_data(fab, valid), + 2, + {static_cast(valid.nx()), static_cast(valid.ny()), 1}, + {1, storage.nx_tot, 0}, + 1, + storage.comp_stride, + POPS_FIELD_CENTERING_CELL_V1, + 0, + {0, 0, 0}, + {0, 0, 0}, + POPS_SCALAR_FLOAT64_V1, + POPS_MEMORY_SPACE_HOST_V1, + layout, + patch, + POPS_FIELD_OWNERSHIP_RUNTIME_BORROWED_V1}; +} + +PopsFieldViewV1 field_view(Fab2D& fab, const Box2D& valid, const char* layout, const char* patch) { + const Array4 storage = fab.array(); + return {sizeof(PopsFieldViewV1), + valid_data(fab, valid), + 2, + {static_cast(valid.nx()), static_cast(valid.ny()), 1}, + {1, storage.nx_tot, 0}, + 1, + storage.comp_stride, + POPS_FIELD_CENTERING_CELL_V1, + 0, + {0, 0, 0}, + {0, 0, 0}, + POPS_SCALAR_FLOAT64_V1, + POPS_MEMORY_SPACE_HOST_V1, + layout, + patch, + POPS_FIELD_OWNERSHIP_RUNTIME_BORROWED_V1}; +} + +class PreparedExternalAmrFieldSolver final : public AmrPreparedFieldSolver { + public: + PreparedExternalAmrFieldSolver(const AmrFieldSolverBuildRequest& request, + PreparedFieldSolverSpec spec, + std::shared_ptr topology_component, + std::shared_ptr solver_component, + std::string exact_contract) + : spec_(std::move(spec)), + exact_contract_(std::move(exact_contract)), + topology_component_(std::move(topology_component)), + solver_component_(std::move(solver_component)) { + static_assert(sizeof(Real) == sizeof(double), + "FieldSolver ABI v2 requires the binary64 PoPS backend"); + if (!topology_component_ || !solver_component_) + throw std::invalid_argument("external AMR field solver lost its component handles"); + topology_state_ = topology_component_->prepare_fresh_state( + POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2, spec_.topology_interface_version, + spec_.execution->view(), spec_.topology_parameters_json); + solver_state_ = solver_component_->prepare_fresh_state( + POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2, spec_.solver_interface_version, + spec_.execution->view(), spec_.solver_parameters_json); + materialize_(request); + } + + [[nodiscard]] std::string_view provider_identity() const noexcept override { + return spec_.provider_slot; + } + [[nodiscard]] std::string_view exact_prepared_contract() const noexcept override { + return exact_contract_; + } + [[nodiscard]] std::string_view exact_materialization_evidence() const noexcept override { + return materialization_evidence_; + } + [[nodiscard]] bool requires_runtime_solution_halos() const noexcept override { return true; } + [[nodiscard]] bool couples_hierarchy_levels() const noexcept override { return true; } + [[nodiscard]] int level_count() const noexcept override { return static_cast(rhs_.size()); } + [[nodiscard]] FieldDistribution level_distribution(int level) const override { + return distributions_.at(static_cast(level)); + } + MultiFab& rhs_level(int level) override { return rhs_.at(static_cast(level)); } + MultiFab& phi_level(int level) override { return phi_.at(static_cast(level)); } + void set_boundary_context(const FieldBoundaryExecutionContext&) override { + throw std::runtime_error("external FieldSolver@2 carries only its immutable boundary contract"); + } + [[nodiscard]] const SolveReport& last_solve_report() const noexcept override { return report_; } + + private: + SolveReport solve() override { + for (auto& level : rhs_) + level.sync_host(); + for (auto& level : phi_) + level.sync_host(); + PopsSolveReportV2 native{}; + native.struct_size = sizeof(PopsSolveReportV2); + const auto& api = solver_component_->table( + POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2, spec_.solver_interface_version); + (void)component::solve_field(api, solver_state_.get(), *solver_request_, native); + + report_ = {}; + report_.iters = native.iterations; + report_.rel_residual = static_cast(native.relative_residual); + report_.reference_residual_norm = static_cast(native.reference_residual_norm); + report_.residual_norm = static_cast(native.residual_norm); + const SolveStatus status = solve_status(native.status); + const SolveAction action = solve_action(native.action); + if (status == SolveStatus::kSolved) { + if (!active_solution_is_finite_()) { + report_.mark_failed( + SolveStatus::kInvalidEvaluation, SolveAction::kFailRun, + "native FieldSolver@2 marked a non-finite active hierarchy solution as solved"); + return report_; + } + for (auto& level : phi_) + level.sync_device(); + report_.mark_solved(native.reason); + return report_; + } + report_.mark_failed(status, action, native.reason); + return report_; + } + + bool active_solution_is_finite_() const { + if (!topology_ || topology_->local_patches().size() != local_locations_.size()) + return false; + for (std::size_t index = 0; index < local_locations_.size(); ++index) { + const auto [level, local] = local_locations_[index]; + const MultiFab& field = phi_.at(static_cast(level)); + const Box2D valid = field.box(local); + const auto& patch = topology_->local_patches()[index]; + if (patch.material_mask.size() != static_cast(valid.num_cells())) + return false; + const ConstArray4 values = field.fab(local).const_array(); + std::size_t point = 0; + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i, ++point) + if (patch.material_mask[point] > 1 || + (patch.material_mask[point] == 1 && !std::isfinite(values(i, j, 0)))) + return false; + } + return true; + } + + static std::vector binary_coverage_(const Box2D& valid, const BoxArray* fine_boxes, + int ratio) { + std::vector footprints; + if (fine_boxes != nullptr) { + footprints.reserve(static_cast(fine_boxes->size())); + for (const Box2D& fine : fine_boxes->boxes()) { + const Box2D footprint = fine.coarsen(ratio); + if (footprint.refine(ratio) != fine) + throw std::invalid_argument( + "external AMR field solver requires refinement-aligned fine patches"); + footprints.push_back(footprint); + } + } + std::vector result(static_cast(valid.num_cells()), 1); + std::size_t point = 0; + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i, ++point) + if (std::any_of(footprints.begin(), footprints.end(), + [i, j](const Box2D& footprint) { return footprint.contains(i, j); })) + result[point] = 0; + return result; + } + + void materialize_(const AmrFieldSolverBuildRequest& request) { + const int levels = request.hierarchy.nlev(); + if (levels < 1 || request.hierarchy.ba.size() != request.hierarchy.dm.size() || + request.hierarchy.ba.size() != request.hierarchy.dx.size() || + request.hierarchy.ba.size() != request.hierarchy.dy.size() || + request.hierarchy.refinement_ratios.size() + 1 != request.hierarchy.ba.size()) + throw std::invalid_argument("external AMR field solver hierarchy is incomplete"); + geometries_.reserve(static_cast(levels)); + rhs_.reserve(static_cast(levels)); + phi_.reserve(static_cast(levels)); + distributions_.reserve(static_cast(levels)); + level_offsets_.reserve(static_cast(levels)); + std::size_t global_patch_count = 0; + int refinement = 1; + for (int level = 0; level < levels; ++level) { + const auto index = static_cast(level); + level_offsets_.push_back(global_patch_count); + global_patch_count += static_cast(request.hierarchy.ba[index].size()); + geometries_.push_back(request.geometry.refine(refinement)); + if (geometries_.back().dx() != request.hierarchy.dx[index] || + geometries_.back().dy() != request.hierarchy.dy[index]) + throw std::invalid_argument( + "external AMR field solver geometry differs from the hierarchy spacing"); + rhs_.emplace_back(request.hierarchy.ba[index], request.hierarchy.dm[index], 1, 0); + phi_.emplace_back(request.hierarchy.ba[index], request.hierarchy.dm[index], 1, 1); + rhs_.back().set_val(Real(0)); + phi_.back().set_val(Real(0)); + distributions_.push_back(level == 0 && request.replicated_coarse + ? FieldDistribution::Replicated + : FieldDistribution::Distributed); + if (index < request.hierarchy.refinement_ratios.size()) { + const int ratio = request.hierarchy.refinement_ratios[index]; + if (ratio != kAmrRefRatio || refinement > std::numeric_limits::max() / ratio) + throw std::invalid_argument( + "external AMR field solver requires representable ratio-2 transitions"); + refinement *= ratio; + } + } + if (global_patch_count == 0) + throw std::invalid_argument("external AMR field solver hierarchy has no global patches"); + + ExactContractBuilder layout_contract; + layout_contract.text("pops.runtime.external-amr-field-layout") + .scalar(std::uint32_t{1}) + .text(spec_.source_layout_identity) + .text(spec_.topology_recipe_identity) + .scalar(periodic_axes(request.boundary)) + .scalar(levels) + .scalar(request.geometry.xlo) + .scalar(request.geometry.xhi) + .scalar(request.geometry.ylo) + .scalar(request.geometry.yhi); + for (int level = 0; level < levels; ++level) { + const auto li = static_cast(level); + layout_contract.scalar(level) + .scalar(request.hierarchy.dx[li]) + .scalar(request.hierarchy.dy[li]) + .scalar(request.hierarchy.ba[li].size()); + for (int patch = 0; patch < request.hierarchy.ba[li].size(); ++patch) { + const Box2D& box = request.hierarchy.ba[li][patch]; + layout_contract.scalar(patch) + .scalar(request.hierarchy.dm[li][patch]) + .scalar(box.lo[0]) + .scalar(box.lo[1]) + .scalar(box.hi[0]) + .scalar(box.hi[1]); + } + layout_contract.scalar(li < request.hierarchy.refinement_ratios.size() + ? request.hierarchy.refinement_ratios[li] + : 1); + } + materialized_layout_identity_ = + hashed_identity("runtime-amr-field-layout", std::move(layout_contract).release()); + patch_identities_.reserve(global_patch_count); + std::vector global; + global.reserve(global_patch_count); + for (int level = 0; level < levels; ++level) { + const auto li = static_cast(level); + const Geometry& geometry = geometries_[li]; + const BoxArray& boxes = request.hierarchy.ba[li]; + for (int patch = 0; patch < boxes.size(); ++patch) { + const std::size_t global_index = level_offsets_[li] + static_cast(patch); + const Box2D& box = boxes[patch]; + ExactContractBuilder identity_contract; + identity_contract.text(materialized_layout_identity_) + .scalar(level) + .scalar(patch) + .scalar(box.lo[0]) + .scalar(box.lo[1]) + .scalar(box.hi[0]) + .scalar(box.hi[1]); + patch_identities_.push_back( + hashed_identity("runtime-amr-field-patch", std::move(identity_contract).release())); + const int owner = request.hierarchy.dm[li][patch]; + PopsFieldPatchMetadataV1 row{sizeof(PopsFieldPatchMetadataV1), + global_index, + owner, + level, + 2, + {}, + {}, + {}, + {}, + POPS_FIELD_CENTERING_CELL_V1, + 0, + spec_.source_layout_identity.c_str(), + patch_identities_.back().c_str()}; + row.lower[0] = box.lo[0]; + row.lower[1] = box.lo[1]; + row.upper[0] = box.hi[0]; + row.upper[1] = box.hi[1]; + row.physical_lower[0] = + geometry.xlo + static_cast(box.lo[0] - geometry.domain.lo[0]) * geometry.dx(); + row.physical_lower[1] = + geometry.ylo + static_cast(box.lo[1] - geometry.domain.lo[1]) * geometry.dy(); + row.cell_spacing[0] = geometry.dx(); + row.cell_spacing[1] = geometry.dy(); + global.push_back(row); + } + } + + PopsFieldGlobalTopologyV1 global_topology{sizeof(PopsFieldGlobalTopologyV1), + spec_.topology_recipe_identity.c_str(), + spec_.source_layout_identity.c_str(), + materialized_layout_identity_.c_str(), + 2, + {}, + {}, + periodic_axes(request.boundary), + global.size(), + global.data()}; + for (int axis = 0; axis < 2; ++axis) { + global_topology.domain_lower[axis] = std::numeric_limits::max(); + global_topology.domain_upper[axis] = std::numeric_limits::min(); + for (const auto& patch : global) { + global_topology.domain_lower[axis] = + std::min(global_topology.domain_lower[axis], patch.lower[axis]); + global_topology.domain_upper[axis] = + std::max(global_topology.domain_upper[axis], patch.upper[axis]); + } + } + + std::size_t local_patch_count = 0; + for (const MultiFab& level : rhs_) + local_patch_count += static_cast(level.local_size()); + local_locations_.reserve(local_patch_count); + coverage_.reserve(local_patch_count); + std::vector local_topology; + local_topology.reserve(local_patch_count); + for (int level = 0; level < levels; ++level) { + const auto li = static_cast(level); + const BoxArray* fine = level + 1 < levels ? &request.hierarchy.ba[li + 1] : nullptr; + const int ratio = fine == nullptr ? 1 : request.hierarchy.refinement_ratios.at(li); + for (int local = 0; local < rhs_[li].local_size(); ++local) { + const int patch = rhs_[li].global_index(local); + const std::size_t metadata_index = level_offsets_[li] + static_cast(patch); + local_locations_.emplace_back(level, local); + coverage_.push_back(binary_coverage_(rhs_[li].box(local), fine, ratio)); + const auto& mask = coverage_.back(); + local_topology.push_back({metadata_index, + POPS_FIELD_MATERIAL_BINARY_COVERAGE_V1, + {sizeof(PopsConstByteViewV1), mask.data(), mask.size()}, + {}, + {}}); + } + } + + const auto& topology_api = topology_component_->table( + POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2, spec_.topology_interface_version); + topology_.emplace(component::prepare_field_topology(topology_api, topology_state_.get(), + global_topology, local_topology, + spec_.execution->view())); + ExactContractBuilder label_contract; + label_contract.text("pops.external-amr-field-topology-labels") + .scalar(std::uint32_t{1}) + .sequence(topology_->labels(), + [](ExactContractBuilder& row, const component::PreparedTopologyLabelV2& label) { + row.scalar(label.id).text(label.label).text(label.provenance); + }); + ExactContractBuilder evidence; + evidence.text("pops.external-amr-field-materialization") + .scalar(std::uint32_t{1}) + .text(topology_->topology_digest()) + .text(topology_->provenance()) + .bytes(label_contract.view()) + .text(materialized_layout_identity_); + materialization_evidence_ = std::move(evidence).release(); + + std::vector bindings; + bindings.reserve(local_locations_.size()); + for (std::size_t index = 0; index < local_locations_.size(); ++index) { + const auto [level, local] = local_locations_[index]; + const auto li = static_cast(level); + const int patch = rhs_[li].global_index(local); + const std::size_t metadata_index = level_offsets_[li] + static_cast(patch); + const auto& metadata = topology_->global_patches().at(metadata_index); + bindings.push_back({metadata_index, + const_view(rhs_[li].fab(local), rhs_[li].box(local), + metadata.layout_identity, metadata.patch_identity), + field_view(phi_[li].fab(local), phi_[li].box(local), + metadata.layout_identity, metadata.patch_identity), + {}}); + } + solver_request_.emplace(component::bind_field_solver_request( + *topology_, bindings, spec_.execution->view(), spec_.boundary_contract_json.c_str(), + spec_.relative_tolerance, spec_.absolute_tolerance, spec_.max_iterations)); + } + + PreparedFieldSolverSpec spec_; + std::string exact_contract_; + std::shared_ptr topology_component_; + std::shared_ptr solver_component_; + component::LoadedComponent::PreparedState topology_state_; + component::LoadedComponent::PreparedState solver_state_; + std::vector geometries_; + std::vector rhs_; + std::vector phi_; + std::vector distributions_; + std::vector level_offsets_; + std::vector patch_identities_; + std::string materialized_layout_identity_; + std::vector> local_locations_; + std::vector> coverage_; + std::optional topology_; + std::optional solver_request_; + std::string materialization_evidence_; + SolveReport report_{}; +}; + +class ExternalAmrFieldSolverProvider final : public AmrFieldSolverProvider { + public: + ExternalAmrFieldSolverProvider(PreparedFieldSolverSpec spec, + std::shared_ptr topology, + std::shared_ptr solver) + : spec_(std::move(spec)), + topology_(std::move(topology)), + solver_(std::move(solver)), + collective_contract_(exact_external_provider_contract(spec_)) { + if (spec_.provider_slot.empty() || spec_.topology_component_id.empty() || + spec_.topology_manifest_identity.empty() || spec_.solver_component_id.empty() || + spec_.topology_parameters_json.empty() || spec_.solver_manifest_identity.empty() || + spec_.solver_parameters_json.empty() || spec_.source_layout_identity.empty() || + spec_.topology_recipe_identity.empty() || spec_.boundary_contract_json.empty() || + spec_.boundary_contract_json.find("\"identity\"") == std::string::npos || + spec_.topology_interface_version != 2 || spec_.solver_interface_version != 2 || + !std::isfinite(spec_.relative_tolerance) || spec_.relative_tolerance < 0.0 || + !std::isfinite(spec_.absolute_tolerance) || spec_.absolute_tolerance < 0.0 || + spec_.max_iterations < 1 || !topology_ || !solver_) + throw std::invalid_argument("external AMR field solver specification is incomplete"); + validate_external_execution(spec_); + multi_rank_execution_ = n_ranks() > 1; + const auto& topology_api = topology_->api(); + const auto& solver_api = solver_->api(); + if (topology_api.component_id == nullptr || topology_api.manifest_identity == nullptr || + solver_api.component_id == nullptr || solver_api.manifest_identity == nullptr || + spec_.topology_component_id != topology_api.component_id || + spec_.topology_manifest_identity != topology_api.manifest_identity || + spec_.solver_component_id != solver_api.component_id || + spec_.solver_manifest_identity != solver_api.manifest_identity) + throw std::invalid_argument("external AMR field solver changed component identity"); + const auto& topology_table = topology_->table( + POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2, spec_.topology_interface_version); + const auto& solver_table = solver_->table( + POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2, spec_.solver_interface_version); + component::require_operation(topology_table.prepare_topology != nullptr, "prepare_topology"); + component::require_operation(solver_table.solve != nullptr, "solve"); + } + + [[nodiscard]] std::string_view identity() const noexcept override { return spec_.provider_slot; } + [[nodiscard]] std::uint64_t interface_version() const noexcept override { return 1; } + [[nodiscard]] std::string_view collective_contract() const noexcept override { + return collective_contract_; + } + [[nodiscard]] std::vector capability_contracts() const override { + std::vector result{ + "pops.amr.external-field-solver.binary-coarse-fine-coverage@1", + "pops.amr.external-field-solver.exact-component-pair@1", + "pops.amr.external-field-solver.full-hierarchy-batch@1", + "pops.amr.external-field-solver.host-serial@1", + "pops.amr.external-field-solver.regrid-rematerialization@1", + "pops.amr.external-field-solver.single-collective-solve@1", + }; + if (spec_.component_pair_declares_mpi) { + result.push_back("pops.amr.external-field-solver.declared-mpi-world@1"); + result.push_back("pops.amr.external-field-solver.mpi-distributed-coarse@1"); + } + return result; + } + [[nodiscard]] AmrFieldSolverOptions default_field_options() const override { + return external_options(spec_); + } + [[nodiscard]] std::optional default_hierarchy_policy( + std::string_view) const override { + return std::nullopt; + } + [[nodiscard]] PreparedProviderSupport accepts_options( + const AmrFieldSolverOptions& options) const noexcept override { + return exact_external_options(options, spec_) + ? PreparedProviderSupport::accept() + : PreparedProviderSupport::reject(1, + "external field solver options differ from the " + "authenticated component request"); + } + [[nodiscard]] PreparedProviderSupport supports( + const AmrFieldSolverBuildRequest& request) const noexcept override { + if (!exact_external_options(request.plan.solver_options, spec_)) + return PreparedProviderSupport::reject(10, "external field solver options are incompatible"); + if (request.use_contract_identity != "pops.amr.field-solver-use.named@1") + return PreparedProviderSupport::reject(11, + "external field solver supports named fields only"); + if (!exact_composite_policy(request.plan.hierarchy_policy)) + return PreparedProviderSupport::reject( + 12, "external field solver requires the composite hierarchy policy"); + if (request.hierarchy.nlev() < 1 || + request.hierarchy.ba.size() != request.hierarchy.dm.size() || + request.hierarchy.ba.size() != request.hierarchy.dx.size() || + request.hierarchy.ba.size() != request.hierarchy.dy.size() || + request.hierarchy.refinement_ratios.size() + 1 != request.hierarchy.ba.size()) + return PreparedProviderSupport::reject(13, "external field solver hierarchy is incomplete"); + if (std::any_of(request.hierarchy.refinement_ratios.begin(), + request.hierarchy.refinement_ratios.end(), + [](int ratio) { return ratio != kAmrRefRatio; })) + return PreparedProviderSupport::reject( + 14, "external field solver currently requires ratio-2 AMR transitions"); + if (static_cast(request.active)) + return PreparedProviderSupport::reject( + 15, "external FieldTopology@2 bridge does not carry an active-region predicate"); + if (request.plan.has_reaction) + return PreparedProviderSupport::reject( + 16, "external FieldSolver@2 has no reaction-coefficient carrier"); + if (request.plan.has_boundary_kernel) + return PreparedProviderSupport::reject( + 17, "external FieldSolver@2 carries only an immutable boundary contract"); + if (request.plan.has_newton) + return PreparedProviderSupport::reject( + 18, "external FieldSolver@2 has no shared nonlinear iterate/JVP protocol"); + if (!paired_periodic_boundary(request.boundary)) + return PreparedProviderSupport::reject(19, "periodic boundary faces are not paired"); + if (request.replicated_coarse && multi_rank_execution_) + return PreparedProviderSupport::reject( + 20, "FieldSolver@2 has no MPI replicated-coarse ownership representation"); + return PreparedProviderSupport::accept(); + } + [[nodiscard]] std::string expected_prepared_contract( + const AmrFieldSolverBuildRequest& request) const override { + ExactContractBuilder contract; + contract.bytes(make_amr_field_solver_contract(identity(), request)).bytes(collective_contract_); + return std::move(contract).release(); + } + [[nodiscard]] std::unique_ptr build( + const AmrFieldSolverBuildRequest& request) const override { + return std::make_unique(request, spec_, topology_, solver_, + expected_prepared_contract(request)); + } + + private: + PreparedFieldSolverSpec spec_; + std::shared_ptr topology_; + std::shared_ptr solver_; + std::string collective_contract_; + bool multi_rank_execution_ = false; +}; + +} // namespace + +POPS_EXPORT std::shared_ptr make_external_amr_field_solver_provider( + runtime::field::PreparedFieldSolverSpec spec, + std::shared_ptr topology, + std::shared_ptr solver) { + return std::make_shared(std::move(spec), std::move(topology), + std::move(solver)); +} + +} // namespace pops diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index 683a7b0d7..ada8a931a 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -246,6 +246,7 @@ struct AmrSystem::Impl { std::map field_storage_routes_; std::shared_ptr amr_tagger_component_; std::shared_ptr amr_clustering_component_; + std::shared_ptr amr_reflux_component_; struct BootstrapArray { std::string centering; int ncomp = 0; @@ -397,48 +398,7 @@ struct AmrSystem::Impl { // second endpoint authority. With 1/1 this is a single program_.step_(dt) call (bit-identical to a // bare install). The cadence applies to the whole resolved ProgramGraph. void run_program_cadence_(double dt) { - const double accepted_time = t; - const auto cadence = program_.prepare_cadence_step(accepted_time, macro_step_, dt, "AmrSystem"); - if (macro_step_ == std::numeric_limits::max()) - throw std::overflow_error("AmrSystem Program cadence macro-step counter overflow"); - if (cadence.due) { - program_.validate_cadence_partition(cadence, program_.substeps_, "AmrSystem"); - const int accepted_macro_step = macro_step_; - const int held_before_due = cadence.window_steps - 1; - if (accepted_macro_step < held_before_due) - throw std::logic_error("AmrSystem Program cadence window starts before macro-step zero"); - const int window_start_macro_step = accepted_macro_step - held_before_due; - try { - for (int s = 0; s < program_.substeps_; ++s) { - const auto partition = - program_.prepare_cadence_substep(cadence, s, program_.substeps_, "AmrSystem"); - // AmrProgramContext reads the facade clock at Program entry. Move it to the exact accepted - // start of this substep so stage/tagger coordinates cover the whole catch-up window instead - // of repeating the outer macro-step time. - t = partition.start; - // All internal calls belong to one public stride window. Publish the accepted start tick - // so schedules, regridding and AmrProgramContext never count Program substeps as facade - // macro-steps. - macro_step_ = window_start_macro_step; - // ADC-626/ADC-631: expose this interval before the Program stores its pre-commit history - // sample. The ring ledger then records the outgoing dt from that sample toward the next - // accepted sample (variable-dt replay). Parity with SystemProgramDriver::run_program_cadence. - program_.last_dt_ = static_cast(partition.dt); - program_.step_(partition.dt); - t = partition.end; - } - } catch (...) { - t = accepted_time; - macro_step_ = accepted_macro_step; - throw; - } - t = accepted_time; - macro_step_ = accepted_macro_step; - } - program_.commit_cadence_step(cadence, "AmrSystem"); - // One prepared endpoint owns facade, stages and serialized AMR accepted clocks. Do not recompute - // it as either accepted_time + dt or window_start + effective_dt after Program execution. - t = cadence.window_end; + program_.dispatch_cadence_step(t, macro_step_, dt, "AmrSystem"); } struct AcceptedSnapshot { @@ -457,6 +417,11 @@ struct AmrSystem::Impl { double cadence_clock_restore_accepted_time = 0.0; int cadence_clock_restore_macro_step = 0; std::map program_diagnostics; + std::map step_balance_terms; + std::map automatic_balance_terms; + bool automatic_balance_due = false; + bool balance_step_completed = false; + bool balance_program_was_due = false; pops::runtime::program::CacheManager cache; pops::runtime::program::HistoryManager history; pops::runtime::program::Profiler profiler; @@ -501,6 +466,11 @@ struct AmrSystem::Impl { cadence_clock_restore_accepted_time = impl.program_.cadence_clock_restore_accepted_time_; cadence_clock_restore_macro_step = impl.program_.cadence_clock_restore_macro_step_; copy_value_map_into(program_diagnostics, impl.program_.diagnostics_); + copy_value_map_into(step_balance_terms, impl.program_.step_balance_terms_); + copy_value_map_into(automatic_balance_terms, impl.program_.automatic_balance_terms_); + automatic_balance_due = impl.program_.automatic_balance_due_; + balance_step_completed = impl.program_.balance_step_completed_; + balance_program_was_due = impl.program_.balance_program_was_due_; // AMR currently owns its native cache/history rings inside AmrRuntime. These two shared // ProgramRuntimeState containers are therefore empty on the AMR path, but retain their value // contract so a future target can populate them without weakening rollback semantics. @@ -531,6 +501,11 @@ struct AmrSystem::Impl { impl.program_.cadence_clock_restore_accepted_time_ = cadence_clock_restore_accepted_time; impl.program_.cadence_clock_restore_macro_step_ = cadence_clock_restore_macro_step; copy_value_map_into(impl.program_.diagnostics_, program_diagnostics); + copy_value_map_into(impl.program_.step_balance_terms_, step_balance_terms); + copy_value_map_into(impl.program_.automatic_balance_terms_, automatic_balance_terms); + impl.program_.automatic_balance_due_ = automatic_balance_due; + impl.program_.balance_step_completed_ = balance_step_completed; + impl.program_.balance_program_was_due_ = balance_program_was_due; impl.program_.cache_ = cache; impl.program_.hist_ = history; impl.program_.profiler_ = profiler; @@ -890,6 +865,9 @@ struct AmrSystem::Impl { runtime->install_external_tagger(amr_tagger_component_); if (amr_clustering_component_) runtime->install_external_clustering(amr_clustering_component_); + // Reflux selection is a collective optional-provider contract: every rank enters this call, + // including ranks where no external provider was selected. + runtime->install_external_reflux(amr_reflux_component_); if (!boundary_plans_.empty()) runtime->install_boundary_storage_routes(field_storage_routes_); // Low-level facade compatibility has no authored AMRTransfer object. Resolve its exact @@ -1330,72 +1308,76 @@ POPS_EXPORT void AmrSystem::install_block_state_route(const std::string& name, POPS_EXPORT void AmrSystem::install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies) { - install_boundary_plan(name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, std::move(read_dependencies), {}); + install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + std::move(read_dependencies), {}); } POPS_EXPORT void AmrSystem::install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications) { + std::vector periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { Impl* P = p_.get(); - require_assembling_amr(P->bound_, "install_boundary_plan"); - if (P->built) - throw std::runtime_error("AmrSystem::install_boundary_plan: system is already built"); - if (name.empty() || state_identity.empty() || P->boundary_plans_.count(name) != 0) - throw std::runtime_error( - "AmrSystem::install_boundary_plan requires unique block/state-qualified identities"); - const auto state_route = P->block_state_identities_.find(name); - if (state_route == P->block_state_identities_.end() || state_route->second != state_identity) - throw std::runtime_error( - "AmrSystem::install_boundary_plan state differs from the exact block state route"); - if (ncomp < 1 || face_types.size() != 4 || - face_values.size() != static_cast(4 * ncomp)) - throw std::runtime_error( - "AmrSystem::install_boundary_plan requires four face types and ncomp*4 values"); - auto parse = [](const std::string& token) { - if (token == "periodic") - return BCType::Periodic; - if (token == "foextrap") - return BCType::Foextrap; - if (token == "dirichlet") - return BCType::Dirichlet; - if (token == "external") - return BCType::External; - throw std::runtime_error("AmrSystem::install_boundary_plan: unsupported face producer '" + - token + "'"); - }; - std::vector components(static_cast(ncomp)); - for (int comp = 0; comp < ncomp; ++comp) { - BCRec& bc = components[static_cast(comp)]; - const BCType types[4] = {parse(face_types[0]), parse(face_types[1]), parse(face_types[2]), - parse(face_types[3])}; - const Real values[4] = {static_cast(face_values[static_cast(4 * comp)]), - static_cast(face_values[static_cast(4 * comp + 1)]), - static_cast(face_values[static_cast(4 * comp + 2)]), - static_cast(face_values[static_cast(4 * comp + 3)])}; - bc.xlo = types[0]; - bc.xhi = types[1]; - bc.ylo = types[2]; - bc.yhi = types[3]; - bc.xlo_val = values[0]; - bc.xhi_val = values[1]; - bc.ylo_val = values[2]; - bc.yhi_val = values[3]; - } - auto plan = std::make_shared( - identity, required_depth, std::move(components), omitted_interface_faces, state_identity, - std::move(read_dependencies), std::move(periodic_identifications)); - for (const auto& [_, installed] : P->boundary_plans_) - if (installed->state_identity() == state_identity) - throw std::runtime_error( - "AmrSystem::install_boundary_plan duplicate qualified state identity"); - P->boundary_plans_.emplace(name, std::move(plan)); + using BoundaryPlanMap = decltype(P->boundary_plans_); + using BoundaryPlanNode = typename BoundaryPlanMap::node_type; + BoundaryPlanNode prepared = analytic::collectively_prepare_exact_analytic_request( + "AmrSystem::install_boundary_plan", + [&]() -> BoundaryPlanNode { + require_assembling_amr(P->bound_, "install_boundary_plan"); + if (P->built) + throw std::runtime_error("AmrSystem::install_boundary_plan: system is already built"); + if (name.empty() || state_identity.empty() || P->boundary_plans_.count(name) != 0) + throw std::runtime_error( + "AmrSystem::install_boundary_plan requires unique block/state-qualified identities"); + const auto state_route = P->block_state_identities_.find(name); + if (state_route == P->block_state_identities_.end() || + state_route->second != state_identity) + throw std::runtime_error( + "AmrSystem::install_boundary_plan state differs from the exact block state route"); + for (const auto& [_, installed] : P->boundary_plans_) + if (installed->state_identity() == state_identity) + throw std::runtime_error( + "AmrSystem::install_boundary_plan duplicate qualified state identity"); + + auto hyperbolic = prepare_hyperbolic_boundary<2>( + face_types, face_values, face_identities, component_roles, + !periodic_identifications.empty(), face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + auto plan = std::make_shared( + identity, required_depth, std::move(hyperbolic), omitted_interface_faces, + state_identity, read_dependencies, periodic_identifications); + if (plan->has_mapped_periodicity()) + throw std::runtime_error( + "AmrSystem::install_boundary_plan: mapped periodic topology is not supported by AMR " + "fill-patch/regrid; use the uniform runtime or an axis-aligned translation"); + BoundaryPlanMap staged; + staged.emplace(name, std::move(plan)); + return staged.extract(staged.begin()); + }, + [&]() { + return detail::canonical_prepared_boundary_plan_request( + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, read_dependencies, + periodic_identifications, face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + }); + const auto published = P->boundary_plans_.insert(std::move(prepared)); + if (!published.inserted) + throw std::logic_error("AmrSystem::install_boundary_plan lost its prepared publication slot"); } POPS_EXPORT void AmrSystem::install_field_storage_route(const std::string& field_identity, @@ -1435,6 +1417,19 @@ POPS_EXPORT void AmrSystem::install_ghost_boundary_component( found->second->install_ghost_component(std::move(spec), std::move(component)); } +POPS_EXPORT void AmrSystem::install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component) { + Impl* P = p_.get(); + require_assembling_amr(P->bound_, "install_boundary_flux_component"); + if (P->built) + throw std::runtime_error("AmrSystem boundary flux: system is already built"); + const auto found = P->boundary_plans_.find(name); + if (found == P->boundary_plans_.end()) + throw std::runtime_error("AmrSystem boundary flux requires an installed block boundary plan"); + found->second->install_flux_component(std::move(spec), std::move(component)); +} + POPS_EXPORT void AmrSystem::install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component) { @@ -1490,6 +1485,17 @@ POPS_EXPORT void AmrSystem::install_amr_clustering_component( std::move(spec), std::move(component)); } +POPS_EXPORT void AmrSystem::install_amr_reflux_component( + runtime::amr::PreparedRefluxSpec spec, std::shared_ptr component) { + Impl* P = p_.get(); + require_assembling_amr(P->bound_, "install_amr_reflux_component"); + if (P->built || P->amr_reflux_component_) + throw std::runtime_error( + "AmrSystem external Reflux requires one installation before runtime build"); + P->amr_reflux_component_ = std::make_shared( + std::move(spec), std::move(component)); +} + POPS_EXPORT void AmrSystem::discard_amr_provider_components() { Impl* P = p_.get(); require_assembling_amr(P->bound_, "discard_amr_provider_components"); @@ -1497,6 +1503,7 @@ POPS_EXPORT void AmrSystem::discard_amr_provider_components() { throw std::runtime_error("AmrSystem cannot discard AMR providers after runtime build"); P->amr_tagger_component_.reset(); P->amr_clustering_component_.reset(); + P->amr_reflux_component_.reset(); } POPS_EXPORT void AmrSystem::install_interface_flux_component( @@ -1767,11 +1774,11 @@ std::vector AmrSystem::output_field_local_pieces(const std::string& return p_->runtime->output_field_local_pieces(provider_slot, level); } -std::vector AmrSystem::output_field_root_pieces(const WorldCommunicator& world, +std::vector AmrSystem::output_field_root_pieces(const ObserverMpiLane& lane, const std::string& provider_slot, int level) { return output_pieces_to_root( - world, detail::output_collective_identity("AmrSystem", "field", provider_slot, level), + lane, detail::output_collective_identity("AmrSystem", "field", provider_slot, level), [&] { return output_field_local_pieces(provider_slot, level); }); } @@ -2213,6 +2220,26 @@ void AmrSystem::register_field_solver_provider( p_->field_plan_consensus_verified_ = false; } +POPS_EXPORT std::string AmrSystem::register_field_solver_provider( + const std::string& provider_slot, runtime::field::PreparedFieldSolverSpec spec, + std::shared_ptr topology, + std::shared_ptr solver) { + require_assembling_amr(p_->bound_, "register_field_solver_provider"); + if (p_->built) + throw std::runtime_error("AmrSystem::register_field_solver_provider: system already built"); + if (provider_slot.empty() || spec.provider_slot != provider_slot) + throw std::invalid_argument( + "AmrSystem::register_field_solver_provider requires one exact provider slot"); + auto provider = make_external_amr_field_solver_provider(std::move(spec), std::move(topology), + std::move(solver)); + if (!provider || provider->identity() != provider_slot) + throw std::runtime_error( + "AmrSystem::register_field_solver_provider changed the authenticated provider route"); + p_->field_solver_registry_->add(std::move(provider)); + p_->field_plan_consensus_verified_ = false; + return provider_slot; +} + void AmrSystem::register_field_nullspace_provider( std::shared_ptr provider) { require_assembling_amr(p_->bound_, "register_field_nullspace_provider"); @@ -2402,13 +2429,19 @@ void AmrSystem::set_field_boundary_dependencies(const std::string& provider_slot const std::vector& field_keys, const std::vector& field_components) { require_assembling_amr(p_->bound_, "set_field_boundary_dependencies"); - if (state_blocks.size() != state_components.size() || !field_blocks.empty() || - !field_keys.empty() || !field_components.empty()) + if (state_blocks.size() != state_components.size() || field_blocks.size() != field_keys.size() || + field_blocks.size() != field_components.size()) throw std::runtime_error( - "AmrSystem::set_field_boundary_dependencies accepts exact state buffers only"); + "AmrSystem::set_field_boundary_dependencies requires exact state/field dependency packs"); if (std::any_of(state_blocks.begin(), state_blocks.end(), [](const auto& value) { return value.empty(); }) || std::any_of(state_components.begin(), state_components.end(), + [](int value) { return value < 0; }) || + std::any_of(field_blocks.begin(), field_blocks.end(), + [](const auto& value) { return value.empty(); }) || + std::any_of(field_keys.begin(), field_keys.end(), + [](const auto& value) { return value.empty(); }) || + std::any_of(field_components.begin(), field_components.end(), [](int value) { return value < 0; })) throw std::runtime_error("AmrSystem::set_field_boundary_dependencies contains invalid entries"); auto found = p_->field_plans_.find(provider_slot); @@ -2416,8 +2449,12 @@ void AmrSystem::set_field_boundary_dependencies(const std::string& provider_slot throw std::runtime_error("AmrSystem::set_field_boundary_dependencies unknown provider slot"); found->second.boundary_state_blocks = state_blocks; found->second.boundary_state_components = state_components; + found->second.boundary_field_blocks = field_blocks; + found->second.boundary_field_keys = field_keys; + found->second.boundary_field_components = field_components; if (p_->runtime) - p_->runtime->set_field_boundary_dependencies(provider_slot, state_blocks, state_components); + p_->runtime->set_field_boundary_dependencies(provider_slot, state_blocks, state_components, + field_blocks, field_keys, field_components); p_->field_plan_consensus_verified_ = false; } @@ -3094,7 +3131,6 @@ void AmrSystem::step(double dt) { // The installed Program is the sole temporal authority. It drives the per-level macro-step // through AmrProgramContext; AmrRuntime remains available only as the spatial hierarchy engine. p_->run_program_cadence_(dt); - ++p_->macro_step_; // authoritative counter (parity System: one macro-step = one increment) }); } void AmrSystem::advance(double dt, int nsteps) { @@ -3181,7 +3217,6 @@ double AmrSystem::step_cfl(double cfl, double speed_floor, double max_dt, double if (dt < min_dt) throw std::runtime_error("AmrSystem::step_cfl stability bound is below declared min_dt"); p_->run_program_cadence_(dt); - ++p_->macro_step_; return dt; }); } @@ -3328,6 +3363,15 @@ void AmrSystem::restore_checkpoint_accepted_state(const std::vectorruntime->topology_epoch()) + throw std::runtime_error( + "AMR checkpoint cell-local temporal partition targets another topology epoch"); + for (const auto& cell : accepted.temporal_partition.cells) + if (cell.level < 0 || cell.level >= p_->runtime->nlev()) + throw std::runtime_error( + "AMR checkpoint cell-local temporal partition targets an inactive level"); + } tagging_candidate = p_->runtime->prepare_checkpoint_tagging_state(accepted.tagging_hysteresis_state); } else { @@ -3435,6 +3479,13 @@ std::vector> AmrSystem::program_clock_manifest() const rows.push_back({"logical", identity, std::to_string(tick)}); return rows; } +std::vector> AmrSystem::program_temporal_partition_manifest() const { + if (p_->program_accepted_state_.empty()) + return {}; + const auto state = + runtime::program::deserialize_amr_program_accepted_state(p_->program_accepted_state_); + return runtime::program::BatchedCellTemporalPartition(state.temporal_partition).manifest(); +} std::vector> AmrSystem::program_flux_ledger_manifest() const { std::vector> rows; if (p_->program_accepted_state_.empty()) @@ -3622,6 +3673,14 @@ runtime::program::ProgramRuntimeState& AmrSystem::program_runtime_state_() { void AmrSystem::record_program_diagnostic(const std::string& name, double value) { p_->program_.record_diagnostic(name, value); // shared subsystem (ADC-594) } +void AmrSystem::record_program_balance_term(const std::string& route, const std::string& term, + double value) { + p_->program_.record_balance_term(route, term, value, "AmrSystem"); +} +bool AmrSystem::program_balance_consumer_is_due(const std::string& contract, + const std::string& route, int every_n) const { + return p_->program_.balance_consumer_is_due(contract, route, every_n, "AmrSystem"); +} double AmrSystem::program_diagnostic(const std::string& name) const { // AMR keeps its historical LENIENT read (missing name -> 0.0), distinct from System's fail-loud // program_diagnostic; not routed through the struct's throwing diagnostic() helper. @@ -3631,6 +3690,35 @@ double AmrSystem::program_diagnostic(const std::string& name) const { std::map AmrSystem::program_diagnostics() const { return p_->program_.diagnostics_; } +std::map AmrSystem::accepted_balance_terms(const std::string& route) const { + if (!p_->external_step_transaction_active_ || p_->external_step_transaction_committed_) + throw std::runtime_error( + "AmrSystem::_accepted_balance_terms requires an active uncommitted external step " + "transaction"); + return p_->program_.accepted_balance_terms(route, "AmrSystem"); +} +std::map AmrSystem::selected_accepted_balance_terms( + const std::string& route, const std::string& block, int component, + const std::vector& levels, const std::vector& automatic_terms) const { + if (!p_->external_step_transaction_active_ || p_->external_step_transaction_committed_) + throw std::runtime_error( + "AmrSystem::_selected_accepted_balance_terms requires an active uncommitted external step " + "transaction"); + if (!p_->runtime) + throw std::runtime_error( + "AmrSystem::_selected_accepted_balance_terms requires an installed AMR runtime"); + const std::size_t runtime_block = p_->block_index_or_throw(block); + if (component < 0 || component >= p_->runtime->block_n_vars(runtime_block)) + throw std::out_of_range( + "AmrSystem::_selected_accepted_balance_terms component is out of range"); + if (levels.empty() || std::any_of(levels.begin(), levels.end(), [&](int level) { + return level < 0 || level >= p_->runtime->nlev(); + })) + throw std::out_of_range( + "AmrSystem::_selected_accepted_balance_terms level is out of active hierarchy range"); + return p_->program_.selected_accepted_balance_terms( + route, static_cast(runtime_block), component, levels, automatic_terms, "AmrSystem"); +} void AmrSystem::begin_step_projection_report() { p_->program_.begin_step_projection_report(); } @@ -4413,9 +4501,9 @@ std::vector AmrSystem::output_geometry_boxes() { return p_->runtime->output_geometry_boxes(); } -std::vector AmrSystem::output_state_root_pieces(const WorldCommunicator& world, +std::vector AmrSystem::output_state_root_pieces(const ObserverMpiLane& lane, const std::string& name, int k) { - return output_pieces_to_root(world, + return output_pieces_to_root(lane, detail::output_collective_identity("AmrSystem", "state", name, k), [&] { return output_state_local_pieces(name, k); }); } @@ -4548,7 +4636,8 @@ int AmrSystem::rebuild_history_slots(const std::string& name, p_->program_.stride_, [imp](double dt, int cursor) { imp->macro_step_ = cursor; // ctx.macro_step() -> facade cursor -> regrid_if_due schedule imp->program_.last_dt_ = static_cast(dt); - imp->program_.step_(dt); + imp->program_.run_balance_replay("AmrSystem::rebuild_history_slots", + [&] { imp->program_.step_(dt); }); }); } catch (...) { p_->macro_step_ = m; diff --git a/src/runtime/builders/amr/block/compressible/amr_block_compressible.cpp b/src/runtime/builders/amr/block/compressible/amr_block_compressible.cpp index 3f97440ec..168584d28 100644 --- a/src/runtime/builders/amr/block/compressible/amr_block_compressible.cpp +++ b/src/runtime/builders/amr/block/compressible/amr_block_compressible.cpp @@ -13,6 +13,10 @@ AmrRuntimeBlock build_amr_block_compressible(const AmrBlockBuildArgs& a, const S // to one seam leaf; the default is the defense-in-depth registry/dispatch guard. validate_riemann(a.riemann, /*polar=*/false, "add_block(AmrSystem, multi-block)"); validate_limiter(a.limiter, "add_block(AmrSystem, multi-block)"); + if (a.wave_speed_cache && a.riemann != "hll") + throw std::runtime_error( + "add_block(AmrSystem, multi-block): wave_speed_cache requires flux='hll'; no alternate " + "flux"); switch (parse_riemann_route(a.riemann, "add_block(AmrSystem, multi-block)")) { case RiemannRouteId::kRusanov: return build_amr_block_compressible_rusanov(a, S); @@ -22,6 +26,8 @@ AmrRuntimeBlock build_amr_block_compressible(const AmrBlockBuildArgs& a, const S return build_amr_block_compressible_hllc(a, S); case RiemannRouteId::kRoe: return build_amr_block_compressible_roe(a, S); + case RiemannRouteId::kRoeHllRusanovRecovery: + return build_amr_block_compressible_roe_hll_rusanov_recovery(a, S); } throw_registry_dispatch_mismatch("add_block(AmrSystem, multi-block)", "flux", a.riemann); } diff --git a/src/runtime/builders/seam_combinations.cmake b/src/runtime/builders/seam_combinations.cmake index 0f95496d8..12bd3f651 100644 --- a/src/runtime/builders/seam_combinations.cmake +++ b/src/runtime/builders/seam_combinations.cmake @@ -49,10 +49,14 @@ set(POPS_SEAM_COMBINATIONS "system_transport_seam|system|exb|-|build_block_exb|system/base|system_exb.cpp" "system_flux_seam|system|isothermal|rusanov|build_block_isothermal_rusanov|system/isothermal|system_isothermal_rusanov.cpp" "system_flux_seam|system|isothermal|hll|build_block_isothermal_hll|system/isothermal|system_isothermal_hll.cpp" + "system_flux_seam|system|isothermal|hllc|build_block_isothermal_hllc|system/isothermal|system_isothermal_hllc.cpp" + "system_flux_seam|system|isothermal|roe|build_block_isothermal_roe|system/isothermal|system_isothermal_roe.cpp" + "system_flux_seam|system|isothermal|roe_hll_rusanov_recovery|build_block_isothermal_roe_hll_rusanov_recovery|system/isothermal|system_isothermal_roe_hll_rusanov_recovery.cpp" "system_flux_seam|system|compressible|rusanov|build_block_compressible_rusanov|system/compressible|system_compressible_rusanov.cpp" "system_flux_seam|system|compressible|hll|build_block_compressible_hll|system/compressible|system_compressible_hll.cpp" "system_flux_seam|system|compressible|hllc|build_block_compressible_hllc|system/compressible|system_compressible_hllc.cpp" "system_flux_seam|system|compressible|roe|build_block_compressible_roe|system/compressible|system_compressible_roe.cpp" + "system_flux_seam|system|compressible|roe_hll_rusanov_recovery|build_block_compressible_roe_hll_rusanov_recovery|system/compressible|system_compressible_roe_hll_rusanov_recovery.cpp" # --- AMR multi-block side ---------------------------------------------------------------------- "amr_block_transport_seam|amr_block|exb|-|build_amr_block_exb|amr/block/base|amr_block_exb.cpp" "amr_block_transport_seam|amr_block|isothermal|-|build_amr_block_isothermal|amr/block/base|amr_block_isothermal.cpp" @@ -60,6 +64,7 @@ set(POPS_SEAM_COMBINATIONS "amr_block_flux_seam|amr_block|compressible|hll|build_amr_block_compressible_hll|amr/block/compressible|amr_block_compressible_hll.cpp" "amr_block_flux_seam|amr_block|compressible|hllc|build_amr_block_compressible_hllc|amr/block/compressible|amr_block_compressible_hllc.cpp" "amr_block_flux_seam|amr_block|compressible|roe|build_amr_block_compressible_roe|amr/block/compressible|amr_block_compressible_roe.cpp" + "amr_block_flux_seam|amr_block|compressible|roe_hll_rusanov_recovery|build_amr_block_compressible_roe_hll_rusanov_recovery|amr/block/compressible|amr_block_compressible_roe_hll_rusanov_recovery.cpp" ) # Expand one manifest row into a generated seam .cpp under @p out_root, appending the generated path to diff --git a/src/runtime/output/hdf5_collective.cpp b/src/runtime/output/hdf5_collective.cpp index 32781070d..aaa84e31c 100644 --- a/src/runtime/output/hdf5_collective.cpp +++ b/src/runtime/output/hdf5_collective.cpp @@ -1,5 +1,4 @@ #include -#include #include #include @@ -160,9 +159,9 @@ template template [[nodiscard]] AgreedFailure collective_phase(int rank, MPI_Comm communicator, - Operation&& operation) { - return agree_failure( - rank, capture_local_failure(std::forward(operation)), communicator); + Operation&& operation) { + return agree_failure(rank, capture_local_failure(std::forward(operation)), + communicator); } [[noreturn]] void throw_collective_failure(std::string_view phase, std::string_view subject, @@ -186,8 +185,8 @@ void require_collective_success(std::string_view phase, std::string_view subject [[nodiscard]] AgreedFailure require_identical_text(int rank, std::string_view local, MPI_Comm communicator) { - int overflow = local.size() > static_cast( - std::numeric_limits::max()); + int overflow = + local.size() > static_cast(std::numeric_limits::max()); require_mpi(MPI_Allreduce(MPI_IN_PLACE, &overflow, 1, MPI_INT, MPI_MAX, communicator), "MPI_Allreduce(schema length overflow)"); if (overflow != 0) { @@ -217,8 +216,7 @@ void require_collective_success(std::string_view phase, std::string_view subject length - offset, static_cast(std::numeric_limits::max()))); char* buffer = rank == 0 ? const_cast(local.data()) + static_cast(offset) : reference.data() + static_cast(offset); - require_mpi(MPI_Bcast(buffer, count, MPI_CHAR, 0, communicator), - "MPI_Bcast(schema bytes)"); + require_mpi(MPI_Bcast(buffer, count, MPI_CHAR, 0, communicator), "MPI_Bcast(schema bytes)"); offset += static_cast(count); } @@ -231,8 +229,7 @@ void require_collective_success(std::string_view phase, std::string_view subject using PieceDescriptor = std::array; -[[nodiscard]] std::vector piece_descriptors( - const std::vector& fields) { +[[nodiscard]] std::vector piece_descriptors(const std::vector& fields) { std::size_t count = 0; for (const auto& field : fields) count = checked_add(count, field.pieces.size(), "native HDF5 piece descriptor count"); @@ -251,11 +248,10 @@ using PieceDescriptor = std::array; } } for (const auto& piece : field.pieces) { - result.push_back({static_cast(field_index), - static_cast(piece.jlo), - static_cast(piece.ilo), - static_cast(piece.jhi), - static_cast(piece.ihi)}); + result.push_back( + {static_cast(field_index), static_cast(piece.jlo), + static_cast(piece.ilo), static_cast(piece.jhi), + static_cast(piece.ihi)}); } } return result; @@ -263,13 +259,14 @@ using PieceDescriptor = std::array; [[nodiscard]] bool pieces_overlap(const PieceDescriptor& left, const PieceDescriptor& right) noexcept { - return left[0] == right[0] && left[1] < right[3] && right[1] < left[3] && - left[2] < right[4] && right[2] < left[4]; + return left[0] == right[0] && left[1] < right[3] && right[1] < left[3] && left[2] < right[4] && + right[2] < left[4]; } -[[nodiscard]] AgreedFailure require_disjoint_rank_pieces( - int rank, int ranks, const std::vector& local, - const std::vector& fields, MPI_Comm communicator) { +[[nodiscard]] AgreedFailure require_disjoint_rank_pieces(int rank, int ranks, + const std::vector& local, + const std::vector& fields, + MPI_Comm communicator) { static_assert(sizeof(PieceDescriptor) == 5 * sizeof(unsigned long long)); int length_overflow = 0; if constexpr (sizeof(std::size_t) > sizeof(unsigned long long)) { @@ -301,8 +298,7 @@ using PieceDescriptor = std::array; return finish(type_failure); for (int owner = 0; owner < ranks; ++owner) { - unsigned long long count = - rank == owner ? static_cast(local.size()) : 0ULL; + unsigned long long count = rank == owner ? static_cast(local.size()) : 0ULL; require_mpi(MPI_Bcast(&count, 1, MPI_UNSIGNED_LONG_LONG, owner, communicator), "MPI_Bcast(piece descriptor count)"); @@ -321,8 +317,8 @@ using PieceDescriptor = std::array; while (offset < count) { const int chunk = static_cast(std::min( count - offset, static_cast(std::numeric_limits::max()))); - require_mpi(MPI_Bcast(buffer + static_cast(offset), chunk, descriptor_type, owner, - communicator), + require_mpi(MPI_Bcast(buffer + static_cast(offset), chunk, descriptor_type, + owner, communicator), "MPI_Bcast(piece descriptors)"); offset += static_cast(chunk); } @@ -335,12 +331,12 @@ using PieceDescriptor = std::array; if (!pieces_overlap(mine, theirs)) continue; const auto field_index = static_cast(mine[0]); - const std::string_view dataset = - field_index < fields.size() ? std::string_view{fields[field_index].dataset} - : std::string_view{""}; - throw std::invalid_argument( - "field pieces overlap across MPI ranks " + std::to_string(owner) + " and " + - std::to_string(rank) + " for dataset " + std::string(dataset)); + const std::string_view dataset = field_index < fields.size() + ? std::string_view{fields[field_index].dataset} + : std::string_view{""}; + throw std::invalid_argument("field pieces overlap across MPI ranks " + + std::to_string(owner) + " and " + std::to_string(rank) + + " for dataset " + std::string(dataset)); } } }); @@ -566,8 +562,7 @@ void validate_inputs(const std::string& path, const std::string& manifest, return result; } -[[nodiscard]] std::vector group_paths( - const std::vector& datasets) { +[[nodiscard]] std::vector group_paths(const std::vector& datasets) { std::vector groups; for (const auto& dataset : datasets) { std::size_t cursor = 0; @@ -590,8 +585,8 @@ struct DatasetCreatePlan { std::vector zero; }; -[[nodiscard]] DatasetCreatePlan prepare_dataset_creation( - const std::vector& shape, const std::string& dtype) { +[[nodiscard]] DatasetCreatePlan prepare_dataset_creation(const std::vector& shape, + const std::string& dtype) { const auto dimensions = hdf5_shape(shape); DatasetCreatePlan plan; plan.space = H5Handle( @@ -766,12 +761,9 @@ struct ManifestAttributePlan { ParallelHdf5Capability parallel_hdf5_capability() { #if defined(POPS_HAS_PARALLEL_HDF5) - std::lock_guard guard{parallel_hdf5_mutex()}; - int initialized = 0; - require_mpi(MPI_Initialized(&initialized), "MPI_Initialized"); const std::string version = std::to_string(H5_VERS_MAJOR) + "." + std::to_string(H5_VERS_MINOR) + "." + std::to_string(H5_VERS_RELEASE); - return {true, version, initialized ? "" : "MPI is compiled but not initialized"}; + return {true, version, ""}; #else return {false, "", "module was not built with MPI and a parallel HDF5 C library"}; #endif @@ -794,19 +786,10 @@ void collective_hdf5_input_consensus(const CommunicatorView& communicator, LocalFailure local; if (!local_error.empty()) set_failure(local, local_error); - require_collective_success( - "binding input validation", "", agree_failure(rank, local, native)); + require_collective_success("binding input validation", "", agree_failure(rank, local, native)); #endif } -void collective_hdf5_input_consensus(const WorldCommunicator& world, - const std::string& local_error) { - if (&world != &WorldCommunicator::world()) - throw std::invalid_argument( - "collective HDF5 requires the exact native process-world authority"); - collective_hdf5_input_consensus(world.communicator(), local_error); -} - void write_collective_hdf5(const CommunicatorView& communicator, const std::string& path, const std::string& manifest_json, const std::vector& root_arrays, @@ -831,24 +814,22 @@ void write_collective_hdf5(const CommunicatorView& communicator, const std::stri require_mpi(MPI_Comm_size(native, &ranks), "MPI_Comm_size"); require_collective_success("input validation", "", collective_phase(rank, native, [&] { - validate_inputs(path, manifest_json, root_arrays, fields); - })); + validate_inputs(path, manifest_json, root_arrays, fields); + })); std::string schema; require_collective_success("schema preparation", "", collective_phase(rank, native, [&] { - schema = schema_text(path, manifest_json, root_arrays, fields); - })); - require_collective_success( - "schema consensus", "", require_identical_text(rank, schema, native)); + schema = schema_text(path, manifest_json, root_arrays, fields); + })); + require_collective_success("schema consensus", "", require_identical_text(rank, schema, native)); std::vector descriptors; require_collective_success( - "piece descriptor preparation", "", collective_phase(rank, native, [&] { - descriptors = piece_descriptors(fields); - })); - require_collective_success("piece descriptor consensus", "", - require_disjoint_rank_pieces( - rank, ranks, descriptors, fields, native)); + "piece descriptor preparation", "", + collective_phase(rank, native, [&] { descriptors = piece_descriptors(fields); })); + require_collective_success( + "piece descriptor consensus", "", + require_disjoint_rank_pieces(rank, ranks, descriptors, fields, native)); std::vector dataset_names; std::vector groups; @@ -861,48 +842,49 @@ void write_collective_hdf5(const CommunicatorView& communicator, const std::stri H5Handle transfer; require_collective_success( "local HDF5 preparation", "", collective_phase(rank, native, [&] { - dataset_names.reserve(root_arrays.size() + fields.size()); - for (const auto& array : root_arrays) - dataset_names.push_back(array.dataset); - for (const auto& field : fields) - dataset_names.push_back(field.dataset); - groups = group_paths(dataset_names); - - root_creation_plans.reserve(root_arrays.size()); - for (const auto& array : root_arrays) - root_creation_plans.push_back( - prepare_dataset_creation(array.values.shape, array.values.dtype)); - field_creation_plans.reserve(fields.size()); - for (const auto& field : fields) - field_creation_plans.push_back(prepare_dataset_creation(field.shape, field.dtype)); - manifest_plan = prepare_manifest_attribute(); - group_creation = H5Handle(H5Pcreate(H5P_GROUP_CREATE), H5Pclose); - if (!group_creation || H5Pset_obj_track_times(group_creation.get(), false) < 0) - throw std::runtime_error("HDF5 deterministic group creation-property preparation failed"); - file_creation = H5Handle(H5Pcreate(H5P_FILE_CREATE), H5Pclose); - if (!file_creation || H5Pset_obj_track_times(file_creation.get(), false) < 0) - throw std::runtime_error("HDF5 deterministic file creation-property preparation failed"); - - access = H5Handle(H5Pcreate(H5P_FILE_ACCESS), H5Pclose); - if (!access || H5Pset_fapl_mpio(access.get(), native, MPI_INFO_NULL) < 0) - throw std::runtime_error("H5Pset_fapl_mpio(explicit communicator) failed"); + dataset_names.reserve(root_arrays.size() + fields.size()); + for (const auto& array : root_arrays) + dataset_names.push_back(array.dataset); + for (const auto& field : fields) + dataset_names.push_back(field.dataset); + groups = group_paths(dataset_names); + + root_creation_plans.reserve(root_arrays.size()); + for (const auto& array : root_arrays) + root_creation_plans.push_back( + prepare_dataset_creation(array.values.shape, array.values.dtype)); + field_creation_plans.reserve(fields.size()); + for (const auto& field : fields) + field_creation_plans.push_back(prepare_dataset_creation(field.shape, field.dtype)); + manifest_plan = prepare_manifest_attribute(); + group_creation = H5Handle(H5Pcreate(H5P_GROUP_CREATE), H5Pclose); + if (!group_creation || H5Pset_obj_track_times(group_creation.get(), false) < 0) + throw std::runtime_error("HDF5 deterministic group creation-property preparation failed"); + file_creation = H5Handle(H5Pcreate(H5P_FILE_CREATE), H5Pclose); + if (!file_creation || H5Pset_obj_track_times(file_creation.get(), false) < 0) + throw std::runtime_error("HDF5 deterministic file creation-property preparation failed"); + + access = H5Handle(H5Pcreate(H5P_FILE_ACCESS), H5Pclose); + if (!access || H5Pset_fapl_mpio(access.get(), native, MPI_INFO_NULL) < 0) + throw std::runtime_error("H5Pset_fapl_mpio(explicit communicator) failed"); #if H5_VERSION_GE(1, 10, 0) - if (H5Pset_all_coll_metadata_ops(access.get(), 1) < 0 || - H5Pset_coll_metadata_write(access.get(), 1) < 0) - throw std::runtime_error("parallel HDF5 collective metadata configuration failed"); + if (H5Pset_all_coll_metadata_ops(access.get(), 1) < 0 || + H5Pset_coll_metadata_write(access.get(), 1) < 0) + throw std::runtime_error("parallel HDF5 collective metadata configuration failed"); #endif - transfer = H5Handle(H5Pcreate(H5P_DATASET_XFER), H5Pclose); - if (!transfer || H5Pset_dxpl_mpio(transfer.get(), H5FD_MPIO_COLLECTIVE) < 0) - throw std::runtime_error("H5Pset_dxpl_mpio(H5FD_MPIO_COLLECTIVE) failed"); - })); + transfer = H5Handle(H5Pcreate(H5P_DATASET_XFER), H5Pclose); + if (!transfer || H5Pset_dxpl_mpio(transfer.get(), H5FD_MPIO_COLLECTIVE) < 0) + throw std::runtime_error("H5Pset_dxpl_mpio(H5FD_MPIO_COLLECTIVE) failed"); + })); H5Handle file; - require_collective_success("file creation", path, collective_phase(rank, native, [&] { - file = H5Handle(H5Fcreate(path.c_str(), H5F_ACC_TRUNC, file_creation.get(), access.get()), - H5Fclose); - if (!file) - throw std::runtime_error("H5Fcreate returned an invalid handle"); - })); + require_collective_success( + "file creation", path, collective_phase(rank, native, [&] { + file = H5Handle(H5Fcreate(path.c_str(), H5F_ACC_TRUNC, file_creation.get(), access.get()), + H5Fclose); + if (!file) + throw std::runtime_error("H5Fcreate returned an invalid handle"); + })); AgreedFailure transaction_failure; auto remember_failure = [&](const AgreedFailure& failure) noexcept { @@ -1028,24 +1010,13 @@ void write_collective_hdf5(const CommunicatorView& communicator, const std::stri } const auto close_failure = collective_phase(rank, native, [&] { - const hid_t handle = file.release(); - if (H5Fclose(handle) < 0) - throw std::runtime_error("H5Fclose failed"); + const hid_t handle = file.release(); + if (H5Fclose(handle) < 0) + throw std::runtime_error("H5Fclose failed"); }); remember_failure(close_failure); require_collective_success("transaction", "", transaction_failure); #endif } -void write_collective_hdf5(const WorldCommunicator& world, const std::string& path, - const std::string& manifest_json, - const std::vector& root_arrays, - const std::vector& fields) { - if (&world != &WorldCommunicator::world()) - throw std::invalid_argument( - "collective HDF5 requires the exact native process-world authority"); - write_collective_hdf5( - world.communicator(), path, manifest_json, root_arrays, fields); -} - } // namespace pops::runtime::output diff --git a/src/runtime/system/system.cpp b/src/runtime/system/system.cpp index ebd8ab29a..94a00d49a 100644 --- a/src/runtime/system/system.cpp +++ b/src/runtime/system/system.cpp @@ -59,11 +59,9 @@ void System::commit_step_transaction() { } std::map System::step_change_l2() const { if (!p_->external_step_transaction_) - throw std::runtime_error( - "System::step_change_l2 requires an active external step transaction"); + throw std::runtime_error("System::step_change_l2 requires an active external step transaction"); if (p_->polar_) - throw std::runtime_error( - "System::step_change_l2 does not yet define the polar cell measure"); + throw std::runtime_error("System::step_change_l2 does not yet define the polar cell measure"); const auto& previous = p_->external_step_transaction_->states; if (previous.size() != p_->sp.size()) throw std::runtime_error("System::step_change_l2 snapshot composition mismatch"); @@ -73,12 +71,11 @@ std::map System::step_change_l2() const { if (p_->geometry_mode_ == GeometryMode::CutCell) measure.inverse_volume_fraction = &p_->eb_inverse_volume_fraction_; } - const double cell_area = - static_cast(p_->geom.dx()) * static_cast(p_->geom.dy()); + const double cell_area = static_cast(p_->geom.dx()) * static_cast(p_->geom.dy()); std::map result; for (std::size_t block = 0; block < p_->sp.size(); ++block) { - const double sum_sq = static_cast( - pops::difference_sum_sq_all(p_->sp[block].U, previous[block], measure)); + const double sum_sq = + static_cast(pops::difference_sum_sq_all(p_->sp[block].U, previous[block], measure)); result.emplace(p_->sp[block].name, std::sqrt(cell_area * sum_sq)); } return result; @@ -134,25 +131,44 @@ void System::mark_bound() { throw std::runtime_error( "System::mark_bound: materialized block lacks its exact state route"); for (const auto& [name, plan] : p_->boundary_plans_) { - if (p_->eb_set_ && p_->geometry_mode_ != GeometryMode::None && - plan->has_component_boundaries()) - throw std::runtime_error( - "System::mark_bound: embedded-boundary block '" + name + - "' has a native boundary component without a geometry-aware provider"); auto found = std::find_if(p_->sp.begin(), p_->sp.end(), [&name](const Impl::Species& block) { return block.name == name; }); if (found == p_->sp.end()) throw std::runtime_error( "System::mark_bound: prepared boundary plan references unknown block '" + name + "'"); + const SpatialProviderGeometry geometry = p_->geometry_mode_ == GeometryMode::None + ? found->base_spatial_geometry + : spatial_provider_geometry(p_->geometry_mode_); + const auto supports = [&](SpatialProviderOperation operation) { + return found->spatial_provider.supports({kNativeDimension, geometry, operation}); + }; + if (plan->requires_characteristic_no_inflow() && + !supports(SpatialProviderOperation::CharacteristicNoInflow)) + throw std::runtime_error( + "System::mark_bound: block '" + name + + "' has characteristic no-inflow without a qualified spatial provider"); + if (plan->has_component_boundaries() && + !supports(SpatialProviderOperation::BoundaryLinearization)) + throw std::runtime_error( + "System::mark_bound: block '" + name + + "' has a native boundary component without a geometry-aware provider"); if (plan->ncomp() != found->ncomp) throw std::runtime_error( "System::mark_bound: prepared boundary component count differs from block '" + name + "'"); - if (!same_periodicity(plan->periodicity(), p_->per_)) + const auto axis_periodicity = plan->axis_aligned_periodicity(); + if (axis_periodicity) { + if (!same_periodicity(*axis_periodicity, p_->per_)) + throw std::runtime_error( + "System::mark_bound: prepared boundary plan periodicity disagrees with the domain " + "topology for block '" + + name + "'"); + } else if (p_->per_.x || p_->per_.y) { throw std::runtime_error( - "System::mark_bound: prepared boundary plan periodicity disagrees with the domain " - "topology for block '" + + "System::mark_bound: an axis-permuted boundary plan cannot be combined with an " + "axis-periodic domain topology for block '" + name + "'"); + } (void)plan->has_boundary_linearization(); runtime::multiblock::BoundaryEvaluationPoint preparation_point; preparation_point.clock = plan->identity() + "::bound-runtime"; diff --git a/src/runtime/system/system_fields.cpp b/src/runtime/system/system_fields.cpp index 460c04b42..81f22061d 100644 --- a/src/runtime/system/system_fields.cpp +++ b/src/runtime/system/system_fields.cpp @@ -4,14 +4,128 @@ // accessors. This TU is a subdivision of system.cpp (state marshaling + field derivation surface). // Pure body move from system.cpp, no logic changed -> production trajectories bit-identical. #include "system_impl.hpp" // ADC-632: shared System::Impl + facade helpers (runtime-private) +#include +#include #include #include #include +#include +#include #include #include namespace pops { +namespace { + +template +void require_recoverable_system_candidate(const Species& state, const MultiFab& candidate, + std::string_view operation) { + const long missing_recovery = all_reduce_sum(state.cons_to_prim ? 0L : 1L); + if (missing_recovery != 0) + throw std::runtime_error(std::string(operation) + + ": target block has no prepared variable-recovery authority"); + + // Candidate kernels may execute asynchronously. The type-erased prepared recovery is a host + // closure over one cell, so make the latest device values visible before inspecting storage. + candidate.sync_host(); + std::vector conserved(static_cast(state.ncomp)); + std::vector primitive(static_cast(state.ncomp)); + long local_failures = 0; + for (int local = 0; local < candidate.local_size(); ++local) { + const ConstArray4 values = candidate.fab(local).const_array(); + const Box2D valid = candidate.box(local); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) { + for (int component = 0; component < state.ncomp; ++component) + conserved[static_cast(component)] = values(i, j, component); + try { + const RecoveryReport report = state.cons_to_prim(conserved.data(), primitive.data()); + const bool finite_candidate = + std::all_of(conserved.begin(), conserved.end(), + [](double value) { return std::isfinite(value); }) && + std::all_of(primitive.begin(), primitive.end(), + [](double value) { return std::isfinite(value); }); + if (!report.publication_permitted() || !finite_candidate) + ++local_failures; + } catch (...) { + // Do not let a rank-local provider exception strand peers before the collective verdict. + ++local_failures; + } + } + } + + const long failures = all_reduce_sum(local_failures); + if (failures != 0) + throw std::runtime_error(std::string(operation) + + ": prepared variable recovery rejected the candidate before " + "publication (failed cells=" + + std::to_string(failures) + ")"); +} + +template +void publish_recovered_initial_candidate(Species& state, MultiFab& candidate, + std::string_view operation) { + require_recoverable_system_candidate(state, candidate, operation); + + PureFieldAlgebra::copy(state.U, candidate); + // candidate is setup-local storage; publication must finish before it is destroyed. + device_fence(); +} + +void require_exact_field_evaluation_request( + const runtime::multiblock::BoundaryEvaluationPoint& point, std::string_view provider_slot, + std::string_view request_kind) { + const bool invalid = + request_kind.empty() || provider_slot.empty() || point.clock.empty() || point.tick < 0 || + point.level != 0 || point.substep < 0 || point.stage < 0 || !std::isfinite(point.dt) || + point.dt <= 0.0 || !std::isfinite(point.physical_time) || + point.stage_fraction < amr::Rational(0, 1) || amr::Rational(1, 1) < point.stage_fraction; + if (all_reduce_max(invalid ? 1L : 0L) != 0) + throw std::invalid_argument( + "System exact field evaluation requires one complete level-zero point and provider slot"); + + ExactContractBuilder request; + request.text("pops.system.exact-field-evaluation") + .scalar(std::uint32_t{1}) + .text(request_kind) + .text(provider_slot) + .text(point.clock) + .scalar(point.tick) + .scalar(static_cast(point.level)) + .scalar(static_cast(point.substep)) + .scalar(static_cast(point.stage)) + .scalar(point.stage_fraction.numerator) + .scalar(point.stage_fraction.denominator) + .scalar(point.dt) + .scalar(point.physical_time); + const std::string exact_request = std::move(request).release(); + if (!all_ranks_agree_exact_ordered_byte_pairs({{"system-exact-field-evaluation", exact_request}})) + throw std::invalid_argument( + "System exact field evaluation point differs between communicator ranks"); +} + +} // namespace + +void System::validate_program_state_publication_candidate(int block, + const MultiFab& candidate) const { + const long invalid_block = + all_reduce_sum(block >= 0 && block < static_cast(p_->sp.size()) ? 0L : 1L); + if (invalid_block != 0) + throw std::out_of_range( + "System Program state publication block index differs across communicator ranks"); + const Impl::Species& state = p_->sp[static_cast(block)]; + const bool exact_layout = candidate.box_array().boxes() == state.U.box_array().boxes() && + candidate.dmap().ranks() == state.U.dmap().ranks() && + candidate.ncomp() == state.U.ncomp() && + candidate.n_grow() == state.U.n_grow(); + if (all_reduce_sum(exact_layout ? 0L : 1L) != 0) + throw std::invalid_argument( + "System Program state publication candidate differs from its block layout"); + require_recoverable_system_candidate( + state, candidate, + "System Program terminal state publication for block '" + state.name + "'"); +} void System::set_density(const std::string& name, const std::vector& rho) { Impl::Species& s = p_->find(name); @@ -60,12 +174,69 @@ void System::set_density(const std::string& name, const std::vector& rho } POPS_EXPORT void System::set_block_conversion(const std::string& name, CellConvert prim_to_cons, - CellConvert cons_to_prim) { + CellRecovery cons_to_prim) { Impl::Species& s = p_->find(name); + const auto boundary = p_->boundary_plans_.find(name); + if (boundary != p_->boundary_plans_.end()) { + if (!cons_to_prim) + throw std::runtime_error( + "System prepared boundary traces require the block-model variable-recovery authority"); + if (boundary->second->requires_fixed_state_conversion()) { + if (!prim_to_cons) + throw std::runtime_error( + "System primitive fixed-state boundary requires the block-model conversion"); + const int ncomp = s.ncomp; + boundary->second->prepare_fixed_state_conversion( + [prim_to_cons, cons_to_prim, ncomp](const double* primitive, double* conservative) { + prim_to_cons(primitive, conservative); + std::vector recovered(static_cast(ncomp)); + const RecoveryReport report = cons_to_prim(conservative, recovered.data()); + if (!report.publication_permitted()) + throw std::runtime_error( + "primitive fixed-state boundary conversion failed prepared variable recovery"); + }); + } + boundary->second->prepare_trace_recovery(cons_to_prim); + } + // A replacement pointwise authority must never inherit warm starts produced by the previous + // model/provider. The matching batch authority is installed explicitly immediately afterwards + // by every supported native and compiled builder. Until then primitive-field materialization + // fails closed instead of reviving a second cell-by-cell recovery engine. + s.batch_cons_to_prim = {}; s.prim_to_cons = std::move(prim_to_cons); s.cons_to_prim = std::move(cons_to_prim); } +POPS_EXPORT void System::set_block_characteristic_no_inflow(const std::string& name, + CharacteristicNoInflowFill fill) { + Impl::Species& block = p_->find(name); + const auto boundary = p_->boundary_plans_.find(name); + if (boundary == p_->boundary_plans_.end() || + !boundary->second->requires_characteristic_no_inflow()) + throw std::runtime_error( + "System characteristic no-inflow was not requested by the exact block boundary plan"); + const SpatialProviderGeometry geometry = p_->geometry_mode_ == GeometryMode::None + ? block.base_spatial_geometry + : spatial_provider_geometry(p_->geometry_mode_); + if (!block.spatial_provider.supports( + {kNativeDimension, geometry, SpatialProviderOperation::CharacteristicNoInflow})) + throw std::runtime_error( + "System characteristic no-inflow has no qualified provider for the active spatial " + "geometry"); + boundary->second->prepare_characteristic_no_inflow(std::move(fill)); +} + +POPS_EXPORT void System::set_block_batch_recovery(const std::string& name, + CellBatchRecovery batch_cons_to_prim) { + Impl::Species& state = p_->find(name); + if (!state.cons_to_prim) + throw std::runtime_error( + "System batch variable recovery requires the pointwise prepared recovery authority"); + if (!batch_cons_to_prim) + throw std::invalid_argument("System batch variable recovery callback must not be empty"); + state.batch_cons_to_prim = std::move(batch_cons_to_prim); +} + void System::set_primitive_state(const std::string& name, const std::vector& prim) { Impl::Species& s = p_->find(name); const int nc = s.ncomp; @@ -83,44 +254,76 @@ void System::set_primitive_state(const std::string& name, const std::vector conservative conversion (.so generated before " "this project ?) ; use set_state (direct conservative state)"); + if (!s.cons_to_prim) + throw std::runtime_error( + "System::set_primitive_state : the model of block '" + name + + "' has no prepared variable-recovery authority for validating conservative publication"); // CELL-BY-CELL conversion via the block model: we read the nc primitives component-major // (prim[c*nn + k]) into a small contiguous buffer, convert, and write the conservatives at the // same place in an output buffer. Then write_state pushes everything to the MultiFab (set_state // path, identical marshaling). Reuses therefore the existing marshaling (copy/write_state). std::vector cons(prim.size()); - std::vector cell_in(static_cast(nc)), cell_out(static_cast(nc)); + std::vector cell_in(static_cast(nc)); + std::vector cell_out(static_cast(nc)); + std::vector recovered(static_cast(nc)); + long local_failures = 0; for (std::size_t k = 0; k < nn; ++k) { for (int c = 0; c < nc; ++c) cell_in[c] = prim[static_cast(c) * nn + k]; - s.prim_to_cons(cell_in.data(), cell_out.data()); + std::fill(cell_out.begin(), cell_out.end(), std::numeric_limits::quiet_NaN()); + bool accepted = false; + try { + s.prim_to_cons(cell_in.data(), cell_out.data()); + const bool finite = std::all_of(cell_out.begin(), cell_out.end(), + [](double value) { return std::isfinite(value); }); + if (finite) { + const RecoveryReport report = s.cons_to_prim(cell_out.data(), recovered.data()); + accepted = report.publication_permitted(); + } + } catch (...) { + accepted = false; + } + if (!accepted) { + ++local_failures; + continue; + } for (int c = 0; c < nc; ++c) cons[static_cast(c) * nn + k] = cell_out[c]; } + const long failures = all_reduce_sum(local_failures); + if (failures != 0) + throw std::runtime_error( + "System::set_primitive_state : prepared variable recovery rejected conservative " + "publication (failed cells=" + + std::to_string(failures) + ")"); p_->write_state(s.U, nc, cons); } std::vector System::get_primitive_state(const std::string& name) { Impl::Species& s = p_->find(name); const int nc = s.ncomp; - // Number of cells = REAL EXTENTS of the index domain (n*n Cartesian, nr*ntheta polar), NOT - // cfg.n*cfg.n: in polar cfg.n = nr, so cfg.n^2 != nr*ntheta -> heap overflow (nthetanr). Cartesian bit-identical (dom.nx()==dom.ny()==n). - const std::size_t nn = - static_cast(p_->dom.nx()) * static_cast(p_->dom.ny()); if (!s.cons_to_prim) throw std::runtime_error( "System::get_primitive_state : the model of block '" + name + "' does not expose a conservative -> primitive conversion (.so generated before " "this project ?) ; use get_state (direct conservative state)"); + if (!s.batch_cons_to_prim) + throw std::runtime_error( + "System::get_primitive_state : block '" + name + + "' has no generation-qualified prepared batch recovery consumer"); const std::vector cons = p_->copy_state(s.U, nc); // get_state path (same marshaling) - std::vector prim(cons.size()); - std::vector cell_in(static_cast(nc)), cell_out(static_cast(nc)); - for (std::size_t k = 0; k < nn; ++k) { - for (int c = 0; c < nc; ++c) - cell_in[c] = cons[static_cast(c) * nn + k]; - s.cons_to_prim(cell_in.data(), cell_out.data()); - for (int c = 0; c < nc; ++c) - prim[static_cast(c) * nn + k] = cell_out[c]; + std::vector prim; + const UniformRecoveryBatchReport batch = s.batch_cons_to_prim(cons, prim); + if (!batch.publication_permitted()) { + const RecoveryReport& recovery = batch.recovery; + throw std::runtime_error( + "System::get_primitive_state : variable recovery failed for block '" + name + + "' at local cell " + std::to_string(batch.failed_cell) + " (status=" + + recovery_status_name(recovery.status) + ", cause=" + recovery_cause_name(recovery.cause) + + ", failing_component=" + std::to_string(recovery.failing_component) + + ", attempted_methods=" + std::to_string(recovery.attempted_methods) + + ", last_method=" + recovery_method_kind_name(recovery.last_method_kind) + + ", last_method_index=" + std::to_string(recovery.last_method) + ")"); } return prim; } @@ -153,11 +356,9 @@ SolveReport System::solve_fields_from_state_in_place_(int block_idx, const Multi } SolveReport System::solve_fields_from_state_at_in_place_( - const runtime::multiblock::BoundaryEvaluationPoint& /*point*/, const std::string& provider_slot, + const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& provider_slot, int block_idx, const MultiFab& U_stage) { - if (provider_slot.empty()) - throw std::invalid_argument( - "System::solve_fields_from_state_at requires an exact provider slot"); + require_exact_field_evaluation_request(point, provider_slot, "single-stage"); return p_->solve_named_field_from_state(provider_slot, block_idx, U_stage); } @@ -195,6 +396,13 @@ POPS_EXPORT SolveReport System::solve_fields_from_blocks_in_place_( return p_->solve_named_field_from_blocks(field, U_stages); } +POPS_EXPORT SolveReport System::solve_fields_from_blocks_at_in_place_( + const runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& field, + const std::vector& U_stages) { + require_exact_field_evaluation_request(point, field, "simultaneous-stages"); + return p_->solve_named_field_from_blocks(field, U_stages); +} + SolveOutcome System::solve_fields() { prepare_default_field_publication_storage_(); return run_field_publication_outcome_([this]() { return solve_fields_in_place_(); }); @@ -233,12 +441,11 @@ SolveOutcome System::solve_fields_from_state(const std::string& field, int block }); } -SolveOutcome System::solve_fields_from_blocks( - const std::string& field, const std::vector& U_stages) { +SolveOutcome System::solve_fields_from_blocks(const std::string& field, + const std::vector& U_stages) { prepare_named_field_publication_storage_(field); - return run_field_publication_outcome_([this, &field, &U_stages]() { - return solve_fields_from_blocks_in_place_(field, U_stages); - }); + return run_field_publication_outcome_( + [this, &field, &U_stages]() { return solve_fields_from_blocks_in_place_(field, U_stages); }); } void System::prepare_default_field_publication_storage_() { @@ -352,6 +559,7 @@ void System::stage_field_publication_candidate() { if (!p_->field_publication_active_ || !p_->accepted_field_publication_ || p_->field_publication_candidate_ready_) throw std::logic_error("System field publication has no unique active candidate slot"); + p_->fields_.stage_named_topology_reports(); if (p_->candidate_field_publication_) p_->candidate_field_publication_->capture(*p_); else @@ -365,8 +573,7 @@ void System::validate_field_publication_candidate() { !p_->candidate_field_publication_ || !p_->field_publication_candidate_ready_) throw std::logic_error("System field publication has no staged candidate"); if (!p_->candidate_field_publication_->publication_layout_matches(*p_)) - throw std::logic_error( - "System field publication snapshot layout changed before Accept"); + throw std::logic_error("System field publication snapshot layout changed before Accept"); } void System::accept_field_publication_candidate() noexcept { @@ -688,8 +895,13 @@ std::int64_t System::set_analytic_expression_state( return std::pair>{ &state, std::move(programs)}; }); - return analytic::materialize_cell_average(prepared.first->U, p_->geom.xlo, p_->geom.ylo, - p_->geom.dx(), p_->geom.dy(), prepared.second); + MultiFab candidate(prepared.first->U.box_array(), prepared.first->U.dmap(), + prepared.first->U.ncomp(), prepared.first->U.n_grow()); + const std::int64_t materialized = analytic::materialize_cell_average( + candidate, p_->geom.xlo, p_->geom.ylo, p_->geom.dx(), p_->geom.dy(), prepared.second); + publish_recovered_initial_candidate(*prepared.first, candidate, + "System::set_analytic_expression_state"); + return materialized; } std::int64_t System::set_analytic_mapped_state(const std::string& name, const std::vector>& opcodes, @@ -753,9 +965,12 @@ std::int64_t System::set_analytic_mapped_state(const std::string& name, dst(i, j, c) = src(i, j, c); } device_fence(); - return analytic::materialize_discrete_mapped_state(state->U, seed, p_->aux, p_->geom.xlo, - p_->geom.ylo, p_->geom.dx(), p_->geom.dy(), - programs, bindings); + MultiFab candidate(state->U.box_array(), state->U.dmap(), state->U.ncomp(), state->U.n_grow()); + const std::int64_t materialized = analytic::materialize_discrete_mapped_state( + candidate, seed, p_->aux, p_->geom.xlo, p_->geom.ylo, p_->geom.dx(), p_->geom.dy(), programs, + bindings); + publish_recovered_initial_candidate(*state, candidate, "System::set_analytic_mapped_state"); + return materialized; } std::int64_t System::set_analytic_gaussian_state(const std::string& name, double center_x, double center_y, double background, @@ -764,10 +979,13 @@ std::int64_t System::set_analytic_gaussian_state(const std::string& name, double if (p_->polar_) throw std::runtime_error("System::set_analytic_gaussian_state requires a Cartesian frame"); Impl::Species& state = p_->find(name); - return analytic::materialize_gaussian_cell_average( - state.U, p_->geom.xlo, p_->geom.ylo, p_->geom.dx(), p_->geom.dy(), + MultiFab candidate(state.U.box_array(), state.U.dmap(), state.U.ncomp(), state.U.n_grow()); + const std::int64_t materialized = analytic::materialize_gaussian_cell_average( + candidate, p_->geom.xlo, p_->geom.ylo, p_->geom.dx(), p_->geom.dy(), static_cast(center_x), static_cast(center_y), static_cast(background), static_cast(amplitude), static_cast(inverse_width)); + publish_recovered_initial_candidate(state, candidate, "System::set_analytic_gaussian_state"); + return materialized; } int System::n_vars(const std::string& name) const { return p_->find(name).ncomp; @@ -918,19 +1136,19 @@ std::vector System::output_field_local_pieces(const std::string& pr return output_local_pieces(field, 0, false); } -std::vector System::output_state_root_pieces(const WorldCommunicator& world, +std::vector System::output_state_root_pieces(const ObserverMpiLane& lane, const std::string& name, int level) const { - return output_pieces_to_root(world, + return output_pieces_to_root(lane, detail::output_collective_identity("System", "state", name, level), [&] { return output_state_local_pieces(name, level); }); } -std::vector System::output_field_root_pieces(const WorldCommunicator& world, +std::vector System::output_field_root_pieces(const ObserverMpiLane& lane, const std::string& provider_slot, int level) { return output_pieces_to_root( - world, detail::output_collective_identity("System", "field", provider_slot, level), + lane, detail::output_collective_identity("System", "field", provider_slot, level), [&] { return output_field_local_pieces(provider_slot, level); }); } diff --git a/src/runtime/system/system_impl.hpp b/src/runtime/system/system_impl.hpp index 1e3cd8f43..1fddcd96a 100644 --- a/src/runtime/system/system_impl.hpp +++ b/src/runtime/system/system_impl.hpp @@ -408,9 +408,11 @@ struct System::Impl { if (found != boundary_plans_.end()) boundary_plan = found->second; } + const Geometry boundary_geometry = + polar_ ? Geometry{dom, pgeom_.r_min, pgeom_.r_max, Real(0), PolarGeometry::kTwoPi} : geom; GridContext context{dom, bc_, - geom, + boundary_geometry, &aux, &domain_mask_, &eb_inverse_volume_fraction_, @@ -512,7 +514,15 @@ struct System::Impl { // POLAR grid context (ring pgeom_ + r/theta BC + aux) for the polar block closures // (block_builder_polar.hpp). Counterpart of grid_ctx(); never called in Cartesian. - PolarGridContext grid_ctx_polar() { return PolarGridContext{dom, bc_, pgeom_, &aux}; } + PolarGridContext grid_ctx_polar(const std::string& block_name = {}) { + std::shared_ptr boundary_plan; + if (!block_name.empty()) { + const auto found = boundary_plans_.find(block_name); + if (found != boundary_plans_.end()) + boundary_plan = found->second; + } + return PolarGridContext{dom, bc_, pgeom_, &aux, std::move(boundary_plan)}; + } // ensure_elliptic_polar / solve_fields_polar / solve_fields (body) EXTRACTED into fields_ // (SystemFieldSolver, Batch B). Pure delegation: the Cartesian/polar dispatch, the device_fence and @@ -616,6 +626,11 @@ struct System::Impl { double cadence_clock_restore_accepted_time; int cadence_clock_restore_macro_step; std::map program_diagnostics; + std::map step_balance_terms; + std::map automatic_balance_terms; + bool automatic_balance_due; + bool balance_step_completed; + bool balance_program_was_due; pops::runtime::program::CacheManager cache; pops::runtime::program::HistoryManager history; pops::runtime::program::Profiler profiler; @@ -639,6 +654,11 @@ struct System::Impl { cadence_clock_restore_accepted_time(impl.program_.cadence_clock_restore_accepted_time_), cadence_clock_restore_macro_step(impl.program_.cadence_clock_restore_macro_step_), program_diagnostics(impl.program_.diagnostics_), + step_balance_terms(impl.program_.step_balance_terms_), + automatic_balance_terms(impl.program_.automatic_balance_terms_), + automatic_balance_due(impl.program_.automatic_balance_due_), + balance_step_completed(impl.program_.balance_step_completed_), + balance_program_was_due(impl.program_.balance_program_was_due_), cache(impl.program_.cache_), history(impl.program_.hist_), profiler(impl.program_.profiler_), @@ -670,6 +690,11 @@ struct System::Impl { impl.program_.cadence_clock_restore_accepted_time_ = cadence_clock_restore_accepted_time; impl.program_.cadence_clock_restore_macro_step_ = cadence_clock_restore_macro_step; impl.program_.diagnostics_ = program_diagnostics; + impl.program_.step_balance_terms_ = step_balance_terms; + impl.program_.automatic_balance_terms_ = automatic_balance_terms; + impl.program_.automatic_balance_due_ = automatic_balance_due; + impl.program_.balance_step_completed_ = balance_step_completed; + impl.program_.balance_program_was_due_ = balance_program_was_due; impl.program_.cache_ = cache; impl.program_.hist_ = history; impl.program_.profiler_ = profiler; diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index 4ed735086..f55eccde1 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -58,6 +58,10 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st const bool imexrk = (time == "imexrk_ars222"); const bool imex = (time == "imex" || imexrk); // both go through the implicit source step const bool recon_prim = (recon == "primitive"); + if (newton_diagnostics) + throw std::runtime_error( + "System::add_block : newton_diagnostics=true is unavailable on the Program-only System " + "runtime because no typed implicit Program consumer publishes that report"); // Wave speed cache (opt-in): only engages for the HLL residual. Requesting it // elsewhere would be SILENTLY without effect -> explicit error (no silent ignore). The polar path has // its own factory (make_block_polar) without this cache. @@ -68,10 +72,10 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st "speed cache only applies to the HLL flux ; received riemann='" + riemann + "')"); if (imex) - throw std::runtime_error("System::add_block : wave_speed_cache not supported with time='" + - time + - "' (the cached residual is not available to an implicit Program ; use time " - "'explicit'/'ssprk3'/'euler')"); + throw std::runtime_error( + "System::add_block : wave_speed_cache not supported with time='" + time + + "' (the cached residual is not available to an implicit Program ; use time " + "'explicit'/'ssprk3'/'euler')"); if (P->polar_) throw std::runtime_error( "System::add_block : wave_speed_cache not supported on the polar " @@ -135,7 +139,9 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st std::function max_speed; std::function add_poisson_rhs; std::function src_freq, stab_dt; // optional step bounds (model traits) - CellConvert prim_to_cons, cons_to_prim; // pointwise model conversions (set/get_primitive_state) + CellConvert prim_to_cons; // pointwise model conversion (set_primitive_state) + CellRecovery cons_to_prim; // fallible prepared recovery (get_primitive_state) + CellBatchRecovery batch_cons_to_prim; // materialized host/Uniform primitive field VariableSet cons_vs, prim_vs; detail::BuiltBlock bb; if (P->polar_) { @@ -150,8 +156,11 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st "' (IMEX / IMEX-RK ARS(2,2,2)) unsupported " "(ring : coupling by explicit local source, no stiff source to handle implicitly " "at this stage). Use 'explicit'/'ssprk3'."); - const PolarGridContext pctx = P->grid_ctx_polar(); - bb = detail::build_block_polar(model, limiter, riemann, pctx, recon_prim, + const PolarGridContext pctx = P->grid_ctx_polar(name); + const auto state_route = P->block_state_identities_.find(name); + const std::string state_identity = + state_route == P->block_state_identities_.end() ? std::string{} : state_route->second; + bb = detail::build_block_polar(model, name, state_identity, limiter, riemann, pctx, recon_prim, static_cast(positivity_floor), &P->aux); // ADC-291: widen the shared aux to the polar block's read width (canonical extras AND model-named // extra[k]), mirroring the Cartesian branch below. ensure_aux_width keeps the aux ADDRESS captured @@ -160,12 +169,6 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st P->ensure_aux_width(bb.aux_width); } else { const GridContext ctx = P->grid_ctx(name); - // Preserve the requested diagnostic carrier until the typed implicit Program primitive owns and - // writes it. The spatial closures never capture this state. - if (newton_diagnostics) { - auto rep = std::make_shared(); - P->diagnostics_.newton_reports[name] = rep; - } // Transport-axis seam (ADC-335): each per-transport TU (python/system_.cpp) runs the // SAME source/elliptic dispatch + make_block + makers as before (detail::build_block_for), but // instantiates ONLY its own transport's leaves -- so the combinatorial product splits across files @@ -193,12 +196,16 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st bb = detail::build_block_exb(model, args); break; case TransportRouteId::kCompressible: { - // Compressible/Euler is flux-subdivided (ADC-335): all four fluxes are valid (4-var + pressure), + // Compressible/Euler is flux-subdivided (ADC-335): its single-solver fluxes and fixed + // recovery policy are valid (4-var + pressure), // so we run the SAME validation as make_block (validate_riemann then validate_limiter, identical // messages) and dispatch the riemann route to the matching per-flux sub-TU. An unknown flux hits // the same registry throw as make_block's tail (validate_riemann already rejected it). validate_riemann(riemann, /*polar=*/false, "System"); validate_limiter(limiter, "System"); + if (args.wave_speed_cache && riemann != "hll") + throw std::runtime_error( + "System: wave_speed_cache requires flux='hll'; no alternate flux"); switch (parse_riemann_route(riemann, "System")) { case RiemannRouteId::kRusanov: bb = detail::build_block_compressible_rusanov(model, args); @@ -212,19 +219,24 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st case RiemannRouteId::kRoe: bb = detail::build_block_compressible_roe(model, args); break; + case RiemannRouteId::kRoeHllRusanovRecovery: + bb = detail::build_block_compressible_roe_hll_rusanov_recovery(model, args); + break; default: throw_registry_dispatch_mismatch("System", "flux", riemann); } break; } case TransportRouteId::kIsothermal: { - // Isothermal is flux-subdivided (ADC-342): only rusanov + hll are reachable (3-var, no pressure - // for hllc/roe). The per-flux seams call make_block_ directly, so -- like compressible -- - // we run make_block's validation here (validate_riemann then validate_limiter, identical - // messages) before dispatching; hllc/roe and any unknown flux hit the registry throw (explicit, - // no UB). The default preserves isothermal+hllc -> registry-mismatch throw exactly. + // Isothermal is flux-subdivided (ADC-342). Its physical provider now supplies the exact + // HLLC and Roe capabilities, so all public providers use the same per-flux seam shape as + // compressible Euler. The registry validates tokens; capability ownership remains in the + // model and no branch substitutes another solver. validate_riemann(riemann, /*polar=*/false, "System"); validate_limiter(limiter, "System"); + if (args.wave_speed_cache && riemann != "hll") + throw std::runtime_error( + "System: wave_speed_cache requires flux='hll'; no alternate flux"); switch (parse_riemann_route(riemann, "System")) { case RiemannRouteId::kRusanov: bb = detail::build_block_isothermal_rusanov(model, args); @@ -232,6 +244,15 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st case RiemannRouteId::kHll: bb = detail::build_block_isothermal_hll(model, args); break; + case RiemannRouteId::kHllc: + bb = detail::build_block_isothermal_hllc(model, args); + break; + case RiemannRouteId::kRoe: + bb = detail::build_block_isothermal_roe(model, args); + break; + case RiemannRouteId::kRoeHllRusanovRecovery: + bb = detail::build_block_isothermal_roe_hll_rusanov_recovery(model, args); + break; default: throw_registry_dispatch_mismatch("System", "flux", riemann); } @@ -250,21 +271,36 @@ void System::add_block(const std::string& name, const ModelSpec& model, const st stab_dt = std::move(bb.stab_dt); prim_to_cons = std::move(bb.prim_to_cons); cons_to_prim = std::move(bb.cons_to_prim); + batch_cons_to_prim = std::move(bb.batch_cons_to_prim); + auto synthesized_boundary_plan = std::move(bb.synthesized_boundary_plan); // Common installation (same path as add_compiled_model for a DSL-generated model): // the closures run on the REAL System MultiFabs (MPI halos via fill_boundary, device // via Kokkos), without copy. - install_block(name, ncomp, cons_vs, prim_vs, model.gamma, std::move(clo), std::move(max_speed), - std::move(add_poisson_rhs), substeps, evolve, stride); - EffectiveBlockOptions block_options = - make_system_block_options(name, model, "native_model", limiter, riemann, recon, time, method, - substeps, evolve, stride, implicit_vars, implicit_roles, - newton, newton_diagnostics, positivity_floor, wave_speed_cache, - weno_epsilon); + bool published_synthesized_boundary = false; + if (synthesized_boundary_plan) { + if (!P->boundary_plans_.emplace(name, synthesized_boundary_plan).second) + throw std::logic_error( + "System::add_block cannot publish a synthesized plan over a prepared boundary plan"); + published_synthesized_boundary = true; + } + try { + install_block(name, ncomp, cons_vs, prim_vs, model.gamma, std::move(clo), std::move(max_speed), + std::move(add_poisson_rhs), substeps, evolve, stride); + } catch (...) { + if (published_synthesized_boundary) + P->boundary_plans_.erase(name); + throw; + } + EffectiveBlockOptions block_options = make_system_block_options( + name, model, "native_model", limiter, riemann, recon, time, method, substeps, evolve, stride, + implicit_vars, implicit_roles, newton, newton_diagnostics, positivity_floor, wave_speed_cache, + weno_epsilon); block_options.ncomp = ncomp; block_options.conservative_vars = cons_vs.names; block_options.primitive_vars = prim_vs.names; P->diagnostics_.block_options[name] = std::move(block_options); set_block_conversion(name, std::move(prim_to_cons), std::move(cons_to_prim)); + set_block_batch_recovery(name, std::move(batch_cons_to_prim)); set_block_dt_bounds(name, std::move(src_freq), std::move(stab_dt)); // SCHEME GHOSTS: WENO5 reads a 5-point stencil (3 ghosts) > the 2 allocated by default in // install_block. We reallocate the block state with block_n_ghost(limiter) if needed (cf. AmrSystem which @@ -289,44 +325,6 @@ POPS_EXPORT GridContext System::grid_context(int block) { return p_->grid_ctx(p_->sp[static_cast(block)].name); } -namespace { -BCType prepared_bc_type(const std::string& token) { - if (token == "periodic") - return BCType::Periodic; - if (token == "foextrap") - return BCType::Foextrap; - if (token == "dirichlet") - return BCType::Dirichlet; - if (token == "external") - return BCType::External; - throw std::runtime_error("System::install_boundary_plan: unsupported face producer '" + token + - "'"); -} - -void set_prepared_face(BCRec& bc, int face, BCType type, Real value) { - switch (face) { - case 0: - bc.xlo = type; - bc.xlo_val = value; - return; - case 1: - bc.xhi = type; - bc.xhi_val = value; - return; - case 2: - bc.ylo = type; - bc.ylo_val = value; - return; - case 3: - bc.yhi = type; - bc.yhi_val = value; - return; - default: - throw std::runtime_error("System::install_boundary_plan: invalid face ordinal"); - } -} -} // namespace - POPS_EXPORT void System::install_block_state_route(const std::string& name, const std::string& state_identity) { Impl* P = p_.get(); @@ -346,50 +344,73 @@ POPS_EXPORT void System::install_block_state_route(const std::string& name, POPS_EXPORT void System::install_boundary_plan(const std::string& name, const std::string& identity, int required_depth, const std::vector& face_types, - const std::vector& face_values, int ncomp, + const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies) { - install_boundary_plan(name, identity, required_depth, face_types, face_values, ncomp, - omitted_interface_faces, state_identity, std::move(read_dependencies), {}); + install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + std::move(read_dependencies), {}); } POPS_EXPORT void System::install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, - const std::vector& face_types, const std::vector& face_values, int ncomp, + const std::vector& face_types, const std::vector& face_values, + const std::vector& face_identities, + const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications) { + std::vector periodic_identifications, + const std::vector& face_representations, + const std::vector& face_converter_identities, + const std::vector>& face_analytic_opcodes, + const std::vector>& face_analytic_literals, + const std::vector& face_analytic_clocks) { Impl* P = p_.get(); - require_assembling(P->lifecycle_, "install_boundary_plan"); - if (name.empty() || state_identity.empty()) - throw std::runtime_error( - "System::install_boundary_plan requires block and state-qualified identities"); - const auto state_route = P->block_state_identities_.find(name); - if (state_route == P->block_state_identities_.end() || state_route->second != state_identity) - throw std::runtime_error( - "System::install_boundary_plan state differs from the exact block state route"); - if (P->boundary_plans_.count(name) != 0) - throw std::runtime_error("System::install_boundary_plan duplicate block '" + name + "'"); - if (ncomp < 1 || face_types.size() != 4 || - face_values.size() != static_cast(4 * ncomp)) - throw std::runtime_error( - "System::install_boundary_plan requires four face types and ncomp*4 values"); - std::vector components(static_cast(ncomp)); - for (int comp = 0; comp < ncomp; ++comp) { - for (int face = 0; face < 4; ++face) { - set_prepared_face(components[static_cast(comp)], face, - prepared_bc_type(face_types[static_cast(face)]), - static_cast(face_values[static_cast(4 * comp + face)])); - } - } - auto plan = std::make_shared( - identity, required_depth, std::move(components), omitted_interface_faces, state_identity, - std::move(read_dependencies), std::move(periodic_identifications)); - for (const auto& [_, installed] : P->boundary_plans_) - if (installed->state_identity() == state_identity) - throw std::runtime_error("System::install_boundary_plan duplicate qualified state identity"); - P->boundary_plans_.emplace(name, std::move(plan)); + using BoundaryPlanMap = decltype(P->boundary_plans_); + using BoundaryPlanNode = typename BoundaryPlanMap::node_type; + BoundaryPlanNode prepared = analytic::collectively_prepare_exact_analytic_request( + "System::install_boundary_plan", + [&]() -> BoundaryPlanNode { + require_assembling(P->lifecycle_, "install_boundary_plan"); + if (name.empty() || state_identity.empty()) + throw std::runtime_error( + "System::install_boundary_plan requires block and state-qualified identities"); + const auto state_route = P->block_state_identities_.find(name); + if (state_route == P->block_state_identities_.end() || + state_route->second != state_identity) + throw std::runtime_error( + "System::install_boundary_plan state differs from the exact block state route"); + if (P->boundary_plans_.count(name) != 0) + throw std::runtime_error("System::install_boundary_plan duplicate block '" + name + "'"); + for (const auto& [_, installed] : P->boundary_plans_) + if (installed->state_identity() == state_identity) + throw std::runtime_error( + "System::install_boundary_plan duplicate qualified state identity"); + + auto hyperbolic = prepare_hyperbolic_boundary<2>( + face_types, face_values, face_identities, component_roles, + !periodic_identifications.empty(), face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + auto plan = std::make_shared( + identity, required_depth, std::move(hyperbolic), omitted_interface_faces, + state_identity, read_dependencies, periodic_identifications); + BoundaryPlanMap staged; + staged.emplace(name, std::move(plan)); + return staged.extract(staged.begin()); + }, + [&]() { + return detail::canonical_prepared_boundary_plan_request( + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, read_dependencies, + periodic_identifications, face_representations, face_converter_identities, + face_analytic_opcodes, face_analytic_literals, face_analytic_clocks); + }); + const auto published = P->boundary_plans_.insert(std::move(prepared)); + if (!published.inserted) + throw std::logic_error("System::install_boundary_plan lost its prepared publication slot"); } POPS_EXPORT void System::install_field_storage_route(const std::string& field_identity, @@ -418,6 +439,10 @@ POPS_EXPORT void System::install_ghost_boundary_component( std::shared_ptr component) { Impl* P = p_.get(); require_assembling(P->lifecycle_, "install_ghost_boundary_component"); + if (P->polar_) + throw std::runtime_error( + "System::install_ghost_boundary_component: polar transport has no native boundary " + "component provider"); if (P->eb_set_ && P->geometry_mode_ != GeometryMode::None) throw std::runtime_error( "System::install_ghost_boundary_component: embedded-boundary transport has no " @@ -429,11 +454,34 @@ POPS_EXPORT void System::install_ghost_boundary_component( found->second->install_ghost_component(std::move(spec), std::move(component)); } +POPS_EXPORT void System::install_boundary_flux_component( + const std::string& name, PreparedBoundaryComponentSpec spec, + std::shared_ptr component) { + Impl* P = p_.get(); + require_assembling(P->lifecycle_, "install_boundary_flux_component"); + if (P->polar_) + throw std::runtime_error( + "System::install_boundary_flux_component: polar transport has no post-Riemann boundary " + "flux provider"); + if (P->eb_set_ && P->geometry_mode_ != GeometryMode::None) + throw std::runtime_error( + "System::install_boundary_flux_component: embedded-boundary transport has no " + "geometry-aware post-Riemann provider"); + const auto found = P->boundary_plans_.find(name); + if (found == P->boundary_plans_.end()) + throw std::runtime_error("System boundary flux requires an installed block boundary plan"); + found->second->install_flux_component(std::move(spec), std::move(component)); +} + POPS_EXPORT void System::install_field_boundary_residual_component( const std::string& name, PreparedBoundaryComponentSpec spec, std::shared_ptr component) { Impl* P = p_.get(); require_assembling(P->lifecycle_, "install_field_boundary_residual_component"); + if (P->polar_) + throw std::runtime_error( + "System::install_field_boundary_residual_component: polar transport has no native field " + "boundary provider"); if (P->eb_set_ && P->geometry_mode_ != GeometryMode::None) throw std::runtime_error( "System::install_field_boundary_residual_component: embedded-boundary transport has no " @@ -450,6 +498,10 @@ POPS_EXPORT void System::install_field_boundary_jvp_component( std::shared_ptr component) { Impl* P = p_.get(); require_assembling(P->lifecycle_, "install_field_boundary_jvp_component"); + if (P->polar_) + throw std::runtime_error( + "System::install_field_boundary_jvp_component: polar transport has no native field " + "boundary provider"); if (P->eb_set_ && P->geometry_mode_ != GeometryMode::None) throw std::runtime_error( "System::install_field_boundary_jvp_component: embedded-boundary transport has no " @@ -514,18 +566,27 @@ POPS_EXPORT void System::install_block(const std::string& name, int ncomp, if (stride < 1) throw std::runtime_error("System::install_block : stride >= 1"); Impl* P = p_.get(); - if (P->eb_set_ && !supports_geometry_mode(closures.supported_geometry_modes, - P->geometry_mode_)) - throw std::runtime_error( - "System::install_block: block '" + name + - "' has no numerical provider for the active embedded-boundary geometry"); + const SpatialProviderGeometry active_geometry = + P->geometry_mode_ == GeometryMode::None ? closures.base_spatial_geometry + : spatial_provider_geometry(P->geometry_mode_); + const auto supports_active = [&](SpatialProviderOperation operation) { + return closures.spatial_provider.supports({kNativeDimension, active_geometry, operation}); + }; + if (!supports_active(SpatialProviderOperation::Residual)) + throw std::runtime_error("System::install_block: block '" + name + + "' has no numerical provider for the active spatial geometry"); const auto boundary_plan = P->boundary_plans_.find(name); - if (P->eb_set_ && P->geometry_mode_ != GeometryMode::None && - boundary_plan != P->boundary_plans_.end() && - boundary_plan->second->has_component_boundaries()) - throw std::runtime_error( - "System::install_block: embedded-boundary block '" + name + - "' has a native boundary component without a geometry-aware provider"); + if (boundary_plan != P->boundary_plans_.end() && + boundary_plan->second->requires_characteristic_no_inflow() && + !supports_active(SpatialProviderOperation::CharacteristicNoInflow)) + throw std::runtime_error("System::install_block: block '" + name + + "' has no characteristic no-inflow provider for the active spatial " + "geometry"); + if (boundary_plan != P->boundary_plans_.end() && + boundary_plan->second->has_component_boundaries() && + !supports_active(SpatialProviderOperation::BoundaryLinearization)) + throw std::runtime_error("System::install_block: embedded-boundary block '" + name + + "' has a native boundary component without a geometry-aware provider"); P->sp.push_back(Impl::Species{name, MultiFab(P->ba, P->dm, ncomp, 2), ncomp, substeps, evolve, stride, gamma, std::move(closures.rhs_into), std::move(max_speed), std::move(poisson_rhs)}); @@ -538,7 +599,8 @@ POPS_EXPORT void System::install_block(const std::string& name, int ncomp, P->sp.back().state_identity = state_route->second; } P->sp.back().U.set_val(Real(0)); - P->sp.back().supported_geometry_modes = closures.supported_geometry_modes; + P->sp.back().base_spatial_geometry = closures.base_spatial_geometry; + P->sp.back().spatial_provider = closures.spatial_provider; P->sp.back().cons_vars = cons_vars; P->sp.back().prim_vars = prim_vars; P->sp.back().hotspot = std::move(closures.hotspot); // dt_hotspot diagnostic (ADC-182) @@ -635,15 +697,16 @@ std::array System::dt_hotspot(const std::string& name) { return {static_cast(w), static_cast(i), static_cast(j)}; } -// Newton report (OPT-IN IMEX diagnostics) of the block. The carrier is written only by an installed -// typed implicit Program primitive; spatial block construction owns no implicit solve. +// Compatibility query for a typed implicit Program diagnostic carrier. The current Program-only +// System runtime rejects newton_diagnostics=true until a consumer actually publishes this carrier. System::SourceNewtonReport System::newton_report(const std::string& name) const { p_->index(name); // raises if unknown block const NewtonReport* rp = p_->diagnostics_.newton_report_ptr(name); if (rp == nullptr) throw std::runtime_error( - "System::newton_report : Newton diagnostics not enabled for block '" + name + - "' ; pass newton_diagnostics=True when installing the block"); + "System::newton_report : no typed implicit Program consumer published diagnostics for " + "block '" + + name + "'"); const NewtonReport& r = *rp; return SourceNewtonReport{r.enabled, r.converged, @@ -690,12 +753,14 @@ void System::add_native_block(const std::string& name, const std::string& so_pat opt.positivity_floor = positivity_floor; } -void System::add_external_riemann_block( - const std::string& name, const std::string& so_path, const std::string& brick_id, - const std::string& sha256, const std::string& limiter, const std::string& recon, - const std::string& time, double gamma, int substeps, bool evolve, int stride, - int expected_nvars, int expected_naux, const std::string& expected_model_identity, - double positivity_floor, double weno_epsilon) { +void System::add_external_riemann_block(const std::string& name, const std::string& so_path, + const std::string& brick_id, const std::string& sha256, + const std::string& limiter, const std::string& recon, + const std::string& time, double gamma, int substeps, + bool evolve, int stride, int expected_nvars, + int expected_naux, + const std::string& expected_model_identity, + double positivity_floor, double weno_epsilon) { require_assembling(p_->lifecycle_, "add_external_riemann_block"); auto library = std::make_shared( so_path, brick_id, sha256, expected_nvars, expected_naux, expected_model_identity); @@ -735,8 +800,7 @@ void System::set_poisson(const std::string& rhs, const std::string& solver, cons "System::set_poisson: polar geometry requires solver='polar'; solver substitution is " "forbidden"); if (!p_->polar_ && solver == "polar") - throw std::runtime_error( - "System::set_poisson: solver='polar' requires polar geometry"); + throw std::runtime_error("System::set_poisson: solver='polar' requires polar geometry"); using FieldSolver = field_solver::SystemFieldSolver; if (solver == "geometric_mg") { GeometricMgOptions mg_options; @@ -810,12 +874,12 @@ void System::set_field_solver_plan( const auto existing = p_->fields_.named_field_plans_.find(provider_slot); if (existing != p_->fields_.named_field_plans_.end()) throw std::runtime_error("System::set_field_solver_plan duplicate provider slot"); - const auto duplicate_output = std::find_if( - p_->fields_.named_field_plans_.begin(), p_->fields_.named_field_plans_.end(), - [&](const auto& configured) { - return configured.second.output_block == output_block && - configured.second.output_key == output_key; - }); + const auto duplicate_output = + std::find_if(p_->fields_.named_field_plans_.begin(), p_->fields_.named_field_plans_.end(), + [&](const auto& configured) { + return configured.second.output_block == output_block && + configured.second.output_key == output_key; + }); if (duplicate_output != p_->fields_.named_field_plans_.end()) throw std::runtime_error( "System::set_field_solver_plan output block/key already belongs to another qualified " @@ -1125,10 +1189,8 @@ struct AnalyticLevelSetPhysicalGhostKernel { } POPS_HD void operator()(int i, int j) const { - const bool physical_x = - !periodicity.x && (i < domain.lo[0] || i > domain.hi[0]); - const bool physical_y = - !periodicity.y && (j < domain.lo[1] || j > domain.hi[1]); + const bool physical_x = !periodicity.x && (i < domain.lo[0] || i > domain.hi[0]); + const bool physical_y = !periodicity.y && (j < domain.lo[1] || j > domain.hi[1]); if (!physical_x && !physical_y) return; @@ -1153,8 +1215,7 @@ struct AnalyticLevelSetMaskKernel { Array4 active_mask; POPS_HD void operator()(int i, int j) const { - active_mask(i, j, 0) = - level_set_values(i, j, 0) < Real(0) ? Real(1) : Real(0); + active_mask(i, j, 0) = level_set_values(i, j, 0) < Real(0) ? Real(1) : Real(0); } }; @@ -1171,8 +1232,7 @@ struct AnalyticInverseVolumeFractionKernel { } const detail::CutFraction fraction = detail::cut_fraction_from_samples( center, level_set_values(i - 1, j, 0), level_set_values(i + 1, j, 0), - level_set_values(i, j - 1, 0), level_set_values(i, j + 1, 0), dx, dy, - cut_theta_min); + level_set_values(i, j - 1, 0), level_set_values(i, j + 1, 0), dx, dy, cut_theta_min); const Real effective = fraction.kappa > kappa_min ? fraction.kappa : kappa_min; inverse_volume_fraction(i, j, 0) = Real(1) / effective; } @@ -1180,9 +1240,8 @@ struct AnalyticInverseVolumeFractionKernel { } // namespace void System::set_analytic_level_set(const std::vector& opcodes, - const std::vector& literals, - const std::string& mode, double kappa_min, - double face_open_eps, double cut_theta_min) { + const std::vector& literals, const std::string& mode, + double kappa_min, double face_open_eps, double cut_theta_min) { Impl* P = p_.get(); struct PreparedAnalyticLevelSet { GeometryMode geometry_mode = GeometryMode::None; @@ -1207,8 +1266,7 @@ void System::set_analytic_level_set(const std::vector& opcodes, "System::set_analytic_level_set : kappa_min / face_open_eps / " "cut_theta_min must be <= 1"); if (P->polar_) - throw std::runtime_error( - "System::set_analytic_level_set : Cartesian geometry required"); + throw std::runtime_error("System::set_analytic_level_set : Cartesian geometry required"); const GeometryMode geometry_mode = parse_geometry_mode(mode, "System::set_analytic_level_set"); if (geometry_mode != GeometryMode::None && P->ws_cache_block_) @@ -1222,16 +1280,35 @@ void System::set_analytic_level_set(const std::vector& opcodes, "System::set_analytic_level_set: embedded-boundary transport has no signed-mask or " "cut-cell shared-interface provider"); for (const auto& block : P->sp) - if (!supports_geometry_mode(block.supported_geometry_modes, geometry_mode)) - throw std::runtime_error( - "System::set_analytic_level_set: block '" + block.name + - "' has no numerical provider for embedded-boundary mode '" + mode + "'"); - if (geometry_mode != GeometryMode::None) - for (const auto& [name, plan] : P->boundary_plans_) - if (plan->has_component_boundaries()) + if (!supports_geometry_mode(block.spatial_provider, geometry_mode)) + throw std::runtime_error("System::set_analytic_level_set: block '" + block.name + + "' has no numerical provider for embedded-boundary mode '" + + mode + "'"); + if (geometry_mode != GeometryMode::None) { + const SpatialProviderGeometry geometry = spatial_provider_geometry(geometry_mode); + for (const auto& [name, plan] : P->boundary_plans_) { + const auto block = std::find_if( + P->sp.begin(), P->sp.end(), + [&name](const Impl::Species& candidate) { return candidate.name == name; }); + // Assembly order is intentionally free: install_block and mark_bound authenticate a + // plan installed before its block once that block has materialized. + if (block == P->sp.end()) + continue; + const auto supports = [&](SpatialProviderOperation operation) { + return block->spatial_provider.supports({kNativeDimension, geometry, operation}); + }; + if (plan->requires_characteristic_no_inflow() && + !supports(SpatialProviderOperation::CharacteristicNoInflow)) + throw std::runtime_error( + "System::set_analytic_level_set: block '" + name + + "' has characteristic no-inflow without an embedded-boundary metric provider"); + if (plan->has_component_boundaries() && + !supports(SpatialProviderOperation::BoundaryLinearization)) throw std::runtime_error( "System::set_analytic_level_set: block '" + name + "' has a native boundary component without an embedded-boundary metric provider"); + } + } std::vector compiled = analytic::compile_component_programs({opcodes}, {literals}); @@ -1242,8 +1319,7 @@ void System::set_analytic_level_set(const std::vector& opcodes, thresholds.face_open_eps = static_cast(face_open_eps); if (cut_theta_min > 0.0) thresholds.cut_theta_min = static_cast(cut_theta_min); - return PreparedAnalyticLevelSet{ - geometry_mode, thresholds, std::move(compiled.front())}; + return PreparedAnalyticLevelSet{geometry_mode, thresholds, std::move(compiled.front())}; }); const GeometryMode gmode = prepared.geometry_mode; @@ -1257,28 +1333,25 @@ void System::set_analytic_level_set(const std::vector& opcodes, // native halo topology. In particular, a periodic seam must copy the opposite valid value rather // than evaluate the expression at a fictitious coordinate outside the domain. for (int li = 0; li < staged_level_set_values.local_size(); ++li) - for_each_cell(staged_level_set_values.box(li), - AnalyticLevelSetValueKernel{ - view, P->geom, staged_level_set_values.fab(li).array()}); + for_each_cell( + staged_level_set_values.box(li), + AnalyticLevelSetValueKernel{view, P->geom, staged_level_set_values.fab(li).array()}); fill_boundary(staged_level_set_values, P->dom, P->per_); // Non-periodic physical ghosts have no halo source. They retain the analytic extension needed by // the centered cut-fraction stencil; mixed-periodic corners wrap only their periodic coordinate. for (int li = 0; li < staged_level_set_values.local_size(); ++li) for_each_cell(staged_level_set_values.fab(li).grown_box(), - AnalyticLevelSetPhysicalGhostKernel{ - view, P->geom, P->dom, P->per_, - staged_level_set_values.fab(li).array()}); + AnalyticLevelSetPhysicalGhostKernel{view, P->geom, P->dom, P->per_, + staged_level_set_values.fab(li).array()}); Real local_non_finite = Real(0); for (int li = 0; li < staged_level_set_values.local_size(); ++li) { const Box2D sampled = staged_level_set_values.fab(li).grown_box(); local_non_finite = std::max( local_non_finite, - for_each_cell_reduce_max( - sampled, - AnalyticLevelSetFiniteIndicator{ - staged_level_set_values.fab(li).const_array()})); + for_each_cell_reduce_max(sampled, AnalyticLevelSetFiniteIndicator{ + staged_level_set_values.fab(li).const_array()})); } if (all_reduce_max(static_cast(local_non_finite)) != 0.0) throw std::domain_error( @@ -1290,11 +1363,10 @@ void System::set_analytic_level_set(const std::vector& opcodes, const ConstArray4 phi = staged_level_set_values.fab(li).const_array(); for_each_cell(staged_mask.fab(li).grown_box(), AnalyticLevelSetMaskKernel{phi, staged_mask.fab(li).array()}); - for_each_cell( - staged_inverse_volume_fraction.box(li), - AnalyticInverseVolumeFractionKernel{ - phi, staged_inverse_volume_fraction.fab(li).array(), dx, dy, - staged_thresholds.kappa_min, staged_thresholds.cut_theta_min}); + for_each_cell(staged_inverse_volume_fraction.box(li), + AnalyticInverseVolumeFractionKernel{ + phi, staged_inverse_volume_fraction.fab(li).array(), dx, dy, + staged_thresholds.kappa_min, staged_thresholds.cut_theta_min}); } if (gmode != GeometryMode::None && sum(staged_mask, 0) <= Real(0)) throw std::domain_error( @@ -1313,8 +1385,8 @@ void System::set_analytic_level_set(const std::vector& opcodes, void System::set_disc_domain(double cx, double cy, double R, const std::string& mode, double kappa_min, double face_open_eps, double cut_theta_min) { - const std::vector opcodes{ - "x", "constant", "sub", "y", "constant", "sub", "hypot", "constant", "sub"}; + const std::vector opcodes{"x", "constant", "sub", "y", "constant", + "sub", "hypot", "constant", "sub"}; const std::vector literals{0.0, cx, 0.0, 0.0, cy, 0.0, 0.0, R, 0.0}; (void)analytic::collectively_prepare_analytic_request( "System::set_disc_domain", {{"mode", mode}}, @@ -1358,17 +1430,41 @@ void System::set_geometry_mode(const std::string& mode) { throw std::runtime_error( "System::set_geometry_mode: embedded-boundary transport has no signed-mask or cut-cell " "shared-interface provider"); - for (const auto& block : P->sp) - if (!supports_geometry_mode(block.supported_geometry_modes, gmode)) - throw std::runtime_error( - "System::set_geometry_mode: block '" + block.name + - "' has no numerical provider for embedded-boundary mode '" + mode + "'"); - if (gmode != GeometryMode::None) - for (const auto& [name, plan] : P->boundary_plans_) - if (plan->has_component_boundaries()) + for (const auto& block : P->sp) { + const SpatialProviderGeometry geometry = gmode == GeometryMode::None + ? block.base_spatial_geometry + : spatial_provider_geometry(gmode); + if (!block.spatial_provider.supports( + {kNativeDimension, geometry, SpatialProviderOperation::Residual})) + throw std::runtime_error("System::set_geometry_mode: block '" + block.name + + "' has no numerical provider for embedded-boundary mode '" + mode + + "'"); + } + if (gmode != GeometryMode::None) { + const SpatialProviderGeometry geometry = spatial_provider_geometry(gmode); + for (const auto& [name, plan] : P->boundary_plans_) { + const auto block = + std::find_if(P->sp.begin(), P->sp.end(), + [&name](const Impl::Species& candidate) { return candidate.name == name; }); + // The exact provider is checked by install_block and again by mark_bound when the plan was + // published before its block. + if (block == P->sp.end()) + continue; + const auto supports = [&](SpatialProviderOperation operation) { + return block->spatial_provider.supports({kNativeDimension, geometry, operation}); + }; + if (plan->requires_characteristic_no_inflow() && + !supports(SpatialProviderOperation::CharacteristicNoInflow)) + throw std::runtime_error( + "System::set_geometry_mode: block '" + name + + "' has characteristic no-inflow without an embedded-boundary metric provider"); + if (plan->has_component_boundaries() && + !supports(SpatialProviderOperation::BoundaryLinearization)) throw std::runtime_error( "System::set_geometry_mode: block '" + name + "' has a native boundary component without an embedded-boundary metric provider"); + } + } P->geometry_mode_ = gmode; } @@ -1722,36 +1818,34 @@ void System::add_coupled_source(const CoupledSourceProgram& prog_desc, double fr } P->couplings.push_back([ins, outs, kconsts, n_in, n_const, n_terms]( Real dt, const std::vector& states) { - // MPI-safe: iteration over the LOCAL fabs of the first input block (or output if no - // input). local_size()==0 on a rank without a box -> empty loop, no-op (no hard-coded fab(0)). - const int sref = n_in > 0 ? ins[0].sidx : outs[0].sidx; - MultiFab& Uref = *states[static_cast(sref)]; - for (int li = 0; li < Uref.local_size(); ++li) { - CoupledSourceKernel kern; - kern.dt = dt; - kern.n_in = n_in; - kern.n_const = n_const; - kern.n_terms = n_terms; - for (int c = 0; c < n_in; ++c) { - kern.in[c] = - states[static_cast(ins[static_cast(c)].sidx)] - ->fab(li) - .array(); - kern.in_comp[c] = ins[static_cast(c)].comp; - } - for (int c = 0; c < n_const; ++c) - kern.consts[c] = kconsts[static_cast(c)]; - for (int t = 0; t < n_terms; ++t) { - kern.out[t] = - states[static_cast(outs[static_cast(t)].sidx)] - ->fab(li) - .array(); - kern.out_comp[t] = outs[static_cast(t)].comp; - kern.prog[t] = outs[static_cast(t)].prog; - } - for_each_cell(Uref.box(li), kern); // NAMED functor, device-clean additive forward-Euler + // MPI-safe: iteration over the LOCAL fabs of the first input block (or output if no + // input). local_size()==0 on a rank without a box -> empty loop, no-op (no hard-coded fab(0)). + const int sref = n_in > 0 ? ins[0].sidx : outs[0].sidx; + MultiFab& Uref = *states[static_cast(sref)]; + for (int li = 0; li < Uref.local_size(); ++li) { + CoupledSourceKernel kern; + kern.dt = dt; + kern.n_in = n_in; + kern.n_const = n_const; + kern.n_terms = n_terms; + for (int c = 0; c < n_in; ++c) { + kern.in[c] = states[static_cast(ins[static_cast(c)].sidx)] + ->fab(li) + .array(); + kern.in_comp[c] = ins[static_cast(c)].comp; } - }); + for (int c = 0; c < n_const; ++c) + kern.consts[c] = kconsts[static_cast(c)]; + for (int t = 0; t < n_terms; ++t) { + kern.out[t] = states[static_cast(outs[static_cast(t)].sidx)] + ->fab(li) + .array(); + kern.out_comp[t] = outs[static_cast(t)].comp; + kern.prog[t] = outs[static_cast(t)].prog; + } + for_each_cell(Uref.box(li), kern); // NAMED functor, device-clean additive forward-Euler + } + }); // Inspect metadata (ADC-595): a raw add_coupled_source declares NO conservation contract, so it // registers an "unchecked" view (empty ConservationContract) carrying the label and the frequency // bound. add_coupling_operator overwrites this behavior by pushing the DECLARED contract instead. diff --git a/src/runtime/system/system_io.cpp b/src/runtime/system/system_io.cpp index 784d58471..ff2280e73 100644 --- a/src/runtime/system/system_io.cpp +++ b/src/runtime/system/system_io.cpp @@ -331,7 +331,9 @@ int System::rebuild_history_slots(const std::string& name, const std::vector newer; --j) { p_->program_.last_dt_ = dts[static_cast(j + 1)]; - p_->program_.step_(static_cast(dts[static_cast(j + 1)])); + p_->program_.run_balance_replay("System::rebuild_history_slots", [&] { + p_->program_.step_(static_cast(dts[static_cast(j + 1)])); + }); reconstructed[static_cast(j)] = p_->sp[owner].U; // deep copy the fresh owner state } diff --git a/src/runtime/system/system_layout_transfer.cpp b/src/runtime/system/system_layout_transfer.cpp index f6385c649..c6155e3b5 100644 --- a/src/runtime/system/system_layout_transfer.cpp +++ b/src/runtime/system/system_layout_transfer.cpp @@ -79,8 +79,8 @@ PopsExecutionContextV1 execution_view(const SystemLayoutTransferExecution& execu execution.communicator_datatype_identity.c_str()}; } -void validate_world_execution(const SystemLayoutTransferExecution& execution, - const CommunicatorView& world) { +CommunicatorView resolve_execution_communicator(const SystemLayoutTransferExecution& execution, + const CommunicatorView& field_rank_space) { const PopsExecutionContextV1 view = execution_view(execution); component::validate_execution_context(view); if (execution.memory_space != POPS_MEMORY_SPACE_HOST_V1 && @@ -88,43 +88,54 @@ void validate_world_execution(const SystemLayoutTransferExecution& execution, throw std::invalid_argument( "prepared System layout transfer requires host-addressable native field storage"); if (execution.communicator_identity == "serial") { - if (world.active()) + if (field_rank_space.active()) throw std::invalid_argument( "serial layout-transfer execution requires native MPI to be inactive"); - return; + return CommunicatorView{}; } - if (execution.communicator_identity != "MPI_COMM_WORLD") + if (execution.communicator_identity == POPS_EXECUTION_NONCOLLECTIVE_IDENTITY_V1) throw std::invalid_argument( - "prepared System layout transfer supports serial or exact MPI_COMM_WORLD execution"); + "prepared System layout transfer requires collective execution authority"); #ifdef POPS_HAS_MPI - if (!world.active()) + if (!field_rank_space.active()) throw std::invalid_argument( - "MPI_COMM_WORLD layout-transfer execution requires initialized native MPI"); - if (execution.communicator_f_handle != static_cast(MPI_Comm_c2f(MPI_COMM_WORLD)) || - execution.communicator_datatype_f_handle != - static_cast(MPI_Type_c2f(MPI_DOUBLE)) || + "collective layout-transfer execution requires initialized native MPI"); + const MPI_Comm communicator = + MPI_Comm_f2c(static_cast(execution.communicator_f_handle)); + if (communicator == MPI_COMM_NULL || + MPI_Type_f2c(static_cast(execution.communicator_datatype_f_handle)) != MPI_DOUBLE || execution.communicator_datatype_identity != "MPI_DOUBLE") throw std::invalid_argument( - "layout-transfer execution handles are not exact MPI_COMM_WORLD/MPI_DOUBLE authorities"); + "layout-transfer execution handles do not identify a live communicator/MPI_DOUBLE " + "authority"); + int relation = MPI_UNEQUAL; + ::pops::detail::require_mpi_success( + MPI_Comm_compare(communicator, field_rank_space.native_handle(), &relation), + "MPI_Comm_compare(layout-transfer field rank space)"); + if (relation != MPI_IDENT && relation != MPI_CONGRUENT) + throw std::invalid_argument( + "layout-transfer execution communicator must preserve the field rank space"); + return CommunicatorView{communicator}; #else - (void)world; + (void)field_rank_space; throw std::invalid_argument( - "MPI_COMM_WORLD layout-transfer execution requires an MPI-enabled PoPS build"); + "collective layout-transfer execution requires an MPI-enabled PoPS build"); #endif } template -void collectively_validate(const CommunicatorView& world, const char* where, Function&& function) { +void collectively_validate(const CommunicatorView& communicator, const char* where, + Function&& function) { std::exception_ptr failure; try { std::forward(function)(); } catch (...) { failure = std::current_exception(); } - const long failures = all_reduce_sum(failure ? 1L : 0L, world); + const long failures = all_reduce_sum(failure ? 1L : 0L, communicator); if (failures == 0) return; - if (world.size() == 1 && failure) + if (communicator.size() == 1 && failure) std::rethrow_exception(failure); throw std::runtime_error(std::string(where) + " failed on at least one MPI rank"); } @@ -172,14 +183,14 @@ std::uint64_t checked_elements(const Box2D& box, int components) { return static_cast(cells) * static_cast(components); } -std::uint64_t collective_elements(std::uint64_t local, const CommunicatorView& world) { - const auto ranks = static_cast(world.size()); +std::uint64_t collective_elements(std::uint64_t local, const CommunicatorView& communicator) { + const auto ranks = static_cast(communicator.size()); const std::uint64_t per_rank_limit = static_cast(std::numeric_limits::max()) / ranks; - const long invalid = all_reduce_max(local > per_rank_limit ? 1L : 0L, world); + const long invalid = all_reduce_max(local > per_rank_limit ? 1L : 0L, communicator); if (invalid != 0) throw std::overflow_error("layout-transfer global element count exceeds MPI long capacity"); - const long global = all_reduce_sum(static_cast(local), world); + const long global = all_reduce_sum(static_cast(local), communicator); return static_cast(global); } @@ -196,7 +207,7 @@ struct PreparedSystemLayoutTransfer::Impl { SystemLayoutTransferSpec spec; SystemLayoutTransferExecution execution; PopsExecutionContextV1 execution_abi{}; - CommunicatorView world; + CommunicatorView communicator; int source_block_index = -1; int target_block_index = -1; int components = 0; @@ -211,7 +222,8 @@ struct PreparedSystemLayoutTransfer::Impl { Impl(System& source_system, System& target_system, std::shared_ptr loaded, SystemLayoutTransferSpec transfer_spec, - SystemLayoutTransferExecution transfer_execution) + SystemLayoutTransferExecution transfer_execution, + const CommunicatorView& transfer_communicator) : source_owner(&source_system), target_owner(&target_system), source(source_system.p_.get()), @@ -220,7 +232,7 @@ struct PreparedSystemLayoutTransfer::Impl { spec(std::move(transfer_spec)), execution(std::move(transfer_execution)), execution_abi(execution_view(execution)), - world(world_communicator_view()) { + communicator(transfer_communicator) { validate_static_contract(); source_block_index = source->blocks_.index(spec.source_block); target_block_index = target->blocks_.index(spec.target_block); @@ -299,7 +311,6 @@ struct PreparedSystemLayoutTransfer::Impl { if (source_owner->lifecycle_state() == "assembling" || target_owner->lifecycle_state() == "assembling") throw std::invalid_argument("prepared System transfer requires bound native Systems"); - validate_world_execution(execution, world); const PopsComponentApiV1& api = component_handle->api(); if (api.component_id == nullptr || api.manifest_identity == nullptr || api.semantic_identity == nullptr || api.catalog_sha256 == nullptr || @@ -381,23 +392,28 @@ PreparedSystemLayoutTransfer::~PreparedSystemLayoutTransfer() = default; std::shared_ptr PreparedSystemLayoutTransfer::prepare( System& source, System& target, std::shared_ptr component, SystemLayoutTransferSpec spec, SystemLayoutTransferExecution execution) { - const CommunicatorView world = world_communicator_view(); + const CommunicatorView field_rank_space = world_communicator_view(); + CommunicatorView communicator; + collectively_validate(field_rank_space, "layout-transfer execution communicator", [&] { + communicator = resolve_execution_communicator(execution, field_rank_space); + }); std::unique_ptr pending; - collectively_validate(world, "prepared System layout-transfer allocation", [&] { + collectively_validate(communicator, "prepared System layout-transfer allocation", [&] { pending = std::make_unique(source, target, std::move(component), std::move(spec), - std::move(execution)); + std::move(execution), communicator); }); const std::string payload = pending->consensus_payload(); if (!all_ranks_agree_exact_ordered_byte_pairs({{"prepared-system-layout-transfer-v1", payload}}, - world)) + communicator)) throw std::invalid_argument( "prepared System layout-transfer contract differs between MPI ranks"); - collectively_validate(world, "native Transfer provider preparation", + collectively_validate(communicator, "native Transfer provider preparation", [&] { pending->prepare_provider(); }); // Warm the persistent copy schedule and MPI buffers before the first run step. This copy is // observationally inert: the carrier is private until capture() authenticates an attempt. - collectively_validate(world, "prepared System layout-transfer warmup", - [&] { parallel_copy(pending->source_snapshot, pending->source_state()); }); + collectively_validate(communicator, "prepared System layout-transfer warmup", [&] { + parallel_copy(pending->source_snapshot, pending->source_state(), communicator); + }); return std::shared_ptr( new PreparedSystemLayoutTransfer(std::move(pending))); } @@ -407,7 +423,7 @@ const SystemLayoutTransferSpec& PreparedSystemLayoutTransfer::spec() const noexc } void PreparedSystemLayoutTransfer::begin_transaction(std::uint64_t generation) { - collectively_validate(p_->world, "layout-transfer begin", [&] { + collectively_validate(p_->communicator, "layout-transfer begin", [&] { if (p_->active) throw std::logic_error("layout-transfer transaction is already active"); if (generation == 0 || generation <= p_->last_generation) @@ -425,7 +441,7 @@ void PreparedSystemLayoutTransfer::begin_transaction(std::uint64_t generation) { } void PreparedSystemLayoutTransfer::capture(std::uint64_t generation, std::uint64_t attempt) { - collectively_validate(p_->world, "layout-transfer capture", [&] { + collectively_validate(p_->communicator, "layout-transfer capture", [&] { p_->validate_active(generation, attempt, "layout-transfer capture"); if (p_->applied) throw std::logic_error( @@ -433,14 +449,15 @@ void PreparedSystemLayoutTransfer::capture(std::uint64_t generation, std::uint64 if (p_->captured_attempt != 0 && p_->captured_attempt != attempt) throw std::logic_error("layout-transfer source was already captured for another attempt"); }); - collectively_validate(p_->world, "layout-transfer source capture", - [&] { parallel_copy(p_->source_snapshot, p_->source_state()); }); + collectively_validate(p_->communicator, "layout-transfer source capture", [&] { + parallel_copy(p_->source_snapshot, p_->source_state(), p_->communicator); + }); p_->captured_attempt = attempt; } SystemLayoutTransferReceipt PreparedSystemLayoutTransfer::apply(std::uint64_t generation, std::uint64_t attempt) { - collectively_validate(p_->world, "layout-transfer apply preflight", [&] { + collectively_validate(p_->communicator, "layout-transfer apply preflight", [&] { p_->validate_active(generation, attempt, "layout-transfer apply"); if (p_->captured_attempt != attempt) throw std::logic_error("layout-transfer apply requires the exact captured attempt"); @@ -450,7 +467,7 @@ SystemLayoutTransferReceipt PreparedSystemLayoutTransfer::apply(std::uint64_t ge std::uint64_t local_source_elements = 0; std::uint64_t local_target_elements = 0; - collectively_validate(p_->world, "native Transfer apply", [&] { + collectively_validate(p_->communicator, "native Transfer apply", [&] { MultiFab& destination = p_->target_state(); try { for (int local = 0; local < p_->source_snapshot.local_size(); ++local) { @@ -549,13 +566,13 @@ SystemLayoutTransferReceipt PreparedSystemLayoutTransfer::apply(std::uint64_t ge receipt.operation = p_->spec.operation; receipt.generation = generation; receipt.attempt = attempt; - receipt.source_element_count = collective_elements(local_source_elements, p_->world); - receipt.destination_element_count = collective_elements(local_target_elements, p_->world); + receipt.source_element_count = collective_elements(local_source_elements, p_->communicator); + receipt.destination_element_count = collective_elements(local_target_elements, p_->communicator); return receipt; } void PreparedSystemLayoutTransfer::reject_attempt(std::uint64_t generation, std::uint64_t attempt) { - collectively_validate(p_->world, "layout-transfer rejected-attempt reset", [&] { + collectively_validate(p_->communicator, "layout-transfer rejected-attempt reset", [&] { p_->validate_active(generation, attempt, "layout-transfer rejected-attempt reset"); if (p_->captured_attempt != attempt) throw std::logic_error( diff --git a/src/runtime/system/system_polar.cpp b/src/runtime/system/system_polar.cpp index 1009d8a20..f0c766d3d 100644 --- a/src/runtime/system/system_polar.cpp +++ b/src/runtime/system/system_polar.cpp @@ -7,7 +7,8 @@ namespace pops::detail { -BuiltBlock build_block_polar(const ModelSpec& model, const std::string& limiter, +BuiltBlock build_block_polar(const ModelSpec& model, const std::string& name, + const std::string& state_identity, const std::string& limiter, const std::string& riemann, const PolarGridContext& pctx, bool recon_prim, Real positivity_floor, const MultiFab* aux) { BuiltBlock out; @@ -21,11 +22,14 @@ BuiltBlock build_block_polar(const ModelSpec& model, const std::string& limiter, // exactly like the Cartesian path. Without it a polar model with n_aux>3 read past the aux fab // (load_aux> on a 3-wide channel) -- a silent out-of-bounds (#51-class). out.aux_width = aux_comps(); - // wall_radial = true: solid wall at both radial edges (no-penetration) -> zero radial flux at - // r_min / r_max -> mass Sum n r dr dtheta conserved TO MACHINE precision (diocotron ring bounded by - // two conducting walls). This is the BC that makes the coupled step conservative. - out.clo = make_block_polar(m, limiter, riemann, pctx, recon_prim, /*wall_radial=*/true, - positivity_floor); + PolarGridContext prepared = pctx; + if (!prepared.boundary_plan) { + out.synthesized_boundary_plan = prepare_builtin_boundary_plan( + name, state_identity, limiter_n_ghost(limiter), out.cons_vs, prepared.bc, + /*close_radial_flux=*/true); + prepared.boundary_plan = out.synthesized_boundary_plan; + } + out.clo = make_block_polar(m, limiter, riemann, prepared, recon_prim, positivity_floor); // POLAR StabilityPolicy (audit wave 3): same policy as the Cartesian -- stability lambda* (trait) // otherwise max_wave_speed; source/admissible-step bounds if declared, EMPTY closures otherwise // (historical step policy, bit-identical). @@ -36,6 +40,7 @@ BuiltBlock build_block_polar(const ModelSpec& model, const std::string& limiter, auto conv = make_cell_convert(m); out.prim_to_cons = std::move(conv.first); out.cons_to_prim = std::move(conv.second); + out.batch_cons_to_prim = make_uniform_recovery_consumer(m); }); return out; } diff --git a/src/runtime/system/system_program.cpp b/src/runtime/system/system_program.cpp index 648881949..59fa72e4e 100644 --- a/src/runtime/system/system_program.cpp +++ b/src/runtime/system/system_program.cpp @@ -227,16 +227,12 @@ void System::block_boundary_residual_into_at( if (!block_has_boundary_linearization(b)) throw std::runtime_error("System block has no executable boundary residual/JVP pair"); auto& block = p_->sp[static_cast(b)]; - if (block.boundary_session) { - if (!block.boundary_residual_at_point_prepared) - throw std::runtime_error("System block lacks its prepared boundary residual closure"); - block.boundary_residual_at_point_prepared(point, U, C, *block.boundary_session); - return; - } - auto& closure = block.boundary_residual_at_point; - if (!closure) - throw std::runtime_error("System block lacks its boundary residual closure"); - closure(point, U, C); + if (!block.boundary_session) + throw std::runtime_error( + "System boundary residual requires its persistent prepared boundary session"); + if (!block.boundary_residual_at_point_prepared) + throw std::runtime_error("System block lacks its prepared boundary residual closure"); + block.boundary_residual_at_point_prepared(point, U, C, *block.boundary_session); } void System::block_boundary_residual_into_at( @@ -262,16 +258,12 @@ void System::block_boundary_jvp_into_at(const runtime::multiblock::BoundaryEvalu if (!block_has_boundary_linearization(b)) throw std::runtime_error("System block has no executable boundary residual/JVP pair"); auto& block = p_->sp[static_cast(b)]; - if (block.boundary_session) { - if (!block.boundary_jvp_at_point_prepared) - throw std::runtime_error("System block lacks its prepared boundary JVP closure"); - block.boundary_jvp_at_point_prepared(point, U, V, J, *block.boundary_session); - return; - } - auto& closure = block.boundary_jvp_at_point; - if (!closure) - throw std::runtime_error("System block lacks its boundary JVP closure"); - closure(point, U, V, J); + if (!block.boundary_session) + throw std::runtime_error( + "System boundary JVP requires its persistent prepared boundary session"); + if (!block.boundary_jvp_at_point_prepared) + throw std::runtime_error("System block lacks its prepared boundary JVP closure"); + block.boundary_jvp_at_point_prepared(point, U, V, J, *block.boundary_session); } void System::block_boundary_jvp_into_at(const runtime::multiblock::BoundaryEvaluationPoint& point, int b, MultiFab& U, const MultiFab& V, MultiFab& J, @@ -374,10 +366,10 @@ void System::set_program_block_map(const std::vector& prog_to_sys) { for (std::size_t program = 0; program < prog_to_sys.size(); ++program) { for (std::size_t previous = 0; previous < program; ++previous) { if (prog_to_sys[program] == prog_to_sys[previous]) - throw std::invalid_argument( - "System::set_program_block_map: Program blocks " + std::to_string(previous) + - " and " + std::to_string(program) + " both map to System block " + - std::to_string(prog_to_sys[program])); + throw std::invalid_argument("System::set_program_block_map: Program blocks " + + std::to_string(previous) + " and " + std::to_string(program) + + " both map to System block " + + std::to_string(prog_to_sys[program])); } } p_->program_.block_map_ = prog_to_sys; @@ -415,12 +407,43 @@ void System::block_project(int b, MultiFab& u) { void System::record_program_diagnostic(const std::string& name, Real value) { p_->program_.record_diagnostic(name, value); } +void System::record_program_balance_term(const std::string& route, const std::string& term, + Real value) { + p_->program_.record_balance_term(route, term, value, "System"); +} +bool System::program_balance_consumer_is_due(const std::string& contract, const std::string& route, + int every_n) const { + return p_->program_.balance_consumer_is_due(contract, route, every_n, "System"); +} Real System::program_diagnostic(const std::string& name) const { return p_->program_.diagnostic(name, "System"); } std::map System::program_diagnostics() const { return p_->program_.diagnostics(); } +std::map System::accepted_balance_terms(const std::string& route) const { + if (!p_->external_step_transaction_ || p_->external_step_transaction_committed_) + throw std::runtime_error( + "System::_accepted_balance_terms requires an active uncommitted external step transaction"); + return p_->program_.accepted_balance_terms(route, "System"); +} +std::map System::selected_accepted_balance_terms( + const std::string& route, const std::string& block, int component, + const std::vector& levels, const std::vector& automatic_terms) const { + if (!p_->external_step_transaction_ || p_->external_step_transaction_committed_) + throw std::runtime_error( + "System::_selected_accepted_balance_terms requires an active uncommitted external step " + "transaction"); + const int runtime_block = p_->index(block); + const auto& state = p_->find(block); + if (component < 0 || component >= state.ncomp) + throw std::out_of_range("System::_selected_accepted_balance_terms component is out of range"); + if (levels != std::vector{0}) + throw std::invalid_argument( + "System::_selected_accepted_balance_terms requires exactly uniform level 0"); + return p_->program_.selected_accepted_balance_terms(route, runtime_block, component, levels, + automatic_terms, "System"); +} void System::begin_step_projection_report() { p_->program_.begin_step_projection_report(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c28cb399d..87b2e3122 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -435,6 +435,17 @@ set(POPS_CPP_STANDARD_TESTS test_fab2d test_box_array test_multifab + test_nd_boundary_schedule + test_nd_cluster + test_nd_distribution + test_nd_execution + test_nd_flux_ledger + test_nd_hierarchy_plan + test_nd_layout + test_nd_tag_mask + test_nd_topology + test_nd_transfer + test_nd_translation_schedule test_multiblock_interface_scheduler test_sync_residence test_reduce @@ -448,7 +459,10 @@ set(POPS_CPP_STANDARD_TESTS test_copy_schedule_cache test_physical_bc test_prepared_boundary_plan + test_prepared_stream_executor test_geometry + test_nd_metric_provider + test_nd_finite_volume test_refinement test_ref_ratio test_amr_hierarchy @@ -506,6 +520,7 @@ set(POPS_CPP_STANDARD_TESTS test_scheme_dispatch test_model_registry test_spatial_discretisation + test_spatial_provider_matrix test_primitive_recon test_system_abstraction test_system_coupler @@ -556,6 +571,9 @@ set(POPS_CPP_STANDARD_TESTS test_roe_flux test_riemann_capabilities test_newton_robustness + test_variable_recovery_chain + test_prepared_cartesian_nd + test_prepared_numerics_gate test_elliptic_interface test_field_nullspace test_field_context @@ -692,6 +710,15 @@ pops_add_gtest_suite( pops_test_source(_src test_program_reflux_ledger) pops_add_gtest_suite(NAME test_program_reflux_ledger SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) +pops_test_source(_src test_temporal_partition_restart) +pops_add_gtest_suite(NAME test_temporal_partition_restart SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) + +pops_test_source(_src test_cell_temporal_partition_executor) +pops_add_gtest_suite(NAME test_cell_temporal_partition_executor SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) + +pops_test_source(_src test_cell_temporal_program_route) +pops_add_gtest_suite(NAME test_cell_temporal_program_route SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) + pops_test_source(_src test_amr_transfer_properties) pops_add_gtest_suite(NAME test_amr_transfer_properties SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) @@ -769,6 +796,10 @@ add_dependencies(test_external_brick_isolation pops_iso_fixture_a pops_iso_fixtu if(POPS_HAS_MPI) pops_add_mpi_standalone_suite(test_mpi_external_lifecycle RANKS 1 2) + pops_add_mpi_standalone_suite(test_mpi_nd_translation_completion_failstop RANKS 1) + set_tests_properties(test_mpi_nd_translation_completion_failstop_np1 PROPERTIES + PASS_REGULAR_EXPRESSION "POPS_ND_COMPLETION_FAILSTOP_OBSERVED" + TIMEOUT 20) set(POPS_MPI_RANKS_test_mpi_polar_schur 1 2 4) set(POPS_MPI_RANKS_test_mpi_mbox_parity 1 2 4) @@ -778,9 +809,12 @@ if(POPS_HAS_MPI) set(POPS_MPI_RANKS_test_mpi_amr_twoblock_parity 1 2 4) set(POPS_MPI_RANK_PARITY_test_mpi_amr_distributed_coarse 1 2 4) set(POPS_MPI_RANKS_test_mpi_amr_program_reflux 2 4) + set(POPS_MPI_RANKS_test_mpi_amr_rebalance_migration 2 4) set(POPS_MPI_RANKS_test_mpi_composite_fac 1 2 4) set(POPS_MPI_RANKS_test_amr_regrid_mpi_parity 1 2 4) set(POPS_MPI_RANKS_test_mpi_amr_dynamic_active_depth 1 2 4) + set(POPS_MPI_RANKS_test_mpi_cell_temporal_program_refusal 2) + set(POPS_MPI_RANKS_test_mpi_amr_prepared_boundary_cf 1 2 4) set(POPS_MPI_RANKS_test_mpi_system_solve_fields 1 2 4) set(POPS_MPI_RANKS_test_mpi_system_fft 1 2 4) set(POPS_MPI_RANKS_test_mpi_system_analytic_level_set 2) @@ -794,6 +828,7 @@ if(POPS_HAS_MPI) set(POPS_MPI_RANKS_test_mpi_load_balance_authority 2 4) set(POPS_MPI_RANKS_test_mpi_array_reduce 4) set(POPS_MPI_RANKS_test_mpi_multiblock_interface_scheduler 2) + set(POPS_MPI_RANKS_test_mpi_nd_translation_exchange 1 2 4) set(POPS_MPI_RANKS_test_mpi_coupler_inject 4) set(POPS_MPI_RANKS_test_mpi_fft_distributed 4) set(POPS_MPI_RANKS_test_mpi_fillboundary 4) @@ -820,9 +855,12 @@ if(POPS_HAS_MPI) test_mpi_amr_twoblock_parity test_mpi_amr_distributed_coarse test_mpi_amr_program_reflux + test_mpi_amr_rebalance_migration test_mpi_composite_fac test_amr_regrid_mpi_parity test_mpi_amr_dynamic_active_depth + test_mpi_cell_temporal_program_refusal + test_mpi_amr_prepared_boundary_cf test_mpi_system_solve_fields test_mpi_system_fft test_mpi_system_analytic_level_set @@ -836,6 +874,7 @@ if(POPS_HAS_MPI) test_mpi_load_balance_authority test_mpi_array_reduce test_mpi_multiblock_interface_scheduler + test_mpi_nd_translation_exchange test_mpi_coupler_inject test_mpi_fft_distributed test_mpi_fillboundary diff --git a/tests/cpp/build_durations.json b/tests/cpp/build_durations.json index 63f1b081b..17d3791eb 100644 --- a/tests/cpp/build_durations.json +++ b/tests/cpp/build_durations.json @@ -5,8 +5,23 @@ "estimated_targets": [ "test_amr_program_diffusion", "test_amr_program_positivity_floor", + "test_cell_temporal_partition_executor", + "test_cell_temporal_program_route", "test_flux_failure_loader_transaction", - "test_interface_flux_fragment_ledger" + "test_interface_flux_fragment_ledger", + "test_nd_cluster", + "test_nd_finite_volume", + "test_nd_flux_ledger", + "test_nd_hierarchy_plan", + "test_nd_metric_provider", + "test_nd_tag_mask", + "test_nd_transfer", + "test_prepared_cartesian_nd", + "test_prepared_numerics_gate", + "test_prepared_stream_executor", + "test_spatial_provider_matrix", + "test_temporal_partition_restart", + "test_variable_recovery_chain" ], "estimate_policy": "new unmeasured targets use a conservative analogous-target estimate, falling back to the catalog median until the next cold-CI timing refresh", "measured_refresh": { @@ -16,7 +31,7 @@ "refresh_source_run": "30190778708", "source_job": "87152034744", "source_run": "29352485297", - "target_count": 187, + "target_count": 208, "unit_seconds": "modeled shard wall time: measured serial-pool TU or parallel-share floor" }, "test_adaptive_multirate": 2.0, @@ -72,6 +87,8 @@ "test_cache_manager": 2.0, "test_canonical_identity": 2.0, "test_capability_report": 2.0, + "test_cell_temporal_partition_executor": 15.0, + "test_cell_temporal_program_route": 15.0, "test_cf_interface": 2.0, "test_cfl_dt": 2.0, "test_checkpoint_cache": 2.0, @@ -139,6 +156,19 @@ "test_multiblock_interface_scheduler": 296.26, "test_multifab": 2.0, "test_multirate_stride": 2.0, + "test_nd_boundary_schedule": 2.0, + "test_nd_cluster": 2.0, + "test_nd_distribution": 2.0, + "test_nd_execution": 2.0, + "test_nd_finite_volume": 2.0, + "test_nd_flux_ledger": 2.0, + "test_nd_hierarchy_plan": 2.0, + "test_nd_layout": 2.0, + "test_nd_metric_provider": 2.0, + "test_nd_tag_mask": 2.0, + "test_nd_topology": 2.0, + "test_nd_transfer": 2.0, + "test_nd_translation_schedule": 2.0, "test_native_aux_named": 3.92, "test_native_loader_param_overflow": 3.66, "test_newton_robustness": 2.0, @@ -161,6 +191,9 @@ "test_polar_transport_mms": 2.0, "test_positivity_floor": 2.0, "test_prepared_boundary_plan": 2.0, + "test_prepared_cartesian_nd": 2.0, + "test_prepared_numerics_gate": 2.0, + "test_prepared_stream_executor": 2.0, "test_primitive_recon": 2.0, "test_pure_field_algebra_extreme_dot": 2.0, "test_profiler": 2.0, @@ -186,11 +219,13 @@ "test_solve_robust": 2.0, "test_solver_codegen_generated": 2.0, "test_spatial_discretisation": 2.0, + "test_spatial_provider_matrix": 2.0, "test_splitting": 2.0, "test_step_attempt_rejected_amr_link": 2.0, "test_step_attempt_rejected_header_only": 2.0, "test_structured_solver_diagnostics": 2.0, "test_sync_residence": 2.0, + "test_temporal_partition_restart": 15.0, "test_system_abstraction": 2.0, "test_system_coupler": 2.0, "test_system_hardening": 2.0, @@ -199,6 +234,7 @@ "test_two_species_minimal": 2.0, "test_user_time_integrator": 2.0, "test_variable_epsilon": 2.0, + "test_variable_recovery_chain": 2.0, "test_variable_role": 2.0, "test_variable_user_role": 2.0, "test_wave_speed_cache_engagement": 2.0, diff --git a/tests/cpp/integration/amr/test_amr_composite_poisson.cpp b/tests/cpp/integration/amr/test_amr_composite_poisson.cpp index 427ce115d..a095aefb2 100644 --- a/tests/cpp/integration/amr/test_amr_composite_poisson.cpp +++ b/tests/cpp/integration/amr/test_amr_composite_poisson.cpp @@ -46,12 +46,16 @@ struct ScalarCharge { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; +struct NeverTag { + POPS_HD bool operator()(ConstArray4, int, int) const { return false; } +}; + // Pose U(i,j,0) = f_rhs(x_cell, y_cell) sur les cellules valides (selon la geometrie @p g du niveau). static void set_state_f(MultiFab& U, const Geometry& g) { for (int li = 0; li < U.local_size(); ++li) { @@ -112,7 +116,7 @@ TEST(test_amr_composite_poisson, Runs) { levels.push_back({std::move(Uf), nullptr, dxf, dxf}); ScalarCharge model; - AmrCouplerMP cpl(model, g, bac, bc, std::move(levels), {}, + AmrCouplerMP cpl(model, g, bac, bc, Periodicity{true, true}, std::move(levels), {}, /*replicated_coarse=*/true, load_balance); set_state_f(cpl.coarse(), g); set_state_f(cpl.levels()[1].U, gf); @@ -143,7 +147,8 @@ TEST(test_amr_composite_poisson, Runs) { std::vector lv2; lv2.push_back({std::move(Uc2), nullptr, dxc, dxc}); lv2.push_back({std::move(Uf2), nullptr, dxf, dxf}); - AmrCouplerMP ref(model, g, bac, bc, std::move(lv2), {}, true, load_balance); + AmrCouplerMP ref(model, g, bac, bc, Periodicity{true, true}, std::move(lv2), {}, + true, load_balance); set_state_f(ref.coarse(), g); set_state_f(ref.levels()[1].U, gf); ref.compute_aux(); // Option A (composite OFF par defaut) @@ -153,5 +158,21 @@ TEST(test_amr_composite_poisson, Runs) { << " e_optA=" << e_optA; } + // The elliptic descriptor and transport topology are distinct authorities. A legacy direct + // coupler may still solve a non-periodic elliptic problem with periodic transport, but it must + // fail closed before remapping a non-periodic transport hierarchy without a prepared boundary + // plan that proves physical ghost support. + { + MultiFab Uc2(bac, dm, 1, 1); + MultiFab Uf2(baf, dm, 1, 1); + std::vector lv2; + lv2.push_back({std::move(Uc2), nullptr, dxc, dxc}); + lv2.push_back({std::move(Uf2), nullptr, dxf, dxf}); + AmrCouplerMP nonperiodic(model, g, bac, bc, Periodicity{false, false}, + std::move(lv2), {}, true, load_balance); + EXPECT_THROW(nonperiodic.set_hierarchy({fb}), std::logic_error); + EXPECT_THROW(nonperiodic.regrid(NeverTag{}), std::logic_error); + } + comm_finalize(); } diff --git a/tests/cpp/integration/amr/test_amr_diagnostics.cpp b/tests/cpp/integration/amr/test_amr_diagnostics.cpp index 285d7f3fd..2d28d2f0b 100644 --- a/tests/cpp/integration/amr/test_amr_diagnostics.cpp +++ b/tests/cpp/integration/amr/test_amr_diagnostics.cpp @@ -54,12 +54,13 @@ struct DiagnosticWaveModel { static constexpr int n_vars = 1; Real B0 = Real(2); - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } - POPS_HD Real max_wave_speed(const State& state, const Aux& aux, int direction) const { + POPS_HD Real max_wave_speed(const State& state, const auto& providers, int direction) const { const Real state_magnitude = state[0] < Real(0) ? -state[0] : state[0]; - const Real gradient = direction == 0 ? aux.grad_x : aux.grad_y; + const Real gradient = direction == 0 ? providers.template flux_provider<1>() + : providers.template flux_provider<2>(); const Real gradient_magnitude = gradient < Real(0) ? -gradient : gradient; return Real(direction + 1) * state_magnitude + gradient_magnitude; } @@ -193,7 +194,7 @@ TEST(test_amr_diagnostics, DeviceMultiboxNonzeroOriginParity) { const Geometry geometry{domain, Real(0), Real(1), Real(0), Real(1)}; const auto load_balance = test::prepare_test_space_filling_curve_load_balance(); AmrCouplerMP coupler(DiagnosticWaveModel{}, geometry, boxes, BCRec{}, - std::move(levels), {}, + Periodicity{true, true}, std::move(levels), {}, /*replicated_coarse=*/false, load_balance); for (int local = 0; local < coupler.coarse().local_size(); ++local) for_each_cell( @@ -243,8 +244,8 @@ TEST(test_amr_diagnostics, RejectsInvalidSpacingBeforeFieldKernels) { EXPECT_THROW( { AmrCouplerMP invalid(DiagnosticWaveModel{}, zero_width, boxes, - BCRec{}, std::move(levels), {}, false, - load_balance); + BCRec{}, Periodicity{true, true}, + std::move(levels), {}, false, load_balance); }, std::invalid_argument); } @@ -257,7 +258,8 @@ TEST(test_amr_diagnostics, RejectsInvalidSpacingBeforeFieldKernels) { EXPECT_THROW( { AmrCouplerMP invalid(DiagnosticWaveModel{}, geometry, boxes, BCRec{}, - std::move(levels), {}, false, load_balance); + Periodicity{true, true}, std::move(levels), {}, + false, load_balance); }, std::invalid_argument); } diff --git a/tests/cpp/integration/amr/test_amr_history_ring.cpp b/tests/cpp/integration/amr/test_amr_history_ring.cpp index 08ca780e7..3fedc7e16 100644 --- a/tests/cpp/integration/amr/test_amr_history_ring.cpp +++ b/tests/cpp/integration/amr/test_amr_history_ring.cpp @@ -65,8 +65,8 @@ struct QuadraticGrowthModel { using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux&) const { return State{u[0] * u[0]}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } POPS_HD Prim to_primitive(const State& state) const { return state; } @@ -212,7 +212,9 @@ static void install_history_threshold_union(AmrSystem& sim, double threshold) { "test://amr-history/block/b/state/U"}}); } -static AmrRuntime make_two_block(int N, double L, double B0, int manifest_ratio = kAmrRefRatio) { +static AmrRuntime make_two_block( + int N, double L, double B0, int manifest_ratio = kAmrRefRatio, + double maximum_recoverable_a = std::numeric_limits::infinity()) { AmrBuildParams bp; bp.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); bp.mesh.periodicity = Periodicity{true, true}; @@ -226,6 +228,21 @@ static AmrRuntime make_two_block(int N, double L, double B0, int manifest_ratio blob(N, 0.35, 0.5, 0.8, 1.0, 0.10), /*has_density=*/true, 1.4, 1, false, 1)); blocks.back().state_identity = "test://amr-history/block/a/state/U"; + if (std::isfinite(maximum_recoverable_a)) + blocks.back().cons_to_prim = [maximum_recoverable_a](const double* conserved, + double* primitive) { + RecoveryReport report; + if (!std::isfinite(conserved[0]) || conserved[0] > maximum_recoverable_a) { + report.status = RecoveryStatus::kRejected; + report.cause = RecoveryCause::kInadmissibleCandidate; + report.failing_component = 0; + return report; + } + primitive[0] = conserved[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; + }; blocks.push_back(detail::dispatch_amr_block(exb_model(-1.0, B0), "minmod", "rusanov", S, "b", blob(N, 0.65, 0.5, 0.8, 1.0, 0.10), /*has_density=*/true, 1.4, 1, false, 1)); @@ -266,8 +283,8 @@ static void install_native_ab2_program(AmrSystem& system, context.install([&context, after_level = std::move(after_level)](double macro_dt) { context.advance_hierarchy(macro_dt, [&context, &after_level](double level_dt) { context.set_stage_time(0, 1); - { - auto outcome = context.solve_fields(); + if (context.level() == 0) { + auto outcome = context.solve_default_field_on_coarse_level(); (void)outcome.consume(SolveConsumption::kAccept); } MultiFab& state = context.state(0); @@ -305,7 +322,8 @@ static void install_transaction_probe_program(AmrSystem& system, after_hierarchy = std::move(after_hierarchy)](double macro_dt) { context.advance_hierarchy(macro_dt, [&context](double level_dt) { context.set_stage_time(0, 1); - (void)consume_solve_outcome(context.solve_fields()); + if (context.level() == 0) + (void)consume_solve_outcome(context.solve_default_field_on_coarse_level()); std::vector states; std::vector rates; states.reserve(static_cast(context.n_blocks())); @@ -482,6 +500,38 @@ TEST(test_amr_history_ring, SharedProgramServiceInterpolatesEveryActiveAmrLevel) EXPECT_EQ(interpolation_visits[1], 2); } +TEST(test_amr_history_ring, SharedGeneratedFieldWorkspaceReachesRealAmrTerminal) { + constexpr int n = 8; + AmrSystemConfig cfg; + cfg.n = n; + cfg.L = 1.0; + cfg.periodicity = {true, true}; + cfg.regrid_every = 0; + AmrSystem sim(cfg); + AmrRuntime* rt = configure_native_ab2_regrid_system(sim, n, /*temporal_ratio=*/2); + ASSERT_NE(rt, nullptr); + ASSERT_EQ(rt->nlev(), 2); + + runtime::program::AmrProgramContext context(rt, &sim); + context.set_level(0); + MultiFab stage = rt->level_state(0, 0); + stage.set_val(Real(7)); + const std::vector accepted_density = sim.density("a"); + const runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.macro", 0, 0, 0, 0, ::pops::amr::Rational(0, 1), 0.01, 0.0}; + + std::string diagnostic; + try { + (void)context.solve_fields_from_blocks_at(point, 700, "missing.provider", {{0, &stage}}); + FAIL() << "the shared route fabricated a field result instead of reaching AmrRuntime"; + } catch (const std::runtime_error& error) { + diagnostic = error.what(); + } + EXPECT_NE(diagnostic.find("AmrRuntime"), std::string::npos) << diagnostic; + EXPECT_EQ(sim.density("a"), accepted_density) + << "the real AMR terminal must restore accepted state after provider rejection"; +} + TEST(test_amr_history_ring, CommitManySnapshotsSourcesThatAreAlsoTargetsOnAFlatHierarchy) { constexpr int n = 16; AmrSystemConfig cfg; @@ -697,6 +747,44 @@ TEST(test_amr_history_ring, RegridRemapKeepsSlotsConsistent) { EXPECT_EQ(global0.size() - ncoarse, nfine) << "fine_slice_matches_fine_extent"; } +TEST(test_amr_history_ring, RegridRecoveryRefusalRollsBackRemappedHistoryAndLiveHierarchy) { + AmrRuntime rt = make_two_block(32, 1.0, 1.0, kAmrRefRatio, 5.0); + detail::AmrHistoryOps::register_history(rt, 0, "R", 1); + for (int level = 0; level < rt.nlev(); ++level) { + MultiFab inadmissible = rt.level_state(0, level); + inadmissible.set_val(Real(10)); + detail::AmrHistoryOps::store_history(rt, "R", level, inadmissible, Real(0.01)); + } + detail::AmrHistoryOps::rotate_histories(rt); + + const std::vector history_before = detail::AmrHistoryOps::global(rt, "R", 0, false); + std::vector> states_before; + for (int level = 0; level < rt.nlev(); ++level) + states_before.push_back(rt.block_level_state(0, level)); + const std::vector patches_before = rt.patch_boxes(); + const int levels_before = rt.nlev(); + const int regrids_before = rt.regrid_count(); + const std::uint64_t topology_epoch_before = rt.topology_epoch(); + + rt.set_regrid(/*every=*/1, /*grow=*/2, /*margin=*/2); + test::install_prepared_threshold_union(rt, {{0, 0, Real(1.2)}, {1, 0, Real(1.2)}}); + try { + rt.regrid(); + FAIL() << "an inadmissible remapped history slot was published"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find("prepared variable recovery rejected"), + std::string::npos); + } + + EXPECT_EQ(rt.nlev(), levels_before); + EXPECT_EQ(rt.regrid_count(), regrids_before); + EXPECT_EQ(rt.topology_epoch(), topology_epoch_before); + EXPECT_TRUE(same_patches(rt.patch_boxes(), patches_before)); + EXPECT_EQ(detail::AmrHistoryOps::global(rt, "R", 0, false), history_before); + for (int level = 0; level < rt.nlev(); ++level) + EXPECT_EQ(rt.block_level_state(0, level), states_before[static_cast(level)]); +} + TEST(test_amr_history_ring, TransferAuthorityRejectsNonRatioTwoProviderBeforeStep) { try { (void)make_two_block(24, 1.0, 1.0, /*manifest_ratio=*/3); @@ -863,7 +951,7 @@ TEST(test_amr_history_ring, BootstrapRefreshFailureRollsBackAcceptedStateAndCanR EXPECT_EQ(sim.program_accepted_state_revision(), revision_before + 1); } -TEST(test_amr_history_ring, FineFieldReuseWaitsForCoarseOutcomeConsumption) { +TEST(test_amr_history_ring, DefaultFieldSolveIsExplicitlyCoarseOnly) { constexpr int n = 8; AmrSystemConfig cfg; cfg.n = n; @@ -877,13 +965,17 @@ TEST(test_amr_history_ring, FineFieldReuseWaitsForCoarseOutcomeConsumption) { runtime::program::AmrProgramContext context(runtime, &sim); context.configure_primary_clock("clock.macro"); + context.set_level(1); + EXPECT_THROW((void)context.solve_default_field_on_coarse_level(), std::logic_error) + << "coarse auxiliary injection must not masquerade as a requested fine-level solve"; + context.set_level(0); - SolveOutcome coarse = context.solve_fields(); + SolveOutcome coarse = context.solve_default_field_on_coarse_level(); ASSERT_TRUE(coarse.report().solved_value_available()) << coarse.report().reason; context.set_level(1); - EXPECT_THROW((void)context.solve_fields(), std::logic_error) - << "a cached report must not expose the private coarse candidate before Accept"; + EXPECT_THROW((void)context.solve_default_field_on_coarse_level(), std::logic_error) + << "a pending coarse candidate must not create a fine-level solve result"; MultiFab& destination = runtime->phi(); const BoxArray boxes = destination.box_array(); @@ -897,8 +989,8 @@ TEST(test_amr_history_ring, FineFieldReuseWaitsForCoarseOutcomeConsumption) { destination = MultiFab(boxes, mapping, components, ghosts); EXPECT_TRUE(coarse.consume(SolveConsumption::kAccept).solved_value_available()); context.set_level(1); - SolveOutcome fine = context.solve_fields(); - EXPECT_TRUE(fine.consume(SolveConsumption::kAccept).solved_value_available()); + EXPECT_THROW((void)context.solve_default_field_on_coarse_level(), std::logic_error) + << "an accepted coarse publication is still not a fine-level solve"; } TEST(test_amr_history_ring, ExactLayoutSnapshotReusesStorageAndCaptureWorkspace) { @@ -949,8 +1041,8 @@ TEST(test_amr_history_ring, ExactLayoutSnapshotReusesStorageAndCaptureWorkspace) bool measured_coarse_capture = false; context.advance_hierarchy(dt, [&](double level_dt) { context.set_stage_time(0, 1); - { - auto outcome = context.solve_fields(); + if (context.level() == 0) { + auto outcome = context.solve_default_field_on_coarse_level(); (void)outcome.consume(SolveConsumption::kAccept); } MultiFab& state = context.state(0); @@ -1337,8 +1429,8 @@ TEST(test_amr_history_ring, Ab2RegridRebindsLaggedResidualAndFluxOnTheNewTopolog [&context, &initial_patches, &lagged_rate_before_regrid, &lagged_rate_spread_after_regrid, &nonflux_carry_kept_old_fine_overlap, rt, n](double level_dt) { context.set_stage_time(0, 1); - { - auto outcome = context.solve_fields(); + if (context.level() == 0) { + auto outcome = context.solve_default_field_on_coarse_level(); (void)outcome.consume(SolveConsumption::kAccept); } MultiFab& state = context.state(0); @@ -1805,11 +1897,13 @@ TEST(test_amr_history_ring, FineNonFiniteAfterCoarseSuccessRestoresCompleteAccep context.install([&](double macro_dt) { context.advance_hierarchy(macro_dt, [&](double level_dt) { context.set_stage_time(0, 1); - const SolveReport field_report = consume_solve_outcome(context.solve_fields()); - if (!field_report.solved()) - throw std::runtime_error("quadratic rollback fixture field solve did not succeed"); - if (context.level() == 0) + if (context.level() == 0) { + const SolveReport field_report = + consume_solve_outcome(context.solve_default_field_on_coarse_level()); + if (!field_report.solved()) + throw std::runtime_error("quadratic rollback fixture field solve did not succeed"); coarse_solve_succeeded = true; + } MultiFab& live = context.state(0); if (context.level() == 1) diff --git a/tests/cpp/integration/amr/test_amr_layout_guard.cpp b/tests/cpp/integration/amr/test_amr_layout_guard.cpp index 5095ff6be..d568e893f 100644 --- a/tests/cpp/integration/amr/test_amr_layout_guard.cpp +++ b/tests/cpp/integration/amr/test_amr_layout_guard.cpp @@ -43,10 +43,10 @@ struct AdvectX { using Aux = pops::Aux; static constexpr int n_vars = 1; Real a = Real(1); - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{dir == 0 ? a * u[0] : Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return a < 0 ? -a : a; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return a < 0 ? -a : a; } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/integration/amr/test_amr_multiblock_compiled.cpp b/tests/cpp/integration/amr/test_amr_multiblock_compiled.cpp index e3db32af2..0e421f4c6 100644 --- a/tests/cpp/integration/amr/test_amr_multiblock_compiled.cpp +++ b/tests/cpp/integration/amr/test_amr_multiblock_compiled.cpp @@ -242,7 +242,8 @@ static void install_compiled_coupling_program(AmrSystem& system) { context->install([context](double macro_dt) { context->advance_hierarchy(macro_dt, [context](double level_dt) { context->set_stage_time(0, 1); - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); MultiFab& ions = context->state(0); MultiFab& neutrals = context->state(1); diff --git a/tests/cpp/integration/amr/test_amr_multiblock_imex.cpp b/tests/cpp/integration/amr/test_amr_multiblock_imex.cpp index 3c4ffcb2d..b4e929b86 100644 --- a/tests/cpp/integration/amr/test_amr_multiblock_imex.cpp +++ b/tests/cpp/integration/amr/test_amr_multiblock_imex.cpp @@ -121,8 +121,8 @@ struct NonlinearDensityDecay { Real rate = Real(0); - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux&) const { return State{-rate * u[0] * u[0]}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } POPS_HD Prim to_primitive(const State& state) const { return state; } @@ -220,7 +220,8 @@ void install_stiff_pair_program(AmrSystem& system, StiffModel stiff_model, bool context->install([context, stiff_model, implicit_stiff, stiff_substeps](double macro_dt) { context->advance_hierarchy(macro_dt, [context, stiff_model, implicit_stiff, stiff_substeps](double level_dt) { - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); MultiFab& stiff_live = context->state(0); MultiFab& neutral_live = context->state(1); MultiFab& stiff_candidate = context->scratch_state(1000, 0, stiff_live); diff --git a/tests/cpp/integration/amr/test_amr_multiblock_substeps.cpp b/tests/cpp/integration/amr/test_amr_multiblock_substeps.cpp index 58b932c72..7282fd871 100644 --- a/tests/cpp/integration/amr/test_amr_multiblock_substeps.cpp +++ b/tests/cpp/integration/amr/test_amr_multiblock_substeps.cpp @@ -122,7 +122,8 @@ static void install_multirate_forward_euler_program(AmrSystem& system, std::vect context->install( [context, substeps = std::move(substeps), strides = std::move(strides)](double macro_dt) { context->advance_hierarchy(macro_dt, [context, &substeps, &strides](double level_dt) { - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); for (int block = 0; block < context->n_blocks(); ++block) { const auto index = static_cast(block); if ((context->macro_step() + 1) % strides[index] != 0) @@ -177,8 +178,8 @@ struct TemporalContractModel { static constexpr int n_vars = 1; int mode = 0; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State& u, const Aux&, int) const { + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State& u, const auto&, int) const { return mode == 1 ? (u[0] < Real(0) ? -u[0] : u[0]) : Real(0); } POPS_HD State source(const State& u, const Aux&) const { return State{u[0]}; } @@ -291,12 +292,14 @@ TEST(test_amr_multiblock_substeps, Runs) { context->advance_hierarchy(macro_dt, [context, per_stage](double) { if (!per_stage) { context->set_stage_time(0, 1); - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); return; } for (int stage = 0; stage < 4; ++stage) { context->set_stage_time(stage, 4); - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); } }); }); diff --git a/tests/cpp/integration/amr/test_amr_named_field.cpp b/tests/cpp/integration/amr/test_amr_named_field.cpp index dd64dfd4d..d6efbfa53 100644 --- a/tests/cpp/integration/amr/test_amr_named_field.cpp +++ b/tests/cpp/integration/amr/test_amr_named_field.cpp @@ -39,6 +39,7 @@ #include // norm_inf #include +#include "amr_tagging_test_authority.hpp" #include "amr_transfer_test_authority.hpp" #include "load_balance_test_authority.hpp" @@ -46,6 +47,7 @@ #include #include #include +#include #include #include #include @@ -222,6 +224,190 @@ static void prepare_level_qualified_boundary(int, const MultiFab& iterate, Multi static void add_noop_level_qualified_boundary(int, const MultiFab&, MultiFab&, const Geometry&, const FieldBoundaryExecutionContext&) {} +static void boundary_carrier_prepare_noop(int face, const MultiFab& iterate, + MultiFab& operator_view, const Geometry& geometry, + const FieldBoundaryExecutionContext& context) { + (void)face; + (void)iterate; + (void)operator_view; + (void)geometry; + (void)context; +} + +static void boundary_carrier_residual_noop(int face, const MultiFab& iterate, MultiFab& residual, + const Geometry& geometry, + const FieldBoundaryExecutionContext& context) { + (void)face; + (void)iterate; + (void)residual; + (void)geometry; + (void)context; +} + +static void require_composite_boundary_carriers(const MultiFab& iterate, + const FieldBoundaryExecutionContext& context) { + if (context.state_count != 1 || context.states == nullptr || + context.state_distributions == nullptr || context.states[0] == nullptr || + context.field_count != 1 || context.fields == nullptr || + context.field_distributions == nullptr || context.fields[0] == nullptr) + throw std::runtime_error( + "composite boundary launcher did not receive its state and field carriers"); + for (const MultiFab* dependency : {context.states[0], context.fields[0]}) + if (dependency->box_array().boxes() != iterate.box_array().boxes() || + dependency->dmap().ranks() != iterate.dmap().ranks()) + throw std::runtime_error( + "composite boundary launcher received a carrier from the wrong AMR level"); +} + +static void composite_boundary_prepare(int, const MultiFab& iterate, MultiFab&, const Geometry&, + const FieldBoundaryExecutionContext& context) { + require_composite_boundary_carriers(iterate, context); +} + +static void composite_boundary_residual(int, const MultiFab& iterate, MultiFab&, const Geometry&, + const FieldBoundaryExecutionContext& context) { + require_composite_boundary_carriers(iterate, context); +} + +class ExternalLevelBoundaryPrepared final : public AmrPreparedFieldSolver { + public: + ExternalLevelBoundaryPrepared(const AmrFieldSolverBuildRequest& request, std::string contract) + : contract_(std::move(contract)), + expects_field_dependency_(!request.plan.boundary_field_blocks.empty()) { + const int levels = request.hierarchy.nlev(); + rhs_.reserve(static_cast(levels)); + phi_.reserve(static_cast(levels)); + distributions_.reserve(static_cast(levels)); + observed_context_.assign(static_cast(levels), false); + for (int level = 0; level < levels; ++level) { + const std::size_t slot = static_cast(level); + rhs_.emplace_back(request.hierarchy.ba[slot], request.hierarchy.dm[slot], 1, 0); + phi_.emplace_back(request.hierarchy.ba[slot], request.hierarchy.dm[slot], 1, 1); + rhs_.back().set_val(Real(0)); + phi_.back().set_val(Real(0)); + distributions_.push_back(level == 0 && request.replicated_coarse + ? FieldDistribution::Replicated + : FieldDistribution::Distributed); + } + } + + std::string_view provider_identity() const noexcept override { + return "tests.amr.field-solver.level-boundary"; + } + std::string_view exact_prepared_contract() const noexcept override { return contract_; } + bool couples_hierarchy_levels() const noexcept override { return false; } + int level_count() const noexcept override { return static_cast(rhs_.size()); } + FieldDistribution level_distribution(int level) const override { + return distributions_.at(static_cast(level)); + } + MultiFab& rhs_level(int level) override { return rhs_.at(static_cast(level)); } + MultiFab& phi_level(int level) override { return phi_.at(static_cast(level)); } + void set_boundary_context(const FieldBoundaryExecutionContext& context) override { + if (level_count() != 1) + throw std::runtime_error( + "external level-boundary provider requires one exact context per AMR level"); + set_boundary_context_at_level(0, context); + } + void set_boundary_context_at_level(int level, + const FieldBoundaryExecutionContext& context) override { + if (level < 0 || level >= level_count()) + throw std::out_of_range("external level-boundary context level is out of range"); + if (!expects_field_dependency_) + return; + if (context.field_count != 1 || context.fields == nullptr || + context.field_distributions == nullptr || context.fields[0] == nullptr) + throw std::runtime_error( + "external level-boundary provider did not receive its exact field dependency"); + const MultiFab& dependency = *context.fields[0]; + const MultiFab& expected = rhs_level(level); + if (dependency.box_array().boxes() != expected.box_array().boxes() || + dependency.dmap().ranks() != expected.dmap().ranks() || dependency.ncomp() != 1 || + context.field_distributions[0] != level_distribution(level)) + throw std::runtime_error( + "external level-boundary provider received a field from the wrong AMR level"); + if (!(norm_inf(dependency) > Real(0))) + throw std::runtime_error( + "external level-boundary provider received an unsolved field dependency"); + observed_context_[static_cast(level)] = true; + } + SolveReport solve() override { + if (expects_field_dependency_ && + !std::all_of(observed_context_.begin(), observed_context_.end(), + [](bool value) { return value; })) { + report_ = SolveReport::capability_failure(); + return report_; + } + for (int level = 0; level < level_count(); ++level) { + phi_level(level).set_val(Real(0)); + parallel_copy(phi_level(level), rhs_level(level)); + } + report_.iters = 0; + report_.reference_residual_norm = norm_inf(rhs_level(0)); + report_.residual_norm = Real(0); + report_.rel_residual = Real(0); + report_.mark_solved("external level-qualified boundary carrier"); + return report_; + } + const SolveReport& last_solve_report() const noexcept override { return report_; } + + private: + std::string contract_; + bool expects_field_dependency_ = false; + std::vector rhs_; + std::vector phi_; + std::vector distributions_; + std::vector observed_context_; + SolveReport report_{}; +}; + +class ExternalLevelBoundaryProvider final : public AmrFieldSolverProvider { + public: + std::string_view identity() const noexcept override { + return "tests.amr.field-solver.level-boundary"; + } + std::uint64_t interface_version() const noexcept override { return 1; } + std::string_view collective_contract() const noexcept override { + return "tests.amr.field-solver.level-boundary@1"; + } + std::vector capability_contracts() const override { + return {"tests.amr.field-solver.level-boundary.level-qualified-fields@1"}; + } + AmrFieldSolverOptions default_field_options() const override { + return {"tests.amr.field-solver.level-boundary.options@1", {}}; + } + std::optional default_hierarchy_policy( + std::string_view) const override { + return level_local_hierarchy_policy(); + } + PreparedProviderSupport accepts_options( + const AmrFieldSolverOptions& options) const noexcept override { + return options.schema_identity == "tests.amr.field-solver.level-boundary.options@1" && + options.values.empty() + ? PreparedProviderSupport::accept() + : PreparedProviderSupport::reject(1, "level-boundary options are invalid"); + } + PreparedProviderSupport supports( + const AmrFieldSolverBuildRequest& request) const noexcept override { + const bool accepted = + request.use_contract_identity == "pops.amr.field-solver-use.named@1" && + request.hierarchy.nlev() >= 1 && accepts_options(request.plan.solver_options).accepted() && + request.plan.hierarchy_policy.policy_id == "pops.field-hierarchy.level-local"; + return accepted ? PreparedProviderSupport::accept() + : PreparedProviderSupport::reject( + 2, "level-boundary provider requires a named level-local hierarchy"); + } + std::string expected_prepared_contract(const AmrFieldSolverBuildRequest& request) const override { + if (!supports(request).accepted()) + throw std::invalid_argument("external level-boundary provider rejected the request"); + return make_amr_field_solver_contract(identity(), request); + } + std::unique_ptr build( + const AmrFieldSolverBuildRequest& request) const override { + return std::make_unique(request, + expected_prepared_contract(request)); + } +}; + #if defined(POPS_HAS_KOKKOS) class KokkosEnvironment : public ::testing::Environment { public: @@ -300,6 +486,89 @@ static Real max_valid_scalar_diff(const MultiFab& lhs, const MultiFab& rhs) { return result; } +struct PhysicalBoundarySupport { + Real boundary = Real(0); + Real interior = Real(0); +}; + +static PhysicalBoundarySupport physical_boundary_support(const MultiFab& values, + const Box2D& domain) { + device_fence(); + PhysicalBoundarySupport result; + for (int li = 0; li < values.local_size(); ++li) { + const ConstArray4 data = values.fab(li).const_array(); + const Box2D valid = values.box(li); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) { + const Real magnitude = std::fabs(data(i, j, 0)); + const bool physical = + i == domain.lo[0] || i == domain.hi[0] || j == domain.lo[1] || j == domain.hi[1]; + Real& maximum = physical ? result.boundary : result.interior; + maximum = std::max(maximum, magnitude); + } + } + return result; +} + +static std::pair fine_difference_linearity_error(const MultiFab& full_step, + const MultiFab& half_step, + const MultiFab& base) { + if (full_step.box_array().boxes() != half_step.box_array().boxes() || + full_step.box_array().boxes() != base.box_array().boxes() || + full_step.dmap().ranks() != half_step.dmap().ranks() || + full_step.dmap().ranks() != base.dmap().ranks() || + full_step.local_size() != half_step.local_size() || + full_step.local_size() != base.local_size()) + throw std::invalid_argument("fine-difference linearity oracle requires identical layouts"); + device_fence(); + Real error = Real(0); + Real response = Real(0); + for (int li = 0; li < full_step.local_size(); ++li) { + const ConstArray4 full = full_step.fab(li).const_array(); + const ConstArray4 half = half_step.fab(li).const_array(); + const ConstArray4 origin = base.fab(li).const_array(); + const Box2D valid = full_step.box(li); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) { + const Real full_response = full(i, j) - origin(i, j); + const Real half_response = half(i, j) - origin(i, j); + error = std::max(error, std::fabs(full_response - Real(2) * half_response)); + response = std::max(response, std::fabs(full_response)); + } + } + return {error, response}; +} + +static std::pair multi_state_superposition_error(const MultiFab& both, + const MultiFab& only_a, + const MultiFab& only_b, + const MultiFab& base) { + if (both.box_array().boxes() != only_a.box_array().boxes() || + both.box_array().boxes() != only_b.box_array().boxes() || + both.box_array().boxes() != base.box_array().boxes() || + both.dmap().ranks() != only_a.dmap().ranks() || + both.dmap().ranks() != only_b.dmap().ranks() || both.dmap().ranks() != base.dmap().ranks()) + throw std::invalid_argument("multi-state superposition oracle requires identical layouts"); + device_fence(); + Real error = Real(0); + Real response = Real(0); + for (int li = 0; li < both.local_size(); ++li) { + const ConstArray4 simultaneous = both.fab(li).const_array(); + const ConstArray4 a = only_a.fab(li).const_array(); + const ConstArray4 b = only_b.fab(li).const_array(); + const ConstArray4 origin = base.fab(li).const_array(); + const Box2D valid = both.box(li); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) { + const Real simultaneous_response = simultaneous(i, j) - origin(i, j); + const Real separate_response = (a(i, j) - origin(i, j)) + (b(i, j) - origin(i, j)); + error = std::max(error, std::fabs(simultaneous_response - separate_response)); + response = std::max(response, std::fabs(simultaneous_response)); + } + } + return {error, response}; +} + static Real max_abs_component_diff(const MultiFab& lhs, const MultiFab& rhs, int component) { device_fence(); Real result = Real(0); @@ -521,6 +790,342 @@ TEST(test_amr_named_field, ExternalPolicyAndEmptyCapabilityProviderRunWithoutCor EXPECT_EQ(max_valid_scalar_diff(runtime.provider_potential(selected), expected), Real(0)); } +TEST(test_amr_named_field, ExternalProviderReceivesSolvedFieldDependencyOnEveryLevel) { + constexpr int n = 16; + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{true, true}; + params.mesh.n = n; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + params.poisson.bc = BCRec{}; + const detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(exb_charge(-1.0, 1.0), "minmod", "rusanov", layout, + "plasma", blob(n, 0.25), + /*has_density=*/true, 1.4, 1, false)); + blocks[0].state_identity = "test://amr-named-field/plasma/state/U"; + blocks[0].aux_ncomp = kAuxNamedBase + 2; + + auto registry = make_default_amr_field_solver_registry(); + registry->add(std::make_shared()); + const auto provider = registry->resolve("tests.amr.field-solver.level-boundary"); + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, std::move(blocks), + layout.base_per, layout.replicated_coarse, layout.wall, registry); + test::install_second_order_amr_transfer_authorities(runtime, 1); + + auto plan = [&](const std::string& field, int component) { + AmrFieldSolveConfig result; + result.plan_identity = "tests:plasma/" + field + ":plan@1"; + result.provider_identity = "tests:plasma/" + field; + result.topology_provider_kind = "tests.level-qualified-topology"; + result.topology_provenance = "tests:level-qualified-boundary"; + result.topology_digest = "tests:level-qualified-boundary:layout@1"; + result.output_owner_identity = "tests:plasma"; + result.output_block = "plasma"; + result.output_key = field; + result.solver = "tests.amr.field-solver.level-boundary"; + result.hierarchy_policy = level_local_hierarchy_policy(); + result.solver_options = provider->default_field_options(); + result.nullspace = operator_topology_zero_mean_nullspace(); + result.has_reaction = true; + result.reaction = Real(1); + result.providers.push_back( + FieldProviderBinding{"tests:plasma/" + field + "/rhs", "plasma", field, Real(1)}); + runtime.install_field_plan(field, result); + runtime.register_named_field("plasma", field, component, -1, -1, /*gradient_sign=*/1); + runtime.set_block_named_elliptic_rhs(0, field, [](const MultiFab& state, MultiFab& rhs) { + add_scaled_component(state, Real(1), 0, rhs); + }); + }; + + // The producer sorts after the consumer by slot name. The runtime must therefore use the + // dependency graph, not std::map iteration order, to solve the producer first. + plan("z_driver", kAuxNamedBase); + + AmrFieldSolveConfig dependent; + dependent.plan_identity = "tests:plasma/a_potential:plan@1"; + dependent.provider_identity = "tests:plasma/a_potential"; + dependent.topology_provider_kind = "tests.level-qualified-topology"; + dependent.topology_provenance = "tests:level-qualified-boundary"; + dependent.topology_digest = "tests:level-qualified-boundary:layout@1"; + dependent.output_owner_identity = "tests:plasma"; + dependent.output_block = "plasma"; + dependent.output_key = "a_potential"; + dependent.solver = "tests.amr.field-solver.level-boundary"; + dependent.hierarchy_policy = level_local_hierarchy_policy(); + dependent.solver_options = provider->default_field_options(); + dependent.nullspace = operator_topology_zero_mean_nullspace(); + dependent.has_reaction = true; + dependent.reaction = Real(1); + dependent.has_boundary_kernel = true; + dependent.boundary_kernel = CompiledFieldBoundaryKernel{ + "tests:a_potential/field-dependent-boundary@1", + "tests:a_potential/field-dependent-boundary-residual@1", + "", + boundary_carrier_prepare_noop, + nullptr, + boundary_carrier_residual_noop, + nullptr, + false, + }; + dependent.boundary_field_blocks = {"plasma"}; + dependent.boundary_field_keys = {"z_driver"}; + dependent.boundary_field_components = {0}; + dependent.providers.push_back( + FieldProviderBinding{"tests:plasma/a_potential/rhs", "plasma", "a_potential", Real(1)}); + runtime.install_field_plan("a_potential", dependent); + runtime.register_named_field("plasma", "a_potential", kAuxNamedBase + 1, -1, -1, + /*gradient_sign=*/1); + runtime.set_block_named_elliptic_rhs(0, "a_potential", [](const MultiFab& state, MultiFab& rhs) { + add_scaled_component(state, Real(0.5), 0, rhs); + }); + + SolveOutcome outcome = runtime.solve_named_fields(); + const SolveReport report = consume_expected_solved(std::move(outcome)); + ASSERT_TRUE(report.solved()) << report.reason; + ASSERT_EQ(runtime.provider_potential_levels("z_driver"), runtime.nlev()); + ASSERT_EQ(runtime.provider_potential_levels("a_potential"), runtime.nlev()); + for (int level = 0; level < runtime.nlev(); ++level) { + EXPECT_GT(norm_inf(runtime.provider_potential_level("z_driver", level)), Real(0)); + EXPECT_GT(norm_inf(runtime.provider_potential_level("a_potential", level)), Real(0)); + } + + // A topology change destroys and rematerializes both prepared providers. The semantic dependency + // pack must survive that invalidation, and the rebuilt consumer must receive the producer from + // each new exact level rather than retaining pointers into the retired hierarchy. + runtime.set_regrid(/*every=*/1, /*grow=*/2, /*margin=*/2); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(1.1)}}); + runtime.regrid(); + ASSERT_GE(runtime.regrid_count(), 1); + + SolveOutcome regridded_outcome = runtime.solve_named_fields(); + const SolveReport regridded_report = consume_expected_solved(std::move(regridded_outcome)); + ASSERT_TRUE(regridded_report.solved()) << regridded_report.reason; + for (int level = 0; level < runtime.nlev(); ++level) { + EXPECT_GT(norm_inf(runtime.provider_potential_level("z_driver", level)), Real(0)); + EXPECT_GT(norm_inf(runtime.provider_potential_level("a_potential", level)), Real(0)); + } +} + +TEST(test_amr_named_field, LevelLocalProviderConsumesTopologicalBoundaryDependenciesOnEveryLevel) { + constexpr int n = 16; + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{false, false}; + params.mesh.n = n; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + params.poisson.bc.xlo = params.poisson.bc.xhi = BCType::Dirichlet; + params.poisson.bc.ylo = params.poisson.bc.yhi = BCType::Dirichlet; + const detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(exb_charge(-1.0, 1.0), "minmod", "rusanov", layout, + "plasma", blob(n, 0.25), + /*has_density=*/true, 1.4, 1, false)); + blocks[0].state_identity = "test://amr-named-field/plasma/state/U"; + blocks[0].aux_ncomp = kAuxNamedBase + 3; + + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, std::move(blocks), + layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 1); + EXPECT_THROW((void)runtime.level_state(1, 0), std::out_of_range); + EXPECT_THROW((void)runtime.level_state(0, -1), std::out_of_range); + + auto driver_rhs_calls = std::make_shared(0); + auto plan = [&](const std::string& field, int component) { + AmrFieldSolveConfig result; + result.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, CompositeFacOptions{}); + result.plan_identity = "tests:plasma/" + field + ":level-local-plan@1"; + result.provider_identity = "tests:plasma/" + field; + result.topology_provider_kind = "tests.level-local-qualified-topology"; + result.topology_provenance = "tests:level-local-qualified-boundary"; + result.topology_digest = "tests:level-local-qualified-boundary:layout@1"; + result.output_owner_identity = "tests:plasma"; + result.output_block = "plasma"; + result.output_key = field; + result.hierarchy_policy = level_local_hierarchy_policy(); + result.nullspace = operator_topology_zero_mean_nullspace(); + result.has_reaction = true; + result.reaction = Real(1); + result.providers.push_back( + FieldProviderBinding{"tests:plasma/" + field + "/rhs", "plasma", field, Real(1)}); + runtime.install_field_plan(field, result); + runtime.register_named_field("plasma", field, component, -1, -1, /*gradient_sign=*/1); + runtime.set_block_named_elliptic_rhs(0, field, + [driver_rhs_calls](const MultiFab& state, MultiFab& rhs) { + ++*driver_rhs_calls; + add_scaled_component(state, Real(1), 0, rhs); + }); + }; + + // The dependency sorts after its consumer. Exact graph traversal must still solve z_driver first, + // because the level-local consumer refuses an unpublished dependency before installing any level + // carrier. + plan("z_driver", kAuxNamedBase); + + AmrFieldSolveConfig dependent; + dependent.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, CompositeFacOptions{}); + dependent.plan_identity = "tests:plasma/a_potential:level-local-plan@1"; + dependent.provider_identity = "tests:plasma/a_potential"; + dependent.topology_provider_kind = "tests.level-local-qualified-topology"; + dependent.topology_provenance = "tests:level-local-qualified-boundary"; + dependent.topology_digest = "tests:level-local-qualified-boundary:layout@1"; + dependent.output_owner_identity = "tests:plasma"; + dependent.output_block = "plasma"; + dependent.output_key = "a_potential"; + dependent.hierarchy_policy = level_local_hierarchy_policy(); + dependent.nullspace = operator_topology_zero_mean_nullspace(); + dependent.has_reaction = true; + dependent.reaction = Real(1); + dependent.has_boundary_kernel = true; + dependent.boundary_kernel = CompiledFieldBoundaryKernel{ + "tests:a_potential/level-local-field-dependent-boundary@1", + "tests:a_potential/level-local-field-dependent-boundary-residual@1", + "", + composite_boundary_prepare, + nullptr, + composite_boundary_residual, + nullptr, + false, + }; + dependent.boundary_state_blocks = {"plasma"}; + dependent.boundary_state_components = {0}; + dependent.boundary_field_blocks = {"plasma"}; + dependent.boundary_field_keys = {"z_driver"}; + dependent.boundary_field_components = {0}; + dependent.providers.push_back( + FieldProviderBinding{"tests:plasma/a_potential/rhs", "plasma", "a_potential", Real(1)}); + runtime.install_field_plan("a_potential", dependent); + runtime.register_named_field("plasma", "a_potential", kAuxNamedBase + 1, -1, -1, + /*gradient_sign=*/1); + auto dependent_rhs_calls = std::make_shared(0); + runtime.set_block_named_elliptic_rhs(0, "a_potential", + [dependent_rhs_calls](const MultiFab& state, MultiFab& rhs) { + ++*dependent_rhs_calls; + add_scaled_component(state, Real(0.5), 0, rhs); + }); + runtime.set_field_logical_timepoint( + "a_potential", FieldLogicalTimePoint{Real(0.25), Real(0.01), 1, 0, 2, 3, 1, 0}); + + const SolveReport initial_report = consume_expected_solved(runtime.solve_named_fields()); + ASSERT_TRUE(initial_report.solved()) << initial_report.reason; + std::vector accepted_driver; + std::vector accepted_potential; + std::vector accepted_aux; + for (int level = 0; level < runtime.nlev(); ++level) { + accepted_driver.push_back(runtime.provider_potential_level("z_driver", level)); + accepted_potential.push_back(runtime.provider_potential_level("a_potential", level)); + accepted_aux.push_back(runtime.aux(level)); + scale(runtime.level_state(0, level), Real(1.25)); + } + + *driver_rhs_calls = 0; + *dependent_rhs_calls = 0; + const std::string selected = "a_potential"; + SolveOutcome selected_outcome = runtime.solve_named_fields(&selected); + ASSERT_TRUE(selected_outcome.report().solved()) << selected_outcome.report().reason; + EXPECT_EQ(*driver_rhs_calls, runtime.nlev()); + EXPECT_EQ(*dependent_rhs_calls, runtime.nlev()); + for (int level = 0; level < runtime.nlev(); ++level) { + EXPECT_EQ(max_valid_scalar_diff(runtime.provider_potential_level("z_driver", level), + accepted_driver[static_cast(level)]), + Real(0)); + EXPECT_EQ(max_valid_scalar_diff(runtime.provider_potential_level("a_potential", level), + accepted_potential[static_cast(level)]), + Real(0)); + EXPECT_EQ(max_abs_diff(runtime.aux(level), accepted_aux[static_cast(level)]), + Real(0)); + } + + const SolveReport report = selected_outcome.consume(SolveConsumption::kAccept); + ASSERT_TRUE(report.solved()) << report.reason; + ASSERT_EQ(runtime.nlev(), 2); + ASSERT_EQ(runtime.provider_potential_levels("z_driver"), runtime.nlev()); + ASSERT_EQ(runtime.provider_potential_levels("a_potential"), runtime.nlev()); + for (int level = 0; level < runtime.nlev(); ++level) { + EXPECT_GT(max_valid_scalar_diff(runtime.provider_potential_level("z_driver", level), + accepted_driver[static_cast(level)]), + Real(0)); + EXPECT_GT(max_valid_scalar_diff(runtime.provider_potential_level("a_potential", level), + accepted_potential[static_cast(level)]), + Real(0)); + EXPECT_GT(max_abs_component_diff(runtime.aux(level), + accepted_aux[static_cast(level)], kAuxNamedBase), + Real(0)); + EXPECT_GT( + max_abs_component_diff(runtime.aux(level), accepted_aux[static_cast(level)], + kAuxNamedBase + 1), + Real(0)); + } + + // A selected failure after its producer has run must restore the complete bounded closure and + // leave an unrelated consumer untouched. One MG cycle cannot meet this deliberately strict + // tolerance, so the outcome exercises the ordinary RejectAttempt path rather than an exception. + AmrFieldSolveConfig failing = dependent; + failing.solver_options.values["mg.rel_tol"] = 1e-30; + failing.solver_options.values["mg.abs_tol"] = 0.0; + failing.solver_options.values["mg.max_cycles"] = std::int64_t{1}; + failing.plan_identity = "tests:plasma/zz_failure:level-local-plan@1"; + failing.provider_identity = "tests:plasma/zz_failure"; + failing.output_key = "zz_failure"; + failing.boundary_kernel = CompiledFieldBoundaryKernel{ + "tests:zz_failure/level-local-field-dependent-boundary@1", + "tests:zz_failure/level-local-field-dependent-boundary-residual@1", + "", + composite_boundary_prepare, + nullptr, + composite_boundary_residual, + nullptr, + false, + }; + failing.providers = { + FieldProviderBinding{"tests:plasma/zz_failure/rhs", "plasma", "zz_failure", Real(1)}}; + runtime.install_field_plan("zz_failure", failing); + runtime.register_named_field("plasma", "zz_failure", kAuxNamedBase + 2, -1, -1, + /*gradient_sign=*/1); + runtime.set_block_named_elliptic_rhs(0, "zz_failure", [](const MultiFab& state, MultiFab& rhs) { + add_scaled_component(state, Real(1), 0, rhs); + }); + + std::vector rollback_driver; + std::vector rollback_potential; + std::vector rollback_aux; + for (int level = 0; level < runtime.nlev(); ++level) { + rollback_driver.push_back(runtime.provider_potential_level("z_driver", level)); + rollback_potential.push_back(runtime.provider_potential_level("a_potential", level)); + rollback_aux.push_back(runtime.aux(level)); + scale(runtime.level_state(0, level), Real(1.25)); + } + *driver_rhs_calls = 0; + *dependent_rhs_calls = 0; + const std::string failing_selected = "zz_failure"; + SolveOutcome failed_outcome = runtime.solve_named_fields(&failing_selected); + const SolveReport failed = failed_outcome.consume(SolveConsumption::kRejectAttempt); + EXPECT_EQ(failed.status, SolveStatus::kIterationLimit); + EXPECT_EQ(failed.action, SolveAction::kRejectAttempt); + EXPECT_EQ(failed.iters, 1); + EXPECT_EQ(*driver_rhs_calls, runtime.nlev()) + << "the dependency closure must solve the producer before the late failure"; + EXPECT_EQ(*dependent_rhs_calls, 0) + << "the bounded closure must not solve an unrelated field consumer"; + for (int level = 0; level < runtime.nlev(); ++level) { + EXPECT_EQ(max_valid_scalar_diff(runtime.provider_potential_level("z_driver", level), + rollback_driver[static_cast(level)]), + Real(0)); + EXPECT_EQ(max_valid_scalar_diff(runtime.provider_potential_level("a_potential", level), + rollback_potential[static_cast(level)]), + Real(0)); + EXPECT_EQ(max_abs_diff(runtime.aux(level), rollback_aux[static_cast(level)]), + Real(0)); + EXPECT_EQ(norm_inf(runtime.provider_potential_level("zz_failure", level)), Real(0)); + } +} + TEST(test_amr_named_field, LevelLocalDynamicBoundaryReceivesLevelQualifiedState) { constexpr int n = 16; constexpr Real charge = Real(-1); @@ -569,7 +1174,7 @@ TEST(test_amr_named_field, LevelLocalDynamicBoundaryReceivesLevelQualifiedState) [charge](const MultiFab& state, MultiFab& rhs) { add_scaled_component(state, charge, 0, rhs); }); - runtime.set_field_boundary_dependencies("level_boundary", {"plasma"}, {0}); + runtime.set_field_boundary_dependencies("level_boundary", {"plasma"}, {0}, {}, {}, {}); runtime.set_field_boundary_kernel( "level_boundary", CompiledFieldBoundaryKernel{"tests.level-qualified-boundary", @@ -641,7 +1246,7 @@ TEST(test_amr_named_field, FullyRefinedCompositeBoundaryReceivesFinestLevelState [charge](const MultiFab& state, MultiFab& rhs) { add_scaled_component(state, charge, 0, rhs); }); - runtime.set_field_boundary_dependencies("composite_level_boundary", {"plasma"}, {0}); + runtime.set_field_boundary_dependencies("composite_level_boundary", {"plasma"}, {0}, {}, {}, {}); runtime.set_field_boundary_kernel( "composite_level_boundary", CompiledFieldBoundaryKernel{"tests.composite-level-qualified-boundary", @@ -846,7 +1451,7 @@ TEST(test_amr_named_field, Runs) { std::string context_diagnostic; try { { - auto outcome = context.solve_fields(); + auto outcome = context.solve_default_field_on_coarse_level(); (void)outcome.consume(SolveConsumption::kAccept); } FAIL() << "periodic default RHS with non-zero mean was accepted or silently projected"; @@ -983,6 +1588,62 @@ TEST(test_amr_named_field, RefinedPublicationPreservesValidAndRefreshesGhosts) { const std::string field = "screened"; ASSERT_TRUE(consume_expected_solved(runtime.solve_named_fields(&field)).solved()); ASSERT_EQ(runtime.nlev(), 2); + const MultiFab accepted_fine_state = runtime.level_state(0, 1); + const MultiFab accepted_fine_phi = runtime.provider_potential_level(field, 1); + const MultiFab accepted_fine_aux = runtime.aux(1); + MultiFab perturbed_fine_state = accepted_fine_state; + add_valid_constant(perturbed_fine_state, Real(0.125)); + const ::pops::runtime::multiblock::BoundaryEvaluationPoint fine_point{ + "main", 7, 1, 0, 3, ::pops::amr::Rational(1, 2), 0.01, 0.075}; + SolveOutcome perturbed = + runtime.solve_named_fields_from_state_at(fine_point, field, 0, perturbed_fine_state); + EXPECT_THROW( + { + SolveOutcome overlapping = + runtime.solve_named_fields_from_state_at(fine_point, field, 0, perturbed_fine_state); + (void)consume_expected_solved(std::move(overlapping)); + }, + std::logic_error) + << "one fine-level perturbation must retain exclusive ownership of its field transaction"; + EXPECT_EQ(max_abs_diff(runtime.level_state(0, 1), accepted_fine_state), Real(0)) + << "the provisional fine stage state must be restored before outcome consumption"; + EXPECT_EQ(max_abs_diff(runtime.provider_potential_level(field, 1), accepted_fine_phi), Real(0)); + EXPECT_EQ(max_abs_diff(runtime.aux(1), accepted_fine_aux), Real(0)) + << "the fine provider publication must remain private before Accept"; + ASSERT_TRUE(consume_expected_solved(std::move(perturbed)).solved()); + EXPECT_GT(max_valid_scalar_diff(runtime.provider_potential_level(field, 1), accepted_fine_phi), + Real(1e-6)) + << "the composite provider must assemble from the exact fine-level stage state"; + EXPECT_EQ(max_abs_diff(runtime.level_state(0, 1), accepted_fine_state), Real(0)); + const MultiFab full_step_phi = runtime.provider_potential_level(field, 1); + + SolveOutcome restored = + runtime.solve_named_fields_from_state_at(fine_point, field, 0, accepted_fine_state); + ASSERT_TRUE(consume_expected_solved(std::move(restored)).solved()); + EXPECT_LT(max_valid_scalar_diff(runtime.provider_potential_level(field, 1), accepted_fine_phi), + Real(1e-8)) + << "re-solving from the frozen accepted state restores the field-coupled evaluation"; + + MultiFab half_perturbed_fine_state = accepted_fine_state; + add_valid_constant(half_perturbed_fine_state, Real(0.0625)); + SolveOutcome half_perturbed = + runtime.solve_named_fields_from_state_at(fine_point, field, 0, half_perturbed_fine_state); + ASSERT_TRUE(consume_expected_solved(std::move(half_perturbed)).solved()); + const MultiFab half_step_phi = runtime.provider_potential_level(field, 1); + const auto [jvp_linearity_error, jvp_response] = + fine_difference_linearity_error(full_step_phi, half_step_phi, accepted_fine_phi); + EXPECT_GT(jvp_response, Real(1e-6)) + << "the fine-level finite-difference direction must produce a nonzero field response"; + EXPECT_LT(jvp_linearity_error, Real(5e-4) * jvp_response + Real(1e-10)) + << "halving the exact fine-level state perturbation must halve the prepared provider " + "response, the numerical contract used by field-coupled rhs_jacvec"; + + SolveOutcome final_restore = + runtime.solve_named_fields_from_state_at(fine_point, field, 0, accepted_fine_state); + ASSERT_TRUE(consume_expected_solved(std::move(final_restore)).solved()); + EXPECT_LT(max_valid_scalar_diff(runtime.provider_potential_level(field, 1), accepted_fine_phi), + Real(1e-8)); + for (int level = 0; level < runtime.nlev(); ++level) EXPECT_EQ(max_valid_component_error(runtime.provider_potential_level(field, level), runtime.aux(level), phi_component), @@ -1022,6 +1683,504 @@ TEST(test_amr_named_field, RefinedPublicationPreservesValidAndRefreshesGhosts) { "configured spatial authority"; } +TEST(test_amr_named_field, FieldCoupledRhsJacvecMatchesCenteredDifferenceOnEveryLevel) { + constexpr int n = 16; + constexpr Real reaction = Real(2); + constexpr Real c_dt = Real(0.01); + constexpr Real h = Real(2e-4); + constexpr double charge = -1.0; + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{true, true}; + params.mesh.n = n; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + params.poisson.bc = BCRec{}; + const detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(exb_charge(charge, 1.0), "minmod", "rusanov", layout, + "plasma", blob(n, 0.5), + /*has_density=*/true, 1.4, 1, false)); + blocks[0].state_identity = "test://amr-named-field/jacvec/state/U"; + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, std::move(blocks), + layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 1); + runtime.set_parent_child_temporal_relations({::pops::amr::ParentChildClockRelation( + 0, 1, ::pops::amr::Rational(2, 1), ::pops::amr::RemainderPolicy::IntegralOnly)}); + + AmrFieldSolveConfig plan; + plan.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, CompositeFacOptions{}); + plan.plan_identity = "test:plasma/jacvec:plan:v1"; + plan.provider_identity = "test:plasma/jacvec"; + plan.topology_provider_kind = "structured"; + plan.topology_provenance = "test:periodic-cartesian"; + plan.topology_digest = "test:periodic-cartesian:v1"; + plan.output_owner_identity = "test:plasma"; + plan.output_block = "plasma"; + plan.output_key = "jacvec"; + plan.hierarchy_policy = composite_hierarchy_policy(); + plan.nullspace = operator_topology_zero_mean_nullspace(); + plan.has_reaction = true; + plan.reaction = reaction; + plan.providers.push_back( + FieldProviderBinding{"test:plasma/jacvec/rhs", "plasma", "jacvec", Real(1)}); + runtime.install_field_plan("jacvec", plan); + // The ExB residual reads the canonical (phi, grad_x, grad_y) auxiliary components 0..2. + // Publishing this named provider there makes the elliptic response part of the residual whose + // Jacobian-vector product is checked below; a provider-only test would miss this coupling. + runtime.register_named_field("plasma", "jacvec", 0, 1, 2, /*gradient_sign=*/-1); + runtime.set_block_named_elliptic_rhs(0, "jacvec", [charge](const MultiFab& state, MultiFab& rhs) { + add_scaled_component(state, Real(charge), 0, rhs); + }); + + const std::string field = "jacvec"; + const auto prove_field_coupled_jvp = [&](int tick_base, std::string_view phase) { + ASSERT_EQ(runtime.nlev(), 2) << phase; + std::vector forward_errors(static_cast(runtime.nlev()), Real(0)); + std::vector coupled_responses(static_cast(runtime.nlev()), Real(0)); + std::vector stale_provider_gaps(static_cast(runtime.nlev()), Real(0)); + std::vector restore_errors(static_cast(runtime.nlev()), Real(0)); + + for (int level = 0; level < runtime.nlev(); ++level) { + const runtime::multiblock::BoundaryEvaluationPoint point{ + "main", tick_base + level, level, 0, 3, ::pops::amr::Rational(1, 2), 0.01, 0.005}; + const MultiFab live_before = runtime.level_state(0, level); + MultiFab iterate = runtime.level_state(0, level); + MultiFab direction = iterate; + scale(direction, Real(0.75)); + + const SolveReport base_report = consume_expected_solved( + runtime.solve_named_fields_from_state_at(point, field, 0, iterate)); + if (!base_report.solved()) + throw std::runtime_error("field-coupled JVP oracle could not prepare its base provider"); + const MultiFab base_phi = runtime.provider_potential_level(field, level); + + MultiFab r0(iterate.box_array(), iterate.dmap(), iterate.ncomp(), 0); + r0.set_val(Real(0)); + runtime.level_rhs_core_into_at(0, level, point, iterate, r0, /*flux_only=*/false); + + const auto residual_at = [&](Real shift, bool coupled) { + MultiFab state = iterate; + saxpy(state, shift, direction); + MultiFab residual(iterate.box_array(), iterate.dmap(), iterate.ncomp(), 0); + residual.set_val(Real(0)); + if (coupled) { + const SolveReport perturbed = consume_expected_solved( + runtime.solve_named_fields_from_state_at(point, field, 0, state)); + if (!perturbed.solved()) + throw std::runtime_error("field-coupled JVP oracle could not solve its perturbation"); + runtime.level_rhs_core_into_at(0, level, point, state, residual, /*flux_only=*/false); + const SolveReport restored = consume_expected_solved( + runtime.solve_named_fields_from_state_at(point, field, 0, iterate)); + if (!restored.solved()) + throw std::runtime_error( + "field-coupled JVP oracle could not restore its base provider"); + } else { + runtime.level_rhs_core_into_at(0, level, point, state, residual, /*flux_only=*/false); + } + return residual; + }; + + const MultiFab plus = residual_at(h, /*coupled=*/true); + const MultiFab minus = residual_at(-h, /*coupled=*/true); + const MultiFab plus_with_stale_provider = residual_at(h, /*coupled=*/false); + + // This is the exact forward-difference algebra emitted for + // rhs_jacvec(field_coupled=True): Jv = v - c_dt (R(U+h v)-R(U))/h. + MultiFab generated = direction; + saxpy(generated, -c_dt / h, plus); + saxpy(generated, c_dt / h, r0); + + // A separately assembled centered finite difference is the numerical reference. It catches + // using a coarse/cached provider for either perturbed residual, while remaining independent of + // the one-sided production formula. + MultiFab centered = direction; + saxpy(centered, -c_dt / (Real(2) * h), plus); + saxpy(centered, c_dt / (Real(2) * h), minus); + forward_errors[static_cast(level)] = max_valid_scalar_diff(generated, centered); + coupled_responses[static_cast(level)] = + max_valid_scalar_diff(centered, direction); + + MultiFab stale = direction; + saxpy(stale, -c_dt / h, plus_with_stale_provider); + saxpy(stale, c_dt / h, r0); + stale_provider_gaps[static_cast(level)] = max_valid_scalar_diff(stale, centered); + restore_errors[static_cast(level)] = + max_valid_scalar_diff(runtime.provider_potential_level(field, level), base_phi); + EXPECT_EQ(max_abs_diff(runtime.level_state(0, level), live_before), Real(0)) + << phase << ": stage-state evaluation must restore live state on level " << level; + } + + for (int level = 0; level < runtime.nlev(); ++level) { + const std::size_t k = static_cast(level); + EXPECT_GT(coupled_responses[k], Real(1e-7)) + << phase << ": the field-coupled residual derivative must be observable on level " + << level; + EXPECT_LT(forward_errors[k], Real(2e-2) * coupled_responses[k] + Real(2e-7)) + << phase + << ": the emitted one-sided field-coupled JVP contract must match an independent " + "centered finite difference on level " + << level; + EXPECT_GT(stale_provider_gaps[k], Real(1e-7)) + << phase << ": freezing the provider must produce a measurably different JVP on level " + << level; + EXPECT_LT(restore_errors[k], Real(1e-8)) + << phase << ": every perturbed evaluation must restore the frozen provider on level " + << level; + } + EXPECT_FALSE(runtime.field_solve_transaction_active()) + << phase << ": the accepted JVP sequence must leave no field transaction"; + }; + + prove_field_coupled_jvp(/*tick_base=*/40, "before regrid"); + const std::vector layout_before = runtime.output_geometry_boxes(); + const auto provider_layout_before = runtime.field_topology_patches(field); + ASSERT_TRUE(provider_layout_before.has_value()); + EXPECT_EQ(*provider_layout_before, layout_before); + const std::uint64_t epoch_before = runtime.topology_epoch(); + const std::uint64_t generation_before = runtime.topology_materialization_generation(); + + // Replace the bootstrap patch with a smaller central L1 layout while retaining uncovered active + // L0 cells. The provider and its stage-state scratch must not retain any box, mapping, or pointer + // from the retired hierarchy. + runtime.set_regrid(/*every=*/1, /*grow=*/2, /*margin=*/2); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.2)}}); + runtime.regrid(); + ASSERT_EQ(runtime.nlev(), 2); + EXPECT_GT(runtime.topology_epoch(), epoch_before); + EXPECT_GT(runtime.topology_materialization_generation(), generation_before); + const std::vector layout_after = runtime.output_geometry_boxes(); + EXPECT_NE(layout_after, layout_before) << "the oracle requires a real L1 layout replacement"; + const auto provider_layout_after_regrid = runtime.field_topology_patches(field); + ASSERT_TRUE(provider_layout_after_regrid.has_value()); + EXPECT_EQ(*provider_layout_after_regrid, layout_after) + << "the regrid transaction must rematerialize the provider before publication"; + EXPECT_NE(*provider_layout_after_regrid, *provider_layout_before); + + // Both (block, level) stage-state scratch keys were populated above on the retired layouts. + // Reusing them here must replace their MultiFabs before the first post-regrid perturbation. + prove_field_coupled_jvp(/*tick_base=*/80, "after regrid"); + const auto provider_layout_after = runtime.field_topology_patches(field); + ASSERT_TRUE(provider_layout_after.has_value()); + EXPECT_EQ(*provider_layout_after, layout_after) + << "the JVP must rematerialize its provider on the exact replacement hierarchy"; + EXPECT_EQ(*provider_layout_after, *provider_layout_after_regrid); +} + +TEST(test_amr_named_field, + PhysicalFieldBoundaryCoupledRhsJacvecMatchesCenteredDifferenceOnEveryLevel) { + constexpr int n = 12; + constexpr Real reaction = Real(2); + constexpr Real c_dt = Real(0.01); + constexpr Real h = Real(2e-4); + constexpr double charge = -1.0; + const std::string state_identity = "test://plasma/physical-boundary/state/U"; + const std::string field_identity = "test://plasma/physical-boundary/field/jacvec"; + const std::string field = "jacvec_boundary"; + + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{false, false}; + params.mesh.n = n; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + BCRec physical_field_bc; + physical_field_bc.xlo = physical_field_bc.xhi = BCType::Dirichlet; + physical_field_bc.ylo = physical_field_bc.yhi = BCType::Dirichlet; + params.poisson.bc = physical_field_bc; + detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + // The ordinary deterministic AMR seed is an interior patch. This oracle deliberately uses one + // fully refined domain so both L0 and L1 own the same physical x-low transport face; no synthetic + // coarse/fine face can accidentally satisfy the boundary assertions below. + const Box2D fine_domain = layout.geom.domain.refine(kAmrRefRatio); + layout.ba[1] = BoxArray::from_domain(fine_domain, fine_domain.nx()); + layout.dm[1] = layout.load_balance->distribute(layout.ba[1], n_ranks()); + + BCRec transport_bc; + transport_bc.xlo = transport_bc.xhi = BCType::Foextrap; + transport_bc.ylo = transport_bc.yhi = BCType::Foextrap; + auto boundary_plan = std::make_shared( + "test://plasma/physical-boundary/plan", 1, std::vector{transport_bc}, + std::vector{}, state_identity, PreparedBoundaryReadDependencies{{}, {field_identity}}); + const PreparedBoundaryFieldRead field_read = boundary_plan->prepare_field_read(field_identity); + std::map> boundary_plans{ + {"plasma", boundary_plan}}; + layout.boundary_plans = &boundary_plans; + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(exb_charge(charge, 1.0), "minmod", "rusanov", layout, + "plasma", blob(n, 0.5), + /*has_density=*/true, 1.4, 1, false)); + blocks.back().state_identity = state_identity; + blocks.back().level_boundary_residual_at_point_prepared = + [field_read](const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& state, + const MultiFab&, const Geometry& geometry, MultiFab& residual, + const PreparedGridBoundarySession& boundary) { + const PreparedBoundaryReadView reads = boundary.bind_reads(point, state); + const MultiFab& solved_field = reads.field(field_read); + for (int local = 0; local < residual.local_size(); ++local) { + const int field_local = solved_field.local_index_of(residual.global_index(local)); + if (field_local < 0) + throw std::logic_error( + "physical field-boundary oracle lost co-distributed field ownership"); + const Box2D valid = residual.box(local); + if (valid.lo[0] > geometry.domain.lo[0] || valid.hi[0] < geometry.domain.lo[0]) + continue; + const ConstArray4 phi = solved_field.fab(field_local).const_array(); + const Array4 output = residual.fab(local).array(); + const int i = geometry.domain.lo[0]; + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + output(i, j, 0) += Real(100) * phi(i, j, 0); + } + }; + + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, std::move(blocks), + layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 1); + runtime.set_parent_child_temporal_relations({::pops::amr::ParentChildClockRelation( + 0, 1, ::pops::amr::Rational(2, 1), ::pops::amr::RemainderPolicy::IntegralOnly)}); + + AmrFieldSolveConfig plan; + plan.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, CompositeFacOptions{}); + plan.plan_identity = "test:plasma/physical-boundary-jacvec:plan:v1"; + plan.provider_identity = "test:plasma/physical-boundary-jacvec"; + plan.topology_provider_kind = "structured"; + plan.topology_provenance = "test:physical-cartesian"; + plan.topology_digest = "test:physical-cartesian:v1"; + plan.output_owner_identity = "test:plasma"; + plan.output_block = "plasma"; + plan.output_key = field; + plan.hierarchy_policy = level_local_hierarchy_policy(); + plan.nullspace = operator_topology_zero_mean_nullspace(); + plan.has_reaction = true; + plan.reaction = reaction; + plan.providers.push_back( + FieldProviderBinding{"test:plasma/physical-boundary-jacvec/rhs", "plasma", field, Real(1)}); + runtime.install_field_plan(field, plan); + runtime.register_named_field("plasma", field, 0, 1, 2, /*gradient_sign=*/-1); + runtime.set_block_named_elliptic_rhs(0, field, [charge](const MultiFab& state, MultiFab& rhs) { + add_scaled_component(state, Real(charge), 0, rhs); + }); + runtime.install_boundary_storage_routes({{field_identity, field}}); + + ASSERT_EQ(runtime.nlev(), 2); + for (int level = 0; level < runtime.nlev(); ++level) { + const runtime::multiblock::BoundaryEvaluationPoint point{ + "main", 60 + level, level, 0, 3, ::pops::amr::Rational(1, 2), 0.01, 0.005}; + MultiFab iterate = runtime.level_state(0, level); + MultiFab direction = iterate; + scale(direction, Real(0.75)); + + const SolveReport base_report = + consume_expected_solved(runtime.solve_named_fields_from_state_at(point, field, 0, iterate)); + ASSERT_TRUE(base_report.solved()); + const MultiFab base_phi = runtime.provider_potential_level(field, level); + + const auto residual_at = [&](Real shift, bool coupled, bool include_boundary) { + MultiFab state = iterate; + saxpy(state, shift, direction); + MultiFab residual(iterate.box_array(), iterate.dmap(), iterate.ncomp(), 0); + residual.set_val(Real(0)); + if (coupled) { + const SolveReport perturbed = consume_expected_solved( + runtime.solve_named_fields_from_state_at(point, field, 0, state)); + if (!perturbed.solved()) + throw std::runtime_error( + "physical field-boundary JVP oracle could not solve its perturbation"); + } + if (include_boundary) + runtime.level_rhs_into_at(0, level, point, state, residual); + else + runtime.level_rhs_core_into_at(0, level, point, state, residual, /*flux_only=*/false); + if (coupled) { + const SolveReport restored = consume_expected_solved( + runtime.solve_named_fields_from_state_at(point, field, 0, iterate)); + if (!restored.solved()) + throw std::runtime_error( + "physical field-boundary JVP oracle could not restore its base provider"); + } + return residual; + }; + + const MultiFab r0 = residual_at(Real(0), /*coupled=*/false, /*include_boundary=*/true); + const MultiFab plus = residual_at(h, /*coupled=*/true, /*include_boundary=*/true); + const MultiFab minus = residual_at(-h, /*coupled=*/true, /*include_boundary=*/true); + const MultiFab stale_plus = residual_at(h, /*coupled=*/false, /*include_boundary=*/true); + const MultiFab plus_core = residual_at(h, /*coupled=*/true, /*include_boundary=*/false); + const MultiFab minus_core = residual_at(-h, /*coupled=*/true, /*include_boundary=*/false); + const MultiFab stale_plus_core = residual_at(h, /*coupled=*/false, /*include_boundary=*/false); + + // Exact one-sided algebra emitted by rhs_jacvec(field_coupled=True). + MultiFab generated = direction; + saxpy(generated, -c_dt / h, plus); + saxpy(generated, c_dt / h, r0); + + // Independent centered reference for the complete core + physical-boundary residual. + MultiFab centered = direction; + saxpy(centered, -c_dt / (Real(2) * h), plus); + saxpy(centered, c_dt / (Real(2) * h), minus); + const Real response = max_valid_scalar_diff(centered, direction); + EXPECT_GT(response, Real(1e-7)); + EXPECT_LT(max_valid_scalar_diff(generated, centered), Real(2e-2) * response + Real(2e-7)) + << "field-coupled physical-boundary JVP mismatch on level " << level; + + MultiFab centered_core = direction; + saxpy(centered_core, -c_dt / (Real(2) * h), plus_core); + saxpy(centered_core, c_dt / (Real(2) * h), minus_core); + EXPECT_GT(max_valid_scalar_diff(centered, centered_core), Real(1e-8)) + << "the solved-field physical boundary must affect the JVP on level " << level; + + MultiFab coupled_boundary = plus; + saxpy(coupled_boundary, Real(-1), plus_core); + MultiFab stale_boundary = stale_plus; + saxpy(stale_boundary, Real(-1), stale_plus_core); + EXPECT_GT(max_valid_scalar_diff(coupled_boundary, stale_boundary), Real(1e-8)) + << "freezing the provider must change the physical boundary residual on level " << level; + + const PhysicalBoundarySupport support = + physical_boundary_support(coupled_boundary, runtime.level_geom(level).domain); + EXPECT_GT(support.boundary, Real(1e-8)) + << "the physical boundary contribution is missing on level " << level; + EXPECT_LT(support.interior, Real(1e-13)) + << "the physical boundary contribution leaked into interior cells on level " << level; + EXPECT_LT(max_valid_scalar_diff(runtime.provider_potential_level(field, level), base_phi), + Real(1e-8)) + << "the perturbed field provider was not restored on level " << level; + } +} + +TEST(test_amr_named_field, ExactMultiStateStagePackRunsOnEveryMaterializedLevel) { + constexpr int n = 16; + constexpr int phi_component = kAuxNamedBase; + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{true, true}; + params.mesh.n = n; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + params.poisson.bc = BCRec{}; + const detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(exb_charge(1.0, 1.0), "minmod", "rusanov", layout, + "a", blob(n, 0.35), + /*has_density=*/true, 1.4, 1, false)); + blocks.push_back(detail::dispatch_amr_block(exb_charge(1.0, 1.0), "minmod", "rusanov", layout, + "b", blob(n, 0.65), + /*has_density=*/true, 1.4, 1, false)); + for (AmrRuntimeBlock& block : blocks) + block.aux_ncomp = phi_component + 1; + + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, std::move(blocks), + layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 2); + runtime.set_parent_child_temporal_relations({::pops::amr::ParentChildClockRelation( + 0, 1, ::pops::amr::Rational(2, 1), ::pops::amr::RemainderPolicy::IntegralOnly)}); + + AmrFieldSolveConfig plan; + plan.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, CompositeFacOptions{}); + plan.plan_identity = "test:a/coupled_screened:plan:v1"; + plan.provider_identity = "test:a/coupled_screened"; + plan.topology_provider_kind = "structured"; + plan.topology_provenance = "test:periodic-cartesian"; + plan.topology_digest = "test:periodic-cartesian:v1"; + plan.output_owner_identity = "test:a"; + plan.output_block = "a"; + plan.output_key = "coupled_screened"; + plan.hierarchy_policy = composite_hierarchy_policy(); + plan.nullspace = operator_topology_zero_mean_nullspace(); + plan.has_reaction = true; + plan.reaction = Real(2); + plan.providers.push_back( + FieldProviderBinding{"test:a/coupled_screened/rhs", "a", "coupled_screened", Real(1)}); + plan.providers.push_back( + FieldProviderBinding{"test:b/coupled_screened/rhs", "b", "coupled_screened", Real(1)}); + runtime.install_field_plan("coupled_screened", plan); + runtime.register_named_field("a", "coupled_screened", phi_component, + /*gx=*/-1, /*gy=*/-1, /*gradient_sign=*/1); + runtime.set_block_named_elliptic_rhs( + 0, "coupled_screened", + [](const MultiFab& state, MultiFab& rhs) { add_scaled_component(state, Real(1), 0, rhs); }); + runtime.set_block_named_elliptic_rhs( + 1, "coupled_screened", + [](const MultiFab& state, MultiFab& rhs) { add_scaled_component(state, Real(1), 0, rhs); }); + + const std::string field = "coupled_screened"; + ASSERT_TRUE(consume_expected_solved(runtime.solve_named_fields(&field)).solved()); + ASSERT_EQ(runtime.nlev(), 2); + for (int level = 0; level < runtime.nlev(); ++level) { + const MultiFab base_phi = runtime.provider_potential_level(field, level); + const MultiFab accepted_a = runtime.level_state(0, level); + const MultiFab accepted_b = runtime.level_state(1, level); + MultiFab stage_a = accepted_a; + MultiFab stage_b = accepted_b; + add_valid_constant(stage_a, Real(0.05)); + add_valid_constant(stage_b, Real(0.08)); + const ::pops::runtime::multiblock::BoundaryEvaluationPoint point{ + "main", + 11, + level, + level, + 23, + ::pops::amr::Rational(1, 2), + 0.01 / static_cast(1 << level), + 0.205}; + + std::vector stages(2, nullptr); + stages[0] = &stage_a; + if (level == 0) { + auto incomplete_point = point; + incomplete_point.clock.clear(); + EXPECT_THROW((void)runtime.solve_named_fields_from_states_at(incomplete_point, field, stages), + std::invalid_argument) + << "the native stage-pack route must retain its complete BoundaryEvaluationPoint"; + } + SolveOutcome only_a_pending = runtime.solve_named_fields_from_states_at(point, field, stages); + EXPECT_EQ(max_abs_diff(runtime.level_state(0, level), accepted_a), Real(0)); + EXPECT_EQ(max_abs_diff(runtime.level_state(1, level), accepted_b), Real(0)); + EXPECT_EQ(max_abs_diff(runtime.provider_potential_level(field, level), base_phi), Real(0)) + << "the level-qualified multi-state candidate must remain private before Accept"; + ASSERT_TRUE(consume_expected_solved(std::move(only_a_pending)).solved()); + const MultiFab only_a = runtime.provider_potential_level(field, level); + + stages[0] = nullptr; + stages[1] = &stage_b; + ASSERT_TRUE( + consume_expected_solved(runtime.solve_named_fields_from_states_at(point, field, stages)) + .solved()); + const MultiFab only_b = runtime.provider_potential_level(field, level); + + stages[0] = &stage_a; + stages[1] = &stage_b; + ASSERT_TRUE( + consume_expected_solved(runtime.solve_named_fields_from_states_at(point, field, stages)) + .solved()); + const MultiFab both = runtime.provider_potential_level(field, level); + const auto [superposition_error, response] = + multi_state_superposition_error(both, only_a, only_b, base_phi); + EXPECT_GT(response, Real(1e-6)) + << "both stage overrides must contribute on materialized level " << level; + EXPECT_LT(superposition_error, Real(5e-4) * response + Real(1e-10)) + << "the exact multi-state request must assemble both level-qualified stage states"; + EXPECT_EQ(max_abs_diff(runtime.level_state(0, level), accepted_a), Real(0)); + EXPECT_EQ(max_abs_diff(runtime.level_state(1, level), accepted_b), Real(0)); + + stages[0] = &accepted_a; + stages[1] = &accepted_b; + ASSERT_TRUE( + consume_expected_solved(runtime.solve_named_fields_from_states_at(point, field, stages)) + .solved()); + EXPECT_LT(max_valid_scalar_diff(runtime.provider_potential_level(field, level), base_phi), + Real(1e-8)) + << "the accepted hierarchy state must restore the level-qualified field"; + } +} + TEST(test_amr_named_field, CoarseAuthoritativeAuxUsesPreparedTransferAndComponentBcOnFineBoundary) { constexpr int n = 16; constexpr int component = kAuxNamedBase; diff --git a/tests/cpp/integration/amr/test_amr_program_diffusion.cpp b/tests/cpp/integration/amr/test_amr_program_diffusion.cpp index ad4359374..225f7e913 100644 --- a/tests/cpp/integration/amr/test_amr_program_diffusion.cpp +++ b/tests/cpp/integration/amr/test_amr_program_diffusion.cpp @@ -38,8 +38,8 @@ struct DiffusiveScalar { Real nu = Real(0); - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } POPS_HD Real diffusivity() const { return nu; } diff --git a/tests/cpp/integration/amr/test_amr_program_positivity_floor.cpp b/tests/cpp/integration/amr/test_amr_program_positivity_floor.cpp index 9e65a3a88..2b6865549 100644 --- a/tests/cpp/integration/amr/test_amr_program_positivity_floor.cpp +++ b/tests/cpp/integration/amr/test_amr_program_positivity_floor.cpp @@ -39,10 +39,10 @@ struct DensityAdvection { using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State& state, const Aux&, int direction) const { + POPS_HD State flux(const State& state, const auto&, int direction) const { return direction == 0 ? state : State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int direction) const { + POPS_HD Real max_wave_speed(const State&, const auto&, int direction) const { return direction == 0 ? Real(1) : Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } diff --git a/tests/cpp/integration/amr/test_amr_system_bz_multibox.cpp b/tests/cpp/integration/amr/test_amr_system_bz_multibox.cpp index 0d09ce025..3edf851e0 100644 --- a/tests/cpp/integration/amr/test_amr_system_bz_multibox.cpp +++ b/tests/cpp/integration/amr/test_amr_system_bz_multibox.cpp @@ -54,8 +54,8 @@ struct BzGrowMB { using Aux = pops::Aux; static constexpr int n_vars = 1; static constexpr int n_aux = 4; // phi, grad_x, grad_y, B_z - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux& a) const { return State{a.B_z * u[0]}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } }; @@ -65,8 +65,8 @@ struct InertMB { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } }; diff --git a/tests/cpp/integration/amr/test_amr_system_bz_pop.cpp b/tests/cpp/integration/amr/test_amr_system_bz_pop.cpp index cc3aba84a..91c3b0e8a 100644 --- a/tests/cpp/integration/amr/test_amr_system_bz_pop.cpp +++ b/tests/cpp/integration/amr/test_amr_system_bz_pop.cpp @@ -50,8 +50,8 @@ struct BzGrowPop { using Aux = pops::Aux; static constexpr int n_vars = 1; static constexpr int n_aux = 4; // phi, grad_x, grad_y, B_z - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux& a) const { return State{a.B_z * u[0]}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } }; @@ -62,10 +62,10 @@ struct AdvectXPop { using Aux = pops::Aux; static constexpr int n_vars = 1; Real v = Real(1); - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{dir == 0 ? v * u[0] : Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return std::fabs(v); } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return std::fabs(v); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } }; diff --git a/tests/cpp/integration/amr/test_amr_system_contract.cpp b/tests/cpp/integration/amr/test_amr_system_contract.cpp index a90f6eda1..e9d452505 100644 --- a/tests/cpp/integration/amr/test_amr_system_contract.cpp +++ b/tests/cpp/integration/amr/test_amr_system_contract.cpp @@ -10,6 +10,7 @@ #include #include "explicit_amr_program.hpp" +#include #include #include #include @@ -79,6 +80,31 @@ static void install_regrid_state_authorities(AmrSystem& system, } } +TEST(test_amr_system_contract, RefusesMappedPeriodicityBeforeAmrFillPatchConstruction) { +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard; +#endif + AmrSystemConfig cfg; + cfg.n = 8; + cfg.L = 1.0; + cfg.regrid_every = 0; + cfg.periodicity = {false, false}; + AmrSystem system(cfg); + const std::string state_identity = "case::block::tracer::state::U"; + system.install_block_state_route("tracer", state_identity); + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + + EXPECT_THROW( + system.install_boundary_plan( + "tracer", "case::block::tracer::boundary", 1, + {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(4, 0.0), + {"case::block::tracer::xlo", "case::block::tracer::xhi", "case::block::tracer::ylo", + "case::block::tracer::yhi"}, + {"Scalar"}, {}, state_identity, PreparedBoundaryReadDependencies{}, {xlo_to_yhi}), + std::runtime_error); +} + TEST(test_amr_system_contract, Runs) { #if defined(POPS_HAS_KOKKOS) Kokkos::ScopeGuard guard; @@ -391,6 +417,62 @@ TEST(test_amr_system_contract, Runs) { } } +TEST(test_amr_system_contract, PrimitiveFixedStateUsesTheConcreteAmrBlockModelConversion) { +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard; +#endif + AmrSystemConfig cfg; + cfg.n = 4; + cfg.L = 1.0; + cfg.regrid_every = 0; + cfg.periodicity = {false, false}; + AmrSystem system(cfg); + const std::string state_identity = "case::block::fluid::state::U"; + system.install_block_state_route("fluid", state_identity); + std::vector face_values; + for (const double primitive : {2.0, 3.0, -1.0}) + face_values.insert(face_values.end(), {0.0, primitive, 0.0, 0.0}); + system.install_boundary_plan("fluid", "case::block::fluid::boundary", 2, + {"foextrap", "dirichlet", "foextrap", "foextrap"}, face_values, + {"case::block::fluid::xlo", "case::block::fluid::xhi", + "case::block::fluid::ylo", "case::block::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY"}, {}, state_identity, {}, {}, + {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::block::fluid::model-p2c", "", ""}); + system.add_block("fluid", magnetic_fluid_spec(), "minmod", "rusanov", "conservative", "explicit", + 1); + (void)system.mass("fluid"); + + AmrRuntime* runtime = system.engine(); + ASSERT_NE(runtime, nullptr); + MultiFab& state = runtime->level_state(0, 0); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + for (int component = 0; component < 3; ++component) + values(i, j, component) = Real(1); + }); + } + device_fence(); + MultiFab rhs = runtime->level_scalar_field(0, state.ncomp(), 0); + runtime->level_rhs_into(0, 0, state, rhs); + device_fence(); + state.sync_host(); + + const Box2D domain = runtime->level_geom(0).domain; + bool observed = false; + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& values = state.fab(local); + if (!values.grown_box().contains(domain.hi[0] + 1, 2)) + continue; + observed = true; + EXPECT_EQ(values(domain.hi[0] + 1, 2, 0), Real(3)); + EXPECT_EQ(values(domain.hi[0] + 1, 2, 1), Real(11)); + EXPECT_EQ(values(domain.hi[0] + 1, 2, 2), Real(-5)); + } + EXPECT_TRUE(observed); +} + TEST(test_amr_system_contract, VariableDtStrideUsesOneExactPublicWindow) { #if defined(POPS_HAS_KOKKOS) Kokkos::ScopeGuard guard; @@ -436,6 +518,46 @@ TEST(test_amr_system_contract, VariableDtStrideUsesOneExactPublicWindow) { EXPECT_DOUBLE_EQ(system.program_cadence_window_start_time(), 0.0); } +TEST(test_amr_system_contract, StrideHeldStepPublishesTheExactZeroBalance) { +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard; +#endif + AmrSystemConfig cfg; + cfg.n = 4; + cfg.L = 1.0; + cfg.regrid_every = 0; + cfg.periodicity = {true, true}; + + AmrSystem system(cfg); + system.add_block("tracer", exb_spec(), "none", "rusanov", "conservative", "explicit", 1); + system.install_program_step([](double) {}); + system.set_program_cadence(/*substeps=*/1, /*stride=*/2); + system.begin_step_transaction(); + system.step(0.1); + + const std::string route = "pops.balance-ledger-route.v1:sha256:" + std::string(64, '8'); + const auto balance = system.accepted_balance_terms(route); + EXPECT_EQ(balance.size(), 5u); + for (const auto& [name, value] : balance) { + EXPECT_FALSE(name.empty()); + EXPECT_DOUBLE_EQ(value, 0.0); + } + system.commit_step_transaction(); + system.finalize_step_transaction(); + + system.begin_step_transaction(); + system.step(0.1); + system.rollback_step_transaction(); + system.begin_step_transaction(); + const auto restored = system.accepted_balance_terms(route); + EXPECT_EQ(restored.size(), 5u); + for (const auto& [name, value] : restored) { + EXPECT_FALSE(name.empty()); + EXPECT_DOUBLE_EQ(value, 0.0); + } + system.rollback_step_transaction(); +} + TEST(test_amr_system_contract, CadenceRestoreRejectsClockDriftWithoutMutatingAcceptedState) { #if defined(POPS_HAS_KOKKOS) Kokkos::ScopeGuard guard; diff --git a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp index 1c3bbda1f..083eeb787 100644 --- a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp +++ b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #if defined(POPS_HAS_KOKKOS) @@ -83,7 +84,9 @@ Real fine_polynomial_average(const Box2D& fine_domain, int i, int j) { return degree_four_cell_average(x, x + Real(0.5), y, y + Real(0.5)); } -AmrRuntime bootstrap_runtime(int cells = 8, bool install_prepared_boundary = false) { +AmrRuntime bootstrap_runtime( + int cells = 8, bool install_prepared_boundary = false, + double maximum_recoverable_value = std::numeric_limits::infinity()) { AmrBuildParams params; params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); params.mesh.periodicity = Periodicity{true, true}; @@ -97,13 +100,33 @@ AmrRuntime bootstrap_runtime(int cells = 8, bool install_prepared_boundary = fal exb_model(), "minmod", "rusanov", layout, "transport", std::vector(static_cast(cells) * cells, 1.0), true, 1.4, 1, false, 1)); blocks.back().state_identity = "test://amr-transfer/bootstrap/transport/state/U"; + if (std::isfinite(maximum_recoverable_value)) + blocks.back().cons_to_prim = [maximum_recoverable_value](const double* conserved, + double* primitive) { + RecoveryReport report; + if (!std::isfinite(conserved[0]) || conserved[0] > maximum_recoverable_value) { + report.status = RecoveryStatus::kRejected; + report.cause = RecoveryCause::kInadmissibleCandidate; + report.failing_component = 0; + return report; + } + primitive[0] = conserved[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; + }; if (install_prepared_boundary) { auto& block = blocks.front(); const std::string state_identity = block.state_identity; block.boundary_plan = std::make_shared( "case::bootstrap::transport::boundary", 1, - std::vector(static_cast(block.ncomp), BCRec{}), std::vector{}, - state_identity); + prepare_hyperbolic_boundary<2>( + {"periodic", "periodic", "periodic", "periodic"}, + std::vector(static_cast(4 * block.ncomp), 0.0), + {"case::bootstrap::xlo", "case::bootstrap::xhi", "case::bootstrap::ylo", + "case::bootstrap::yhi"}, + std::vector(static_cast(block.ncomp), "Custom")), + std::vector{}, state_identity); const PreparedBoundaryPlan* const expected_plan = block.boundary_plan.get(); block.boundary_field_registry = std::make_shared(); block.level_rhs_core_at_point_prepared = @@ -509,6 +532,102 @@ TEST(test_amr_transfer_properties, NativeSubcyclingGhostFillInterpolatesTimeThen std::invalid_argument); } +TEST(test_amr_transfer_properties, + RegridPublishesOnlyAfterPreparedRecoveryAcceptsEveryCandidateCell) { + AmrRuntime runtime = bootstrap_runtime(); + const std::vector coarse_before = runtime.block_level_state(0, 0); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.5)}}, + "test::recovery-accepted-regrid@1"); + + EXPECT_NO_THROW(runtime.regrid()); + EXPECT_GT(runtime.nlev(), 1); + EXPECT_EQ(runtime.regrid_count(), 1); + EXPECT_EQ(runtime.block_level_state(0, 0), coarse_before); +} + +TEST(test_amr_transfer_properties, + RestrictionPublishesOnlyAfterPreparedRecoveryAcceptsEveryCandidateCell) { + AmrRuntime runtime = bootstrap_runtime(); + const std::vector coarse_before = runtime.block_level_state(0, 0); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.5)}}, + "test::recovery-accepted-restriction-bootstrap@1"); + ASSERT_NO_THROW(runtime.regrid()); + ASSERT_GT(runtime.nlev(), 1); + + test::install_prepared_threshold_decisions( + runtime, {{0, 0, Real(1e9), test::PreparedThresholdRelation::Above}}, + {{0, 0, Real(1e9), test::PreparedThresholdRelation::Below}}, + "test::recovery-accepted-restriction@1"); + EXPECT_NO_THROW(runtime.regrid()); + EXPECT_EQ(runtime.nlev(), 1); + EXPECT_EQ(runtime.regrid_count(), 2); + EXPECT_EQ(runtime.block_level_state(0, 0), coarse_before); +} + +TEST(test_amr_transfer_properties, + RegridRecoveryRefusalRollsBackHierarchyStateAndPublicationCounters) { + AmrRuntime runtime = bootstrap_runtime(8, false, 0.5); + const std::vector coarse_before = runtime.block_level_state(0, 0); + const auto boxes_before = runtime.level_state(0, 0).box_array().boxes(); + const std::uint64_t topology_epoch_before = runtime.topology_epoch(); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.5)}}, + "test::recovery-rejected-regrid@1"); + + try { + runtime.regrid(); + FAIL() << "a rejected regrid candidate was published"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find("prepared variable recovery rejected"), + std::string::npos); + } + EXPECT_EQ(runtime.nlev(), 1); + EXPECT_EQ(runtime.regrid_count(), 0); + EXPECT_EQ(runtime.topology_epoch(), topology_epoch_before); + EXPECT_EQ(runtime.level_state(0, 0).box_array().boxes(), boxes_before); + EXPECT_EQ(runtime.block_level_state(0, 0), coarse_before); +} + +TEST(test_amr_transfer_properties, + RestrictionRecoveryRefusalRollsBackEveryLevelAndHierarchyPublication) { + AmrRuntime runtime = bootstrap_runtime(8, false, 1.5); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.5)}}, + "test::restriction-recovery-bootstrap@1"); + ASSERT_NO_THROW(runtime.regrid()); + ASSERT_GT(runtime.nlev(), 1); + for (int level = 1; level < runtime.nlev(); ++level) + runtime.level_state(0, level).set_val(Real(2)); + device_fence(); + + std::vector> states_before; + std::vector> boxes_before; + for (int level = 0; level < runtime.nlev(); ++level) { + states_before.push_back(runtime.block_level_state(0, level)); + boxes_before.push_back(runtime.level_state(0, level).box_array().boxes()); + } + const int levels_before = runtime.nlev(); + const int regrids_before = runtime.regrid_count(); + const std::uint64_t topology_epoch_before = runtime.topology_epoch(); + test::install_prepared_threshold_decisions( + runtime, {{0, 0, Real(1e9), test::PreparedThresholdRelation::Above}}, + {{0, 0, Real(1e9), test::PreparedThresholdRelation::Below}}, + "test::recovery-rejected-restriction@1"); + + try { + runtime.regrid(); + FAIL() << "a rejected restriction candidate was published"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find("prepared variable recovery rejected"), + std::string::npos); + } + EXPECT_EQ(runtime.nlev(), levels_before); + EXPECT_EQ(runtime.regrid_count(), regrids_before); + EXPECT_EQ(runtime.topology_epoch(), topology_epoch_before); + for (int level = 0; level < runtime.nlev(); ++level) { + EXPECT_EQ(runtime.level_state(0, level).box_array().boxes(), boxes_before[level]); + EXPECT_EQ(runtime.block_level_state(0, level), states_before[level]); + } +} + TEST(test_amr_transfer_properties, AnalyticEveryLevelCacheEpochAndL0L1L2Rollback) { AmrRuntime runtime = bootstrap_runtime(); ASSERT_EQ(runtime.nlev(), 1); @@ -582,3 +701,111 @@ TEST(test_amr_transfer_properties, BootstrapMaterializesPreparedBoundarySessionA coarse_rhs.fab(0).const_array()(coarse_rhs.box(0).lo[0], coarse_rhs.box(0).lo[1], 0), kPreparedBoundarySentinel); } + +TEST(test_amr_transfer_properties, BootstrapCommitPublishesRecoveryAcceptedLevels) { + AmrRuntime runtime = bootstrap_runtime(8, false, 5.0); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.5)}}, + "test::bootstrap-recovery-accepted@1"); + runtime.begin_bootstrap_plan(); + ASSERT_TRUE(runtime.bootstrap_next_level(2)); + EXPECT_GT(runtime.fill_bootstrap_block_constant(0, 1, {2.0}), 0); + EXPECT_NO_THROW(runtime.commit_bootstrap_level()); + EXPECT_EQ(runtime.nlev(), 2); +} + +TEST(test_amr_transfer_properties, BootstrapRecoveryRefusalKeepsPendingLevelRollbackable) { + AmrRuntime runtime = bootstrap_runtime(8, false, 5.0); + const std::vector coarse_before = runtime.block_level_state(0, 0); + const std::uint64_t topology_epoch_before = runtime.topology_epoch(); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.5)}}, + "test::bootstrap-recovery-rejected@1"); + runtime.begin_bootstrap_plan(); + ASSERT_TRUE(runtime.bootstrap_next_level(2)); + EXPECT_GT(runtime.fill_bootstrap_block_constant(0, 1, {7.0}), 0); + try { + runtime.commit_bootstrap_level(); + FAIL() << "an inadmissible bootstrap level was committed"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find("prepared variable recovery rejected"), + std::string::npos); + } + runtime.rollback_bootstrap_level(); + EXPECT_EQ(runtime.nlev(), 1); + EXPECT_EQ(runtime.topology_epoch(), topology_epoch_before); + EXPECT_EQ(runtime.block_level_state(0, 0), coarse_before); +} + +TEST(test_amr_transfer_properties, RuntimePreparedSlipWallFillsDeepPhysicalGhosts) { + const Box2D domain = Box2D::from_extents(4, 4); + const BoxArray boxes(std::vector{domain}); + const DistributionMapping distribution(boxes.size(), n_ranks()); + const Geometry geometry{domain, Real(0), Real(1), Real(0), Real(1)}; + const auto load_balance = test::prepare_test_space_filling_curve_load_balance(); + AmrHierarchyLayout hierarchy{{boxes}, {distribution}, {Real(0.25)}, {Real(0.25)}, + {}, load_balance}; + + MultiFab state(boxes, distribution, 5, 2); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(5); + values(i, j, 3) = Real(3); + values(i, j, 4) = Real(4); + }); + } + device_fence(); + auto levels = std::make_shared>(); + levels->push_back(AmrLevelMP{std::move(state), nullptr, Real(0.25), Real(0.25)}); + + AmrRuntimeBlock block; + block.name = "fluid"; + block.state_identity = "case::amr::fluid::state::U"; + block.ncomp = 5; + block.levels = std::move(levels); + block.boundary_plan = std::make_shared( + "case::amr::fluid::boundary", 2, + prepare_hyperbolic_boundary<2>({"slip_wall", "slip_wall", "slip_wall", "slip_wall"}, + std::vector(20, 0.0), + {"case::amr::fluid::xlo", "case::amr::fluid::xhi", + "case::amr::fluid::ylo", "case::amr::fluid::yhi"}, + {"Density", "MomentumX", "MomentumX", "MomentumY", "AxialZ"}), + std::vector{}, block.state_identity); + block.boundary_field_registry = std::make_shared(); + block.level_rhs_core_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& U, const MultiFab&, + const Geometry&, MultiFab& R, const PreparedGridBoundarySession& boundary) { + boundary.fill_same_level_and_physical(U, point); + R.set_val(Real(0)); + }; + block.level_boundary_residual_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint&, MultiFab&, const MultiFab&, + const Geometry&, MultiFab&, const PreparedGridBoundarySession&) {}; + + BCRec poisson_boundary; + poisson_boundary.xlo = poisson_boundary.xhi = BCType::Foextrap; + poisson_boundary.ylo = poisson_boundary.yhi = BCType::Foextrap; + std::vector blocks; + blocks.push_back(std::move(block)); + AmrRuntime runtime(geometry, std::move(hierarchy), poisson_boundary, std::move(blocks), + Periodicity{false, false}, true); + runtime.install_boundary_storage_routes({}); + + MultiFab& live = runtime.level_state(0, 0); + MultiFab rhs(live.box_array(), live.dmap(), live.ncomp(), 0); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.amr-slip", 0, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.0}; + EXPECT_NO_THROW(runtime.level_rhs_into_at(0, 0, point, live, rhs)); + device_fence(); + + if (live.local_size() > 0) { + const ConstArray4 values = live.fab(0).const_array(); + EXPECT_EQ(values(-2, 2, 1), Real(-2)); + EXPECT_EQ(values(-2, 2, 2), Real(-5)); + EXPECT_EQ(values(-2, 2, 4), Real(-4)); + EXPECT_EQ(values(2, -2, 3), Real(-3)); + EXPECT_EQ(values(2, -2, 4), Real(-4)); + } +} diff --git a/tests/cpp/integration/amr/test_cell_temporal_partition_executor.cpp b/tests/cpp/integration/amr/test_cell_temporal_partition_executor.cpp new file mode 100644 index 000000000..42f421c8b --- /dev/null +++ b/tests/cpp/integration/amr/test_cell_temporal_partition_executor.cpp @@ -0,0 +1,524 @@ +#include + +#include +#include +#include +#include + +#include "load_balance_test_authority.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#define POPS_TEST_CELL_TEMPORAL_INLINE KOKKOS_INLINE_FUNCTION +#else +#define POPS_TEST_CELL_TEMPORAL_INLINE inline +#endif + +using namespace pops; +using namespace pops::runtime::program; + +namespace { + +CellTemporalPartitionAcceptedState prepared_state() { + CellTemporalPartitionAcceptedState state; + state.kind = TemporalPartitionKind::CellLocal; + state.provider_identity = "pops.test.temporal-stage-flux@1"; + state.topology_epoch = 17; + state.synchronization_tick = 8; + state.tick_denominator = 32; + state.cells = {{0, 10, 0, 8}, {0, 11, 0, 8}, {0, 12, 1, 8}, + {1, 20, 0, 8}, {1, 21, 1, 8}, {1, 22, 2, 8}}; + return state; +} + +template +using DeviceVector = std::vector>; + +struct StageFluxProbe { + explicit StageFluxProbe(std::size_t cells) + : last_begin(cells, -1), + last_end(cells, -1), + visits(cells, 0), + scratch_flux(cells, 0), + committed_flux(cells, 0) {} + + DeviceVector last_begin; + DeviceVector last_end; + DeviceVector visits; + DeviceVector scratch_flux; + DeviceVector committed_flux; + std::size_t fail_record = std::numeric_limits::max(); + std::int64_t fail_end_tick = -1; + std::uint32_t fail_reason = 0; + bool reject_begin = false; + bool reject_commit = false; + int begins = 0; + int commits = 0; + int rollbacks = 0; +}; + +struct ProbeStageFluxDeviceView { + std::int64_t* last_begin = nullptr; + std::int64_t* last_end = nullptr; + std::uint32_t* visits = nullptr; + std::uint32_t* scratch_flux = nullptr; + std::size_t cell_count = 0; + std::size_t fail_record = std::numeric_limits::max(); + std::int64_t fail_end_tick = -1; + std::uint32_t fail_reason = 0; + + [[nodiscard]] POPS_TEST_CELL_TEMPORAL_INLINE CellTemporalStageOutcome + evaluate_local_stage_and_record_space_time_flux(CellTemporalStagePoint point) const noexcept { + if (point.record_index >= cell_count) + return CellTemporalStageOutcome::failed(9001); + last_begin[point.record_index] = point.begin_tick; + last_end[point.record_index] = point.end_tick; + ++visits[point.record_index]; + if (point.record_index == fail_record && point.end_tick == fail_end_tick) + return CellTemporalStageOutcome::rejected(fail_reason); + ++scratch_flux[point.record_index]; + return CellTemporalStageOutcome::accepted(); + } +}; + +static_assert(CellTemporalStageFluxDeviceView); + +class ProbeStageFluxProvider { + public: + explicit ProbeStageFluxProvider(std::shared_ptr probe) + : probe_(std::move(probe)) {} + + [[nodiscard]] static constexpr PreparedProviderIdentity provider_identity() noexcept { + return {"pops.test.temporal-stage-flux", 1}; + } + [[nodiscard]] static constexpr PreparedCellTemporalStageFluxContractV1 + stage_flux_contract() noexcept { + return {}; + } + void serialize_exact_parameters(ExactContractBuilder& contract) const { + contract.text("probe-stage-flux") + .scalar(std::uint32_t{1}) + .scalar(static_cast(probe_->last_begin.size())) + .scalar(static_cast(probe_->fail_record)) + .scalar(probe_->fail_end_tick) + .scalar(probe_->fail_reason); + } + [[nodiscard]] PreparedProviderSupport begin_attempt( + CellTemporalAttemptDescriptor attempt) noexcept { + ++probe_->begins; + std::fill(probe_->last_begin.begin(), probe_->last_begin.end(), -1); + std::fill(probe_->last_end.begin(), probe_->last_end.end(), -1); + std::fill(probe_->visits.begin(), probe_->visits.end(), 0); + std::fill(probe_->scratch_flux.begin(), probe_->scratch_flux.end(), 0); + if (probe_->reject_begin) + return PreparedProviderSupport::reject(41, "probe rejected attempt preparation"); + if (attempt.topology_epoch != 17 || attempt.begin_tick != 8 || attempt.target_tick <= 8 || + attempt.tick_denominator != 32 || attempt.cell_count != probe_->last_begin.size()) + return PreparedProviderSupport::reject(42, "probe received the wrong attempt authority"); + return PreparedProviderSupport::accept(); + } + [[nodiscard]] PreparedProviderSupport prepare_commit_attempt() noexcept { + if (probe_->reject_commit) + return PreparedProviderSupport::reject(43, "probe rejected accepted publication"); + return PreparedProviderSupport::accept(); + } + void commit_attempt() noexcept { + ++probe_->commits; + std::copy(probe_->scratch_flux.begin(), probe_->scratch_flux.end(), + probe_->committed_flux.begin()); + } + void rollback_attempt() noexcept { + ++probe_->rollbacks; + std::fill(probe_->scratch_flux.begin(), probe_->scratch_flux.end(), 0); + } + [[nodiscard]] ProbeStageFluxDeviceView device_view() const noexcept { + return {probe_->last_begin.data(), probe_->last_end.data(), probe_->visits.data(), + probe_->scratch_flux.data(), probe_->last_begin.size(), probe_->fail_record, + probe_->fail_end_tick, probe_->fail_reason}; + } + + private: + std::shared_ptr probe_; +}; + +static_assert(CellTemporalStageFluxProvider); + +struct LinearTransportModel { + using State = StateVec<1>; + using Prim = State; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + Real velocity_x = Real(0.7); + Real velocity_y = Real(-0.2); + + POPS_HD State flux(const State& state, const auto&, int axis) const { + return State{(axis == 0 ? velocity_x : velocity_y) * state[0]}; + } + POPS_HD Real max_wave_speed(const State&, const auto&, int axis) const { + const Real velocity = axis == 0 ? velocity_x : velocity_y; + return velocity < Real(0) ? -velocity : velocity; + } + POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } + POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } + POPS_HD Prim to_primitive(const State& state) const { return state; } + POPS_HD State to_conservative(const Prim& primitive) const { return primitive; } + + [[nodiscard]] static constexpr PreparedProviderIdentity + transport_model_provider_identity() noexcept { + return {"pops.test.linear-transport-model", 1}; + } + void serialize_exact_transport_parameters(ExactContractBuilder& contract) const { + contract.scalar(velocity_x).scalar(velocity_y); + } + static VariableSet conservative_vars() { + return {VariableKind::Conservative, {"u"}, 1, {VariableRole::Scalar}}; + } + static VariableSet primitive_vars() { + return {VariableKind::Primitive, {"u"}, 1, {VariableRole::Scalar}}; + } +}; + +static_assert(PhysicalModel); +static_assert(detail::ExactAmrTransportModelProvider); + +std::unique_ptr make_linear_transport_runtime() { + constexpr int n = 4; + AmrBuildParams build; + build.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + build.mesh.n = n; + build.mesh.L = 1.0; + build.mesh.periodicity = Periodicity{true, true}; + build.mesh.regrid_every = 0; + build.poisson.bc = BCRec{}; + detail::SharedAmrLayout layout = detail::make_shared_amr_layout_levels(build, 1); + std::vector initial(static_cast(n) * n); + for (int j = 0; j < n; ++j) + for (int i = 0; i < n; ++i) + initial[static_cast(j) * n + i] = + Real(1) + Real(0.05) * static_cast(i + 2 * j); + std::vector blocks; + blocks.push_back(detail::build_amr_block( + LinearTransportModel{}, layout, "tracer", initial, true, 1.4, 1, false)); + blocks.back().state_identity = "test://cell-temporal/tracer/U"; + return std::make_unique(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, + std::move(blocks), layout.base_per, layout.replicated_coarse, + layout.wall); +} + +std::shared_ptr make_scientific_flux_ledger( + AmrRuntime& runtime, const CellTemporalPartitionAcceptedState& partition) { + return std::make_shared( + runtime.topology_epoch(), runtime.topology_materialization_generation(), 0, 0, + partition.cells.size(), runtime.level_state(0, 0).ncomp()); +} + +} // namespace + +TEST(test_cell_temporal_partition_executor, + executes_bounded_rung_batches_and_commits_exact_local_clocks) { + const CellTemporalPartitionAcceptedState accepted = prepared_state(); + const auto probe = std::make_shared(accepted.cells.size()); + PreparedBatchedCellTemporalExecutor executor{accepted, ProbeStageFluxProvider(probe)}; + + ASSERT_EQ(executor.prepared_rung_count(), 3u); + EXPECT_EQ(executor.provider_identity(), accepted.provider_identity); + EXPECT_FALSE(executor.exact_contract().empty()); + const AllocationEventStats allocations_before = allocation_event_stats(); + + executor.begin_attempt(16); + executor.advance_to_barrier(); + EXPECT_TRUE(executor.attempt_active()); + executor.commit(); + + EXPECT_EQ(allocation_event_stats(), allocations_before) + << "the prepared attempt and rung loop must not allocate PoPS storage"; + EXPECT_FALSE(executor.attempt_active()); + EXPECT_EQ(probe->begins, 1); + EXPECT_EQ(probe->commits, 1); + EXPECT_EQ(probe->rollbacks, 0); + const CellTemporalPartitionAcceptedState committed = executor.checkpoint(); + EXPECT_EQ(committed.synchronization_tick, 16); + for (const CellTemporalPartitionRecord& cell : committed.cells) + EXPECT_EQ(cell.accepted_tick, 16); + + EXPECT_EQ(executor.stats().rung_batch_launches, 14u); + EXPECT_EQ(executor.stats().stage_evaluations, 34u); + const std::vector expected_visits{8, 8, 4, 8, 4, 2}; + const std::vector expected_last_begin{15, 15, 14, 15, 14, 12}; + for (std::size_t index = 0; index < accepted.cells.size(); ++index) { + EXPECT_EQ(probe->visits[index], expected_visits[index]); + EXPECT_EQ(probe->committed_flux[index], expected_visits[index]); + EXPECT_EQ(probe->last_begin[index], expected_last_begin[index]); + EXPECT_EQ(probe->last_end[index], 16); + } +} + +TEST(test_cell_temporal_partition_executor, + stage_rejection_rolls_back_clocks_and_attempt_local_flux_ledger) { + const CellTemporalPartitionAcceptedState accepted = prepared_state(); + const auto probe = std::make_shared(accepted.cells.size()); + probe->fail_record = 2; + probe->fail_end_tick = 10; + probe->fail_reason = 73; + PreparedBatchedCellTemporalExecutor executor{accepted, ProbeStageFluxProvider(probe)}; + + executor.begin_attempt(16); + try { + executor.advance_to_barrier(); + FAIL() << "a rejected local stage advanced the accepted clock"; + } catch (const CellTemporalStageFailure& failure) { + EXPECT_EQ(failure.disposition(), CellTemporalStageDisposition::Rejected); + EXPECT_EQ(failure.reason_code(), 73u); + } + + EXPECT_FALSE(executor.attempt_active()); + EXPECT_EQ(executor.checkpoint(), accepted); + EXPECT_EQ(probe->begins, 1); + EXPECT_EQ(probe->commits, 0); + EXPECT_EQ(probe->rollbacks, 1); + EXPECT_TRUE(std::all_of(probe->scratch_flux.begin(), probe->scratch_flux.end(), + [](std::uint32_t value) { return value == 0; })); + EXPECT_TRUE(std::all_of(probe->committed_flux.begin(), probe->committed_flux.end(), + [](std::uint32_t value) { return value == 0; })); + EXPECT_EQ(executor.stats().rung_batch_launches, 3u) + << "the executor batches every same-rung cell into one launch"; + EXPECT_EQ(executor.stats().stage_evaluations, 8u); +} + +TEST(test_cell_temporal_partition_executor, + provider_preparation_and_identity_fail_closed_before_any_accepted_mutation) { + const CellTemporalPartitionAcceptedState accepted = prepared_state(); + const auto rejected_probe = std::make_shared(accepted.cells.size()); + rejected_probe->reject_begin = true; + PreparedBatchedCellTemporalExecutor rejected{accepted, ProbeStageFluxProvider(rejected_probe)}; + EXPECT_THROW(rejected.begin_attempt(16), std::runtime_error); + EXPECT_EQ(rejected.checkpoint(), accepted); + EXPECT_EQ(rejected_probe->begins, 1); + EXPECT_EQ(rejected_probe->commits, 0); + EXPECT_EQ(rejected_probe->rollbacks, 1); + + CellTemporalPartitionAcceptedState wrong_identity = accepted; + wrong_identity.provider_identity = "pops.test.different-temporal-stage-flux@1"; + const auto wrong_probe = std::make_shared(accepted.cells.size()); + EXPECT_THROW( + (PreparedBatchedCellTemporalExecutor(wrong_identity, ProbeStageFluxProvider(wrong_probe))), + std::logic_error); + EXPECT_EQ(wrong_probe->begins, 0); + EXPECT_EQ(wrong_probe->commits, 0); + EXPECT_EQ(wrong_probe->rollbacks, 0); +} + +TEST(test_cell_temporal_partition_executor, + provider_commit_preflight_rolls_back_clocks_and_attempt_local_ledger) { + const CellTemporalPartitionAcceptedState accepted = prepared_state(); + const auto probe = std::make_shared(accepted.cells.size()); + PreparedBatchedCellTemporalExecutor executor{accepted, ProbeStageFluxProvider(probe)}; + + executor.begin_attempt(16); + executor.advance_to_barrier(); + probe->reject_commit = true; + EXPECT_THROW(executor.commit(), std::runtime_error); + + EXPECT_FALSE(executor.attempt_active()); + EXPECT_EQ(executor.checkpoint(), accepted); + EXPECT_EQ(probe->commits, 0); + EXPECT_EQ(probe->rollbacks, 1); + EXPECT_TRUE(std::all_of(probe->committed_flux.begin(), probe->committed_flux.end(), + [](std::uint32_t value) { return value == 0; })); +} + +TEST(test_cell_temporal_partition_executor, + production_same_level_provider_commits_real_state_and_integrated_face_fluxes) { + auto runtime = make_linear_transport_runtime(); + constexpr Real seconds_per_tick = Real(0.01); + const CellTemporalPartitionAcceptedState partition = + prepare_same_level_transport_euler_partition(*runtime, 0, 100, 0); + auto ledger = make_scientific_flux_ledger(*runtime, partition); + + MultiFab expected = runtime->level_state(0, 0); + MultiFab residual(expected.box_array(), expected.dmap(), expected.ncomp(), 0); + MultiFab flux_x(same_level_cell_temporal_detail::face_boxes(expected.box_array(), true), + expected.dmap(), expected.ncomp(), 0); + MultiFab flux_y(same_level_cell_temporal_detail::face_boxes(expected.box_array(), false), + expected.dmap(), expected.ncomp(), 0); + runtime::multiblock::BoundaryEvaluationPoint point; + point.clock = "test.clock.cell-local"; + point.tick = 0; + point.level = 0; + point.substep = 0; + point.stage = 0; + point.stage_fraction = amr::Rational(0, 1); + point.dt = seconds_per_tick; + point.physical_time = 0.0; + runtime->level_neg_div_flux_capture_into(0, 0, point, expected, residual, flux_x, flux_y); + lincomb(expected, Real(1), expected, seconds_per_tick, residual); + device_fence(); + + PreparedSameLevelTransportEulerStageFluxProvider provider(*runtime, partition, ledger, + "test.clock.cell-local"); + PreparedBatchedCellTemporalExecutor executor{partition, std::move(provider)}; + EXPECT_NE(executor.exact_contract().find("pops.amr.compiled-transport-flux"), std::string::npos); + const std::string initial_contract = executor.exact_contract(); + executor.begin_attempt(1); + executor.advance_to_barrier(); + executor.commit(); + device_fence(); + + const MultiFab& actual = runtime->level_state(0, 0); + const ConstArray4 want = expected.fab(0).const_array(); + const ConstArray4 got = actual.fab(0).const_array(); + const ConstArray4 fx = flux_x.fab(0).const_array(); + const ConstArray4 fy = flux_y.fab(0).const_array(); + const Box2D box = actual.box(0); + std::size_t linear = 0; + for (int j = box.lo[1]; j <= box.hi[1]; ++j) + for (int i = box.lo[0]; i <= box.hi[0]; ++i, ++linear) { + EXPECT_DOUBLE_EQ(got(i, j), want(i, j)); + EXPECT_DOUBLE_EQ(ledger->integrated_flux(linear, SameLevelCellFace::XLow, 0), + seconds_per_tick * fx(i, j)); + EXPECT_DOUBLE_EQ(ledger->integrated_flux(linear, SameLevelCellFace::XHigh, 0), + seconds_per_tick * fx(i + 1, j)); + EXPECT_DOUBLE_EQ(ledger->integrated_flux(linear, SameLevelCellFace::YLow, 0), + seconds_per_tick * fy(i, j)); + EXPECT_DOUBLE_EQ(ledger->integrated_flux(linear, SameLevelCellFace::YHigh, 0), + seconds_per_tick * fy(i, j + 1)); + } + EXPECT_EQ(ledger->publication_generation(), 1u); + EXPECT_EQ(ledger->begin_tick(), 0); + EXPECT_EQ(ledger->end_tick(), 1); + EXPECT_EQ(ledger->tick_denominator(), 100); + EXPECT_EQ(executor.checkpoint().synchronization_tick, 1); + EXPECT_NE(executor.exact_contract(), initial_contract); + + const SameLevelCellIntegratedFluxLedgerAcceptedState first_accepted_flux = + ledger->accepted_state(); + const CellTemporalPartitionAcceptedState first_accepted_partition = executor.checkpoint(); + AmrRuntime::StepSnapshot first_accepted_state; + runtime->capture_step_snapshot(first_accepted_state); + const std::vector after_first_commit = runtime->density(0); + executor.begin_attempt(2); + executor.advance_to_barrier(); + executor.commit(); + EXPECT_NE(runtime->density(0), after_first_commit); + EXPECT_EQ(ledger->publication_generation(), 2u); + EXPECT_EQ(ledger->begin_tick(), 1); + EXPECT_EQ(ledger->end_tick(), 2); + EXPECT_EQ(executor.checkpoint().synchronization_tick, 2); + + runtime->restore_step_snapshot(first_accepted_state); + executor.restore_accepted_boundary(first_accepted_partition); + ledger->restore_accepted_state(first_accepted_flux); + EXPECT_EQ(ledger->publication_generation(), 1u); + EXPECT_EQ(ledger->begin_tick(), 0); + EXPECT_EQ(ledger->end_tick(), 1); + EXPECT_EQ(ledger->tick_denominator(), 100); + for (int component = 0; component < ledger->component_count(); ++component) + EXPECT_DOUBLE_EQ( + ledger->integrated_flux(0, SameLevelCellFace::XLow, component), + first_accepted_flux.integrated_flux[SameLevelCellIntegratedFluxLedger::storage_offset( + 0, SameLevelCellFace::XLow, component, ledger->component_count())]); + + executor.begin_attempt(2); + executor.advance_to_barrier(); + executor.commit(); + EXPECT_EQ(executor.checkpoint().synchronization_tick, 2); + EXPECT_EQ(ledger->publication_generation(), 2u); +} + +TEST(test_cell_temporal_partition_executor, + production_same_level_provider_rolls_back_and_refuses_unproved_envelopes) { + auto runtime = make_linear_transport_runtime(); + const std::vector accepted_state = runtime->density(0); + const CellTemporalPartitionAcceptedState partition = + prepare_same_level_transport_euler_partition(*runtime, 0, 100, 0); + auto ledger = make_scientific_flux_ledger(*runtime, partition); + + PreparedSameLevelTransportEulerStageFluxProvider exact_time_provider(*runtime, partition, ledger, + "test.clock.cell-local"); + const PreparedProviderSupport prepared = exact_time_provider.begin_attempt( + {partition.topology_epoch, 0, 1, 100, partition.cells.size()}); + ASSERT_TRUE(prepared.accepted()); + exact_time_provider.begin_rung_batch({0, 0, 1, 100, partition.cells.size()}); + const CellTemporalStageOutcome wrong_time = + exact_time_provider.device_view().evaluate_local_stage_and_record_space_time_flux( + {0, 0, 0, 0, 0, 1, 99}); + EXPECT_EQ(wrong_time.disposition, CellTemporalStageDisposition::Failed); + EXPECT_EQ(wrong_time.reason_code, 0x756001u); + exact_time_provider.rollback_attempt(); + EXPECT_EQ(ledger->publication_generation(), 0u); + + PreparedSameLevelTransportEulerStageFluxProvider provider(*runtime, partition, ledger, + "test.clock.cell-local"); + PreparedBatchedCellTemporalExecutor executor{partition, std::move(provider)}; + executor.begin_attempt(1); + executor.advance_to_barrier(); + executor.rollback(); + EXPECT_EQ(runtime->density(0), accepted_state); + EXPECT_EQ(ledger->publication_generation(), 0u); + + CellTemporalPartitionAcceptedState mixed_rungs = partition; + mixed_rungs.cells.back().rung = 1; + auto mixed_ledger = make_scientific_flux_ledger(*runtime, mixed_rungs); + EXPECT_THROW((PreparedSameLevelTransportEulerStageFluxProvider( + *runtime, mixed_rungs, mixed_ledger, "test.clock.cell-local")), + std::invalid_argument); + + auto stale_ledger = make_scientific_flux_ledger(*runtime, partition); + PreparedSameLevelTransportEulerStageFluxProvider stale_provider(*runtime, partition, stale_ledger, + "test.clock.cell-local"); + PreparedBatchedCellTemporalExecutor stale_executor{partition, std::move(stale_provider)}; + runtime->restore_checkpoint_counters(runtime->regrid_count(), runtime->topology_epoch() + 1); + EXPECT_THROW(stale_executor.begin_attempt(1), std::runtime_error); + EXPECT_EQ(runtime->density(0), accepted_state); + EXPECT_EQ(stale_ledger->publication_generation(), 0u); +} + +TEST(test_cell_temporal_partition_executor, + production_provider_refuses_restart_rematerialization_between_barrier_and_commit) { + auto runtime = make_linear_transport_runtime(); + const std::vector accepted_state = runtime->density(0); + const CellTemporalPartitionAcceptedState partition = + prepare_same_level_transport_euler_partition(*runtime, 0, 100, 0); + auto stale_ledger = make_scientific_flux_ledger(*runtime, partition); + const std::uint64_t accepted_epoch = runtime->topology_epoch(); + const std::uint64_t accepted_generation = runtime->topology_materialization_generation(); + + PreparedSameLevelTransportEulerStageFluxProvider stale_provider( + *runtime, partition, stale_ledger, "test.clock.cell-local"); + PreparedBatchedCellTemporalExecutor stale_executor{partition, std::move(stale_provider)}; + stale_executor.begin_attempt(1); + stale_executor.advance_to_barrier(); + + runtime->rebuild_hierarchy({{}}, {{}}); + runtime->restore_checkpoint_counters(runtime->regrid_count(), accepted_epoch); + ASSERT_GT(runtime->topology_materialization_generation(), accepted_generation) + << "a same-topology restart still rematerializes address-bound provider storage"; + EXPECT_THROW(stale_executor.commit(), std::runtime_error); + EXPECT_FALSE(stale_executor.attempt_active()); + EXPECT_EQ(stale_executor.checkpoint(), partition); + EXPECT_EQ(stale_ledger->publication_generation(), 0u); + EXPECT_EQ(runtime->density(0), accepted_state); + + auto retry_ledger = make_scientific_flux_ledger(*runtime, partition); + PreparedSameLevelTransportEulerStageFluxProvider retry_provider( + *runtime, partition, retry_ledger, "test.clock.cell-local"); + PreparedBatchedCellTemporalExecutor retry{partition, std::move(retry_provider)}; + retry.begin_attempt(1); + retry.advance_to_barrier(); + EXPECT_NO_THROW(retry.commit()); + EXPECT_EQ(retry_ledger->publication_generation(), 1u); + EXPECT_EQ(retry.checkpoint().synchronization_tick, 1); +} + +#undef POPS_TEST_CELL_TEMPORAL_INLINE diff --git a/tests/cpp/integration/amr/test_cell_temporal_program_route.cpp b/tests/cpp/integration/amr/test_cell_temporal_program_route.cpp new file mode 100644 index 000000000..46fa08628 --- /dev/null +++ b/tests/cpp/integration/amr/test_cell_temporal_program_route.cpp @@ -0,0 +1,205 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +using namespace pops; +using namespace pops::runtime::program; + +namespace { + +struct LinearTransportModel { + using State = StateVec<1>; + using Prim = State; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + Real velocity_x = Real(0.7); + Real velocity_y = Real(-0.2); + + POPS_HD State flux(const State& state, const auto&, int axis) const { + return State{(axis == 0 ? velocity_x : velocity_y) * state[0]}; + } + POPS_HD Real max_wave_speed(const State&, const auto&, int axis) const { + const Real velocity = axis == 0 ? velocity_x : velocity_y; + return velocity < Real(0) ? -velocity : velocity; + } + POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } + POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } + POPS_HD Prim to_primitive(const State& state) const { return state; } + POPS_HD State to_conservative(const Prim& primitive) const { return primitive; } + + [[nodiscard]] static constexpr PreparedProviderIdentity + transport_model_provider_identity() noexcept { + return {"pops.test.program-cell-local-transport", 1}; + } + void serialize_exact_transport_parameters(ExactContractBuilder& contract) const { + contract.scalar(velocity_x).scalar(velocity_y); + } + static VariableSet conservative_vars() { + return {VariableKind::Conservative, {"u"}, 1, {VariableRole::Scalar}}; + } + static VariableSet primitive_vars() { + return {VariableKind::Primitive, {"u"}, 1, {VariableRole::Scalar}}; + } +}; + +static_assert(PhysicalModel); +static_assert(detail::ExactAmrTransportModelProvider); + +std::vector initial_state(int n) { + std::vector state(static_cast(n) * static_cast(n)); + for (int j = 0; j < n; ++j) + for (int i = 0; i < n; ++i) { + const double x = (static_cast(i) + 0.5) / static_cast(n); + const double y = (static_cast(j) + 0.5) / static_cast(n); + state[static_cast(j) * static_cast(n) + + static_cast(i)] = + 1.0 + 0.1 * std::sin(2.0 * std::numbers::pi * x) * std::cos(2.0 * std::numbers::pi * y); + } + return state; +} + +std::shared_ptr install_cell_local_program(AmrSystem& system) { + system.install_program_step([](double) {}); + if (!system.uses_runtime_engine() || system.engine() == nullptr) + throw std::runtime_error("cell-local Program test requires a materialized AMR runtime"); + auto context = std::make_shared(system.engine(), &system); + context->configure_primary_clock("test.clock.cell-local"); + context->prepare_same_level_cell_temporal_execution("test.clock.cell-local", 100, 0); + context->install([context](double dt) { context->advance_same_level_cell_temporal(dt); }, + context); + system.set_program_block_map({0}); + return context; +} + +double state_sum(AmrSystem& system) { + const std::vector values = system.density("tracer"); + return std::accumulate(values.begin(), values.end(), 0.0); +} + +} // namespace + +TEST(test_cell_temporal_program_route, + installed_program_commits_exact_ticks_state_and_conservative_face_ledger) { +#if defined(POPS_HAS_KOKKOS) + int argc = 0; + char** argv = nullptr; + Kokkos::ScopeGuard guard(argc, argv); +#endif + constexpr int n = 8; + AmrSystemConfig config; + config.n = n; + config.L = 1.0; + config.level_count = 1; + config.regrid_every = 0; + config.periodicity = {true, true}; + + AmrSystem system(config); + add_compiled_model(system, "tracer", LinearTransportModel{}, "none", "rusanov", "conservative", + "euler"); + system.set_density("tracer", initial_state(n)); + const auto context = install_cell_local_program(system); + const double sum_before = state_sum(system); + const std::vector state_before = system.density("tracer"); + + system.step(0.01); + + EXPECT_NE(system.density("tracer"), state_before); + EXPECT_NEAR(state_sum(system), sum_before, + 64.0 * std::numeric_limits::epsilon() * std::abs(sum_before)); + const auto manifest = system.program_temporal_partition_manifest(); + ASSERT_FALSE(manifest.empty()); + EXPECT_EQ(manifest.front()[1], "cell_local"); + EXPECT_EQ(manifest.front()[2], kSameLevelTransportEulerStageFluxProvider); + EXPECT_EQ(manifest.front()[4], "1"); + EXPECT_EQ(manifest.front()[5], "100"); + + const SameLevelCellIntegratedFluxLedger& ledger = context->accepted_same_level_cell_flux_ledger(); + EXPECT_EQ(ledger.begin_tick(), 0); + EXPECT_EQ(ledger.end_tick(), 1); + EXPECT_EQ(ledger.publication_generation(), 1u); + for (int j = 0; j < n; ++j) + for (int i = 0; i < n; ++i) { + const std::size_t cell = static_cast(j * n + i); + const std::size_t right = static_cast(j * n + (i + 1) % n); + const std::size_t upper = static_cast(((j + 1) % n) * n + i); + EXPECT_DOUBLE_EQ(ledger.integrated_flux(cell, SameLevelCellFace::XHigh, 0), + ledger.integrated_flux(right, SameLevelCellFace::XLow, 0)); + EXPECT_DOUBLE_EQ(ledger.integrated_flux(cell, SameLevelCellFace::YHigh, 0), + ledger.integrated_flux(upper, SameLevelCellFace::YLow, 0)); + } +} + +TEST(test_cell_temporal_program_route, + invalid_tick_outer_rollback_and_same_topology_restart_remain_atomic) { +#if defined(POPS_HAS_KOKKOS) + int argc = 0; + char** argv = nullptr; + Kokkos::ScopeGuard guard(argc, argv); +#endif + constexpr int n = 8; + AmrSystemConfig config; + config.n = n; + config.L = 1.0; + config.level_count = 1; + config.regrid_every = 0; + config.periodicity = {true, true}; + + AmrSystem system(config); + add_compiled_model(system, "tracer", LinearTransportModel{}, "none", "rusanov", "conservative", + "euler"); + system.set_density("tracer", initial_state(n)); + const auto context = install_cell_local_program(system); + system.step(0.01); + + const std::vector accepted_state = system.density("tracer"); + const std::vector accepted_bytes = system.program_accepted_state(); + const double accepted_time = system.time(); + const int accepted_step = system.macro_step(); + const auto accepted_ledger = context->accepted_same_level_cell_flux_ledger().accepted_state(); + + EXPECT_THROW(system.step(0.015), std::invalid_argument); + EXPECT_EQ(system.density("tracer"), accepted_state); + EXPECT_EQ(system.program_accepted_state(), accepted_bytes); + EXPECT_DOUBLE_EQ(system.time(), accepted_time); + EXPECT_EQ(system.macro_step(), accepted_step); + EXPECT_EQ(context->accepted_same_level_cell_flux_ledger().publication_generation(), + accepted_ledger.publication_generation); + + system.begin_step_transaction(); + system.step(0.01); + system.rollback_step_transaction(); + EXPECT_EQ(system.density("tracer"), accepted_state); + EXPECT_EQ(system.program_accepted_state(), accepted_bytes); + EXPECT_DOUBLE_EQ(system.time(), accepted_time); + EXPECT_EQ(system.macro_step(), accepted_step); + EXPECT_THROW(context->accepted_same_level_cell_flux_ledger(), std::logic_error); + + system.step(0.01); + EXPECT_EQ(context->accepted_same_level_cell_flux_ledger().publication_generation(), 1u); + const std::vector restart_bytes = system.program_accepted_state(); + system.begin_restart_transaction(); + system.restore_checkpoint_accepted_state(restart_bytes); + system.commit_restart_transaction(); + EXPECT_THROW(context->accepted_same_level_cell_flux_ledger(), std::logic_error); + EXPECT_NO_THROW(system.step(0.01)); + EXPECT_EQ(context->accepted_same_level_cell_flux_ledger().publication_generation(), 1u); +} diff --git a/tests/cpp/integration/amr/test_program_reflux_ledger.cpp b/tests/cpp/integration/amr/test_program_reflux_ledger.cpp index d5d9a916a..0bc26754e 100644 --- a/tests/cpp/integration/amr/test_program_reflux_ledger.cpp +++ b/tests/cpp/integration/amr/test_program_reflux_ledger.cpp @@ -115,6 +115,110 @@ TEST(test_program_reflux_ledger, prepared_transition_does_not_skip_right_or_top_ << "every rejected preflight leaves the parent untouched"; } +TEST(test_program_reflux_ledger, prepared_local_kernel_routes_exact_face_corrections) { + const Box2D coarse_domain{{0, 0}, {7, 7}}; + const BoxArray coarse_boxes(std::vector{coarse_domain}); + const BoxArray fine_boxes(std::vector{Box2D{{4, 4}, {11, 11}}}); + const DistributionMapping coarse_mapping(coarse_boxes.size(), n_ranks()); + const DistributionMapping fine_mapping(fine_boxes.size(), n_ranks()); + AmrLevelMP coarse{MultiFab(coarse_boxes, coarse_mapping, 1, 0), nullptr, Real(0.5), Real(0.5)}; + AmrLevelMP fine{MultiFab(fine_boxes, fine_mapping, 1, 0), nullptr, Real(0.25), Real(0.25)}; + coarse.U.set_val(Real(0)); + fine.U.set_val(Real(0)); + + int calls = 0; + const auto kernel = [&calls](const PreparedAmrRefluxLocalRequest& request) { + ++calls; + EXPECT_EQ(*request.transition_identity, "pops://test/reflux"); + EXPECT_EQ(*request.patch_identity, "pops://test/reflux/patch=0"); + EXPECT_EQ(request.parent_level, 0); + EXPECT_EQ(request.child_level, 1); + EXPECT_EQ(request.global_child, 0u); + EXPECT_EQ(request.logical_time, (amr::ClockStamp{0, 3, amr::Rational(0, 1), 0.3})); + const auto x_size = + static_cast(request.correction.J1 - request.correction.J0 + 1) * + static_cast(request.correction.components); + const auto y_size = + static_cast(request.correction.I1 - request.correction.I0 + 1) * + static_cast(request.correction.components); + std::fill_n(request.correction.x_low, x_size, Real(3)); + std::fill_n(request.correction.x_high, x_size, Real(4)); + std::fill_n(request.correction.y_low, y_size, Real(5)); + std::fill_n(request.correction.y_high, y_size, Real(6)); + }; + auto transition = PreparedAmrProgramRefluxTransition::prepare_with_local_kernel( + coarse, fine, coarse_domain, Periodicity{false, false}, 0, "pops://test/reflux", kernel, + world_communicator_view()); + + EdgeStrip coarse_role = make_strip(2, 5, 2, 5, 1); + EdgeStrip fine_role = make_strip(2, 5, 2, 5, 1); + coarse_role.cL.assign(4, Real(1)); + coarse_role.cR.assign(4, Real(1)); + coarse_role.cB.assign(4, Real(1)); + coarse_role.cT.assign(4, Real(1)); + fine_role.fL.assign(4, Real(2)); + fine_role.fR.assign(4, Real(2)); + fine_role.fB.assign(4, Real(2)); + fine_role.fT.assign(4, Real(2)); + const amr::ClockStamp logical_time{0, 3, amr::Rational(0, 1), 0.3}; + transition.synchronize_integrated( + coarse.U, coarse.dx, coarse.dy, std::vector{coarse_role}, + std::vector{fine_role}, world_communicator_view(), &logical_time); + + EXPECT_EQ(calls, 1); + ASSERT_EQ(coarse.U.local_size(), 1); + EXPECT_EQ(coarse.U.fab(0)(1, 3, 0), Real(3)); + EXPECT_EQ(coarse.U.fab(0)(6, 3, 0), Real(4)); + EXPECT_EQ(coarse.U.fab(0)(3, 1, 0), Real(5)); + EXPECT_EQ(coarse.U.fab(0)(3, 6, 0), Real(6)); + EXPECT_EQ(coarse.U.fab(0)(2, 2, 0), Real(0)) + << "covered parent cells remain outside the sparse correction"; +} + +TEST(test_program_reflux_ledger, prepared_local_kernel_rejects_unwritten_output_atomically) { + const Box2D coarse_domain{{0, 0}, {7, 7}}; + const BoxArray coarse_boxes(std::vector{coarse_domain}); + const BoxArray fine_boxes(std::vector{Box2D{{4, 4}, {11, 11}}}); + const DistributionMapping coarse_mapping(coarse_boxes.size(), n_ranks()); + const DistributionMapping fine_mapping(fine_boxes.size(), n_ranks()); + AmrLevelMP coarse{MultiFab(coarse_boxes, coarse_mapping, 1, 0), nullptr, Real(0.5), Real(0.5)}; + AmrLevelMP fine{MultiFab(fine_boxes, fine_mapping, 1, 0), nullptr, Real(0.25), Real(0.25)}; + coarse.U.set_val(Real(0)); + fine.U.set_val(Real(0)); + + const auto incomplete = [](const PreparedAmrRefluxLocalRequest& request) { + const auto x_size = + static_cast(request.correction.J1 - request.correction.J0 + 1) * + static_cast(request.correction.components); + std::fill_n(request.correction.x_low, x_size, Real(1)); + }; + auto transition = PreparedAmrProgramRefluxTransition::prepare_with_local_kernel( + coarse, fine, coarse_domain, Periodicity{false, false}, 0, "pops://test/reflux", incomplete, + world_communicator_view()); + + EdgeStrip coarse_role = make_strip(2, 5, 2, 5, 1); + EdgeStrip fine_role = make_strip(2, 5, 2, 5, 1); + coarse_role.cL.assign(4, Real(1)); + coarse_role.cR.assign(4, Real(1)); + coarse_role.cB.assign(4, Real(1)); + coarse_role.cT.assign(4, Real(1)); + fine_role.fL.assign(4, Real(2)); + fine_role.fR.assign(4, Real(2)); + fine_role.fB.assign(4, Real(2)); + fine_role.fT.assign(4, Real(2)); + const amr::ClockStamp logical_time{0, 3, amr::Rational(0, 1), 0.3}; + EXPECT_THROW(transition.synchronize_integrated( + coarse.U, coarse.dx, coarse.dy, std::vector{coarse_role}, + std::vector{fine_role}, world_communicator_view(), &logical_time), + std::runtime_error); + + ASSERT_EQ(coarse.U.local_size(), 1); + EXPECT_EQ(coarse.U.fab(0)(1, 3, 0), Real(0)); + EXPECT_EQ(coarse.U.fab(0)(6, 3, 0), Real(0)); + EXPECT_EQ(coarse.U.fab(0)(3, 1, 0), Real(0)); + EXPECT_EQ(coarse.U.fab(0)(3, 6, 0), Real(0)); +} + TEST(test_program_reflux_ledger, edge_flux_axpy_rejects_shifted_equal_width_footprints) { EdgeFlux destination; destination.fine.push_back(make_strip(2, 5, 2, 5, 1)); diff --git a/tests/cpp/integration/amr/test_temporal_partition_restart.cpp b/tests/cpp/integration/amr/test_temporal_partition_restart.cpp new file mode 100644 index 000000000..54986050e --- /dev/null +++ b/tests/cpp/integration/amr/test_temporal_partition_restart.cpp @@ -0,0 +1,211 @@ +#include + +#include "explicit_amr_program.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +using namespace pops; +using namespace pops::runtime::program; + +namespace { + +CellTemporalPartitionAcceptedState cell_local_state(std::uint64_t topology_epoch = 7) { + CellTemporalPartitionAcceptedState state; + state.kind = TemporalPartitionKind::CellLocal; + state.provider_identity = "test.temporal-partition.batched-cells@1"; + state.topology_epoch = topology_epoch; + state.synchronization_tick = 8; + state.tick_denominator = 16; + state.cells = {{0, 10, 0, 8}, {0, 11, 1, 8}, {1, 20, 2, 8}}; + return state; +} + +CellTemporalPartitionAcceptedState single_level_cell_local_state(std::uint64_t topology_epoch) { + CellTemporalPartitionAcceptedState state = cell_local_state(topology_epoch); + for (CellTemporalPartitionRecord& cell : state.cells) + cell.level = 0; + return state; +} + +ModelSpec exb_spec() { + ModelSpec spec; + spec.transport = "exb"; + spec.source = "none"; + spec.elliptic = "charge"; + return spec; +} + +} // namespace + +TEST(test_temporal_partition_restart, batched_attempt_commit_and_rollback_are_exact) { + const CellTemporalPartitionAcceptedState accepted = cell_local_state(); + BatchedCellTemporalPartition partition(accepted); + + partition.begin_attempt(16); + partition.advance_batch(0, {0}, 12); + partition.advance_batch(0, {0}, 16); + EXPECT_THROW(partition.require_barrier("field solve"), std::logic_error); + partition.rollback(); + EXPECT_EQ(partition.checkpoint(), accepted); + + partition.begin_attempt(16); + partition.advance_batch(0, {0}, 16); + partition.advance_batch(1, {1}, 16); + partition.advance_batch(2, {2}, 16); + EXPECT_NO_THROW(partition.require_barrier("output")); + partition.commit(); + + const CellTemporalPartitionAcceptedState committed = partition.checkpoint(); + EXPECT_EQ(committed.synchronization_tick, 16); + for (const CellTemporalPartitionRecord& cell : committed.cells) + EXPECT_EQ(cell.accepted_tick, 16); + const auto manifest = partition.manifest(); + ASSERT_EQ(manifest.size(), 4u); + EXPECT_EQ(manifest[0][1], "cell_local"); + EXPECT_EQ(manifest[0][6], "3"); + EXPECT_EQ(manifest[1], (std::vector{"rung", "0", "1"})); + EXPECT_EQ(manifest[2], (std::vector{"rung", "1", "1"})); + EXPECT_EQ(manifest[3], (std::vector{"rung", "2", "1"})); +} + +TEST(test_temporal_partition_restart, malformed_state_and_batches_fail_before_mutation) { + const CellTemporalPartitionAcceptedState accepted = cell_local_state(); + BatchedCellTemporalPartition partition(accepted); + + CellTemporalPartitionAcceptedState unsynchronized = accepted; + unsynchronized.cells[1].accepted_tick = 6; + EXPECT_THROW(partition.restore(unsynchronized), std::invalid_argument); + EXPECT_EQ(partition.checkpoint(), accepted); + + partition.begin_attempt(16); + EXPECT_THROW(partition.advance_batch(0, {0, 0}, 12), std::invalid_argument); + EXPECT_THROW(partition.advance_batch(0, {1}, 12), std::invalid_argument); + EXPECT_THROW(partition.advance_batch(2, {2}, 10), std::invalid_argument); + partition.rollback(); + EXPECT_EQ(partition.checkpoint(), accepted); + + EXPECT_THROW(partition.require_global_execution_route(), std::logic_error); + EXPECT_THROW(partition.require_prepared_execution_route("test.temporal-partition.other@1"), + std::logic_error); + EXPECT_NO_THROW( + partition.require_prepared_execution_route("test.temporal-partition.batched-cells@1")); + EXPECT_NO_THROW(BatchedCellTemporalPartition().require_global_execution_route()); +} + +TEST(test_temporal_partition_restart, accepted_image_round_trips_canonically) { + AmrProgramAcceptedState accepted; + accepted.temporal_partition = cell_local_state(); + + const std::vector encoded = serialize_amr_program_accepted_state(accepted); + const AmrProgramAcceptedState decoded = deserialize_amr_program_accepted_state(encoded); + EXPECT_EQ(decoded.temporal_partition, accepted.temporal_partition); + EXPECT_EQ(serialize_amr_program_accepted_state(decoded), encoded); + + CellTemporalPartitionAcceptedState duplicate = accepted.temporal_partition; + duplicate.cells[1].cell = duplicate.cells[0].cell; + accepted.temporal_partition = duplicate; + EXPECT_THROW(serialize_amr_program_accepted_state(accepted), std::invalid_argument); +} + +TEST(test_temporal_partition_restart, + regrid_restart_refuses_cell_local_partition_before_topology_mutation) { + const CellTemporalPartitionAcceptedState cell_local = cell_local_state(); + try { + require_regrid_rematerializable_temporal_partition(cell_local); + FAIL() << "cell-local restart regrid requires unavailable provider rematerialization"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find("stage provider and integrated flux ledger"), + std::string::npos); + } + + CellTemporalPartitionAcceptedState global; + global.kind = TemporalPartitionKind::Global; + global.provider_identity = "pops.temporal-partition.global@1"; + global.tick_denominator = 1; + EXPECT_NO_THROW(require_regrid_rematerializable_temporal_partition(global)); +} + +TEST(test_temporal_partition_restart, legacy_image_without_temporal_authority_is_refused) { + std::vector legacy = {'P', 'O', 'P', 'S', 'A', 'S', 'T', '4'}; + legacy.resize(17 * sizeof(std::uint64_t), 0); + + try { + static_cast(deserialize_amr_program_accepted_state(legacy)); + FAIL() << "accepted-state v4 silently invents a global temporal partition"; + } catch (const std::runtime_error& error) { + EXPECT_STREQ(error.what(), + "invalid AMR Program accepted-state payload: unsupported magic/version"); + } +} + +TEST(test_temporal_partition_restart, + strict_amr_restore_consumes_manifest_and_refuses_global_step_bypass) { +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard; +#endif + AmrSystemConfig config; + config.n = 4; + config.L = 1.0; + config.regrid_every = 0; + config.periodicity = {true, true}; + + AmrSystem system(config); + system.add_block("tracer", exb_spec(), "none", "rusanov", "conservative", "explicit", 1); + test::install_forward_euler_program(system); + system.step(0.01); + ASSERT_EQ(system.engine()->nlev(), 1); + + AmrProgramAcceptedState accepted = + deserialize_amr_program_accepted_state(system.program_accepted_state()); + accepted.temporal_partition = single_level_cell_local_state(system.engine()->topology_epoch()); + const std::vector cell_local_image = serialize_amr_program_accepted_state(accepted); + system.restore_checkpoint_accepted_state(cell_local_image); + + const auto manifest = system.program_temporal_partition_manifest(); + ASSERT_EQ(manifest.size(), 4u); + EXPECT_EQ(manifest[0][1], "cell_local"); + EXPECT_EQ(manifest[0][2], "test.temporal-partition.batched-cells@1"); + + const double time_before = system.time(); + const int step_before = system.macro_step(); + const std::vector bytes_before = system.program_accepted_state(); + try { + system.step(0.01); + FAIL() << "an authenticated cell-local schedule degraded to the global AMR driver"; + } catch (const std::logic_error& error) { + EXPECT_NE(std::string(error.what()).find("local-stage and time-integrated flux-ledger"), + std::string::npos); + } + EXPECT_DOUBLE_EQ(system.time(), time_before); + EXPECT_EQ(system.macro_step(), step_before); + EXPECT_EQ(system.program_accepted_state(), bytes_before); + EXPECT_EQ(system.program_temporal_partition_manifest(), manifest); + + AmrProgramAcceptedState wrong_topology = accepted; + ++wrong_topology.temporal_partition.topology_epoch; + EXPECT_THROW(system.restore_checkpoint_accepted_state( + serialize_amr_program_accepted_state(wrong_topology)), + std::runtime_error); + EXPECT_EQ(system.program_accepted_state(), bytes_before) + << "rejected restore must not replace the accepted image"; + + AmrProgramAcceptedState wrong_level = accepted; + wrong_level.temporal_partition.cells.back().level = system.engine()->nlev(); + EXPECT_THROW( + system.restore_checkpoint_accepted_state(serialize_amr_program_accepted_state(wrong_level)), + std::runtime_error); + EXPECT_EQ(system.program_accepted_state(), bytes_before) + << "an inactive-level partition must not replace the accepted image"; +} diff --git a/tests/cpp/integration/mpi/test_mpi_amr_distributed_coarse.cpp b/tests/cpp/integration/mpi/test_mpi_amr_distributed_coarse.cpp index 4af988742..978113157 100644 --- a/tests/cpp/integration/mpi/test_mpi_amr_distributed_coarse.cpp +++ b/tests/cpp/integration/mpi/test_mpi_amr_distributed_coarse.cpp @@ -40,6 +40,7 @@ #include "amr_tagging_test_authority.hpp" #include +#include #include #include @@ -302,7 +303,10 @@ static Result run(int n, int nsteps, double dt, bool distribute) { // contract of level_{state,potential}_global(0). R.state = sys.level_state_global(0); R.output_local_pieces = sys.output_state_local_pieces("gas", 0); - R.output_root_pieces = sys.output_state_root_pieces(WorldCommunicator::world(), "gas", 0); + auto output_lane = + ObserverMpiLane::duplicate_world_collectively("test/amr-distributed-coarse/root-output"); + R.output_root_pieces = sys.output_state_root_pieces(output_lane, "gas", 0); + output_lane.close_collectively(); R.phi = sys.potential(); R.phi_global = sys.level_potential_global(0); R.mass = sys.mass(); diff --git a/tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp b/tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp index 79c2e8309..319523608 100644 --- a/tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp +++ b/tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp @@ -17,6 +17,9 @@ #include #include +#include +#include +#include #include #if defined(POPS_HAS_KOKKOS) @@ -27,6 +30,21 @@ using namespace pops; namespace { +RecoveryReport scalar_recovery(const double* conserved, double* primitive, bool reject) { + RecoveryReport report; + if (reject || !std::isfinite(conserved[0])) { + report.status = RecoveryStatus::kRejected; + report.cause = + reject ? RecoveryCause::kInadmissibleCandidate : RecoveryCause::kNonFiniteCandidate; + report.failing_component = 0; + return report; + } + primitive[0] = conserved[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; +} + int run_dynamic_active_depth(int n, int me, int np) { const Geometry geometry{Box2D::from_extents(n, n), Real(0), Real(1), Real(0), Real(1)}; const BoxArray coarse_boxes = BoxArray::from_domain(geometry.domain, n / 2); @@ -64,10 +82,14 @@ int run_dynamic_active_depth(int n, int me, int np) { const auto load_balance = test::prepare_test_space_filling_curve_load_balance(); AmrHierarchyLayout hierarchy = AmrHierarchyLayout::from_levels(*levels, load_balance); + auto reject_rank_zero_candidate = std::make_shared(false); AmrRuntimeBlock block; block.name = "moving"; block.state_identity = "test://mpi-active-depth/block/moving/state/U"; + block.cons_to_prim = [reject_rank_zero_candidate](const double* conserved, double* primitive) { + return scalar_recovery(conserved, primitive, *reject_rank_zero_candidate && my_rank() == 0); + }; block.levels = levels; block.add_elliptic_rhs = [](const MultiFab&, MultiFab&) {}; block.max_speed = [](const MultiFab&, const MultiFab&) { return Real(0); }; @@ -99,6 +121,19 @@ int run_dynamic_active_depth(int n, int me, int np) { runtime, {{0, 0, Real(1e9), test::PreparedThresholdRelation::Above}}, {{0, 0, Real(1e9), test::PreparedThresholdRelation::Below}}, "test::mpi-active-depth-coarsen@1"); + *reject_rank_zero_candidate = true; + bool restriction_rejected = false; + try { + runtime.regrid(); + } catch (const std::runtime_error& error) { + restriction_rejected = + std::string(error.what()).find("prepared variable recovery rejected") != std::string::npos; + } + const bool restriction_refusal_collective = + all_reduce_sum(restriction_rejected ? 1L : 0L) == n_ranks(); + const bool restriction_rolled_back = runtime.nlev() == 3 && runtime.regrid_count() == 0 && + std::fabs(runtime.mass(0) - initial_mass) < 1e-10; + *reject_rank_zero_candidate = false; runtime.regrid(); const bool removed = runtime.nlev() == 1 && runtime.max_levels() == 3 && runtime.n_patches() == 0; const double removed_mass = runtime.mass(0); @@ -109,6 +144,19 @@ int run_dynamic_active_depth(int n, int me, int np) { runtime, {{0, 0, Real(1.05), test::PreparedThresholdRelation::Above}}, {{0, 0, Real(1.05), test::PreparedThresholdRelation::Below}}, "test::mpi-active-depth-regrow@1"); + *reject_rank_zero_candidate = true; + bool prolongation_rejected = false; + try { + runtime.regrid(); + } catch (const std::runtime_error& error) { + prolongation_rejected = + std::string(error.what()).find("prepared variable recovery rejected") != std::string::npos; + } + const bool prolongation_refusal_collective = + all_reduce_sum(prolongation_rejected ? 1L : 0L) == n_ranks(); + const bool prolongation_rolled_back = runtime.nlev() == 1 && runtime.regrid_count() == 1 && + std::fabs(runtime.mass(0) - removed_mass) < 1e-10; + *reject_rank_zero_candidate = false; runtime.regrid(); const bool regrown = runtime.nlev() == 3 && runtime.max_levels() == 3 && runtime.n_patches() > 0; const double regrown_mass = runtime.mass(0); @@ -120,15 +168,21 @@ int run_dynamic_active_depth(int n, int me, int np) { std::fmax(spread(removed_mass), spread(regrown_mass)))); const bool conserved = std::fabs(removed_mass - initial_mass) < 1e-10 && std::fabs(regrown_mass - initial_mass) < 1e-10; - const long local_failure = removed && regrown && conserved && cross_rank_spread == 0.0 ? 0L : 1L; + const long local_failure = removed && regrown && conserved && restriction_refusal_collective && + restriction_rolled_back && prolongation_refusal_collective && + prolongation_rolled_back && cross_rank_spread == 0.0 + ? 0L + : 1L; const long failure = all_reduce_max(local_failure); if (me == 0) { std::printf( - "AMRDEPTH np=%d | removed=%d regrown=%d | active=%d configured=%d patches=%d | " - "dm_remove=%.3e dm_regrow=%.3e spread=%.3e\n", - np, removed ? 1 : 0, regrown ? 1 : 0, runtime.nlev(), runtime.max_levels(), - runtime.n_patches(), std::fabs(removed_mass - initial_mass), + "AMRDEPTH np=%d | removed=%d regrown=%d | recovery_restrict=%d recovery_prolong=%d | " + "active=%d configured=%d patches=%d | dm_remove=%.3e dm_regrow=%.3e spread=%.3e\n", + np, removed ? 1 : 0, regrown ? 1 : 0, + restriction_refusal_collective && restriction_rolled_back ? 1 : 0, + prolongation_refusal_collective && prolongation_rolled_back ? 1 : 0, runtime.nlev(), + runtime.max_levels(), runtime.n_patches(), std::fabs(removed_mass - initial_mass), std::fabs(regrown_mass - initial_mass), cross_rank_spread); } return failure == 0 ? 0 : 1; diff --git a/tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp b/tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp new file mode 100644 index 000000000..69c474b64 --- /dev/null +++ b/tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp @@ -0,0 +1,286 @@ +// Distributed qualification of the sole prepared transport-boundary authority at a moving AMR +// hierarchy. +// +// A real regrid first removes and then recreates the fine level. The recreated patch remains +// strictly inside the physical domain, so every uncovered fine ghost is a coarse/fine interface +// ghost, never a physical-boundary ghost. The persistent PreparedGridBoundarySession must execute +// the conservative coarse/fine producer before same-level/MPI and physical-face production. A +// large fixed physical value makes any accidental patch-edge-as-domain-edge routing immediately +// visible. + +#include + +#include "amr_tagging_test_authority.hpp" +#include "amr_transfer_test_authority.hpp" +#include "gtest_compat.hpp" +#include "load_balance_test_authority.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +using namespace pops; + +namespace { + +RecoveryReport accept_scalar_recovery(const double* conserved, double* primitive) { + RecoveryReport report; + if (!std::isfinite(conserved[0])) { + report.status = RecoveryStatus::kRejected; + report.cause = RecoveryCause::kNonFiniteCandidate; + report.failing_component = 0; + return report; + } + primitive[0] = conserved[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; +} + +constexpr Real kCoarseValue = Real(7.25); +constexpr Real kFineValue = Real(3.0); +constexpr Real kPhysicalValue = Real(40.0); +constexpr Real kUntouchedGhost = Real(-901.0); +constexpr Real kExpectedPhysicalGhost = Real(2) * kPhysicalValue - kCoarseValue; + +bool covered_by(const BoxArray& boxes, int i, int j) { + return std::any_of(boxes.boxes().begin(), boxes.boxes().end(), + [=](const Box2D& box) { return box.contains(i, j); }); +} + +POPS_HD Real native_exp(Real value) { +#if defined(POPS_HAS_KOKKOS) + return Kokkos::exp(value); +#else + return std::exp(value); +#endif +} + +void seed_centered_refinement_marker(MultiFab& state, int resolution) { + state.set_val(Real(1)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + const Box2D valid = state.box(local); + for_each_cell(valid, [=] POPS_HD(int i, int j) { + const Real x = (Real(i) + Real(0.5)) / Real(resolution); + const Real y = (Real(j) + Real(0.5)) / Real(resolution); + const Real r2 = (x - Real(0.5)) * (x - Real(0.5)) + (y - Real(0.5)) * (y - Real(0.5)); + values(i, j, 0) = Real(1) + Real(0.8) * native_exp(-r2 / Real(0.0064)); + }); + } + device_fence(); +} + +int run_prepared_boundary_cf_regrid(int me, int np) { + constexpr int n = 16; + const Geometry geometry{Box2D::from_extents(n, n), Real(0), Real(1), Real(0), Real(1)}; + const BoxArray coarse_boxes = BoxArray::from_domain(geometry.domain, n / 2); + const Box2D initial_fine_patch = Box2D{{4, 4}, {11, 11}}.refine(2); + + auto levels = std::make_shared>(); + levels->push_back( + AmrLevelMP{MultiFab(coarse_boxes, DistributionMapping(coarse_boxes.size(), n_ranks()), 1, 1), + nullptr, geometry.dx(), geometry.dy()}); + levels->push_back( + AmrLevelMP{MultiFab(BoxArray({initial_fine_patch}), DistributionMapping({0}), 1, 1), nullptr, + geometry.dx() / Real(2), geometry.dy() / Real(2)}); + + MultiFab& coarse_seed = levels->front().U; + seed_centered_refinement_marker(coarse_seed, n); + levels->back().U.set_val(Real(1)); + + const auto load_balance = test::prepare_test_space_filling_curve_load_balance(); + AmrHierarchyLayout hierarchy = AmrHierarchyLayout::from_levels(*levels, load_balance); + + const std::string state_identity = "test://adc749/mpi-amr/block/tracer/state/U"; + AmrRuntimeBlock block; + block.name = "tracer"; + block.state_identity = state_identity; + block.ncomp = 1; + block.cons_to_prim = accept_scalar_recovery; + block.levels = levels; + block.add_elliptic_rhs = [](const MultiFab&, MultiFab&) {}; + block.max_speed = [](const MultiFab&, const MultiFab&) { return Real(0); }; + block.boundary_plan = std::make_shared( + "test://adc749/mpi-amr/block/tracer/boundary", 1, + prepare_hyperbolic_boundary<2>({"dirichlet", "dirichlet", "dirichlet", "dirichlet"}, + std::vector(4, static_cast(kPhysicalValue)), + {"test://adc749/mpi-amr/xlo", "test://adc749/mpi-amr/xhi", + "test://adc749/mpi-amr/ylo", "test://adc749/mpi-amr/yhi"}, + {"Scalar"}), + std::vector{}, state_identity); + block.boundary_field_registry = std::make_shared(); + block.level_rhs_core_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint& point, MultiFab& state, + const MultiFab&, const Geometry&, MultiFab& residual, + const PreparedGridBoundarySession& boundary) { + boundary.fill(state, point); + residual.set_val(Real(0)); + }; + block.level_boundary_residual_at_point_prepared = + [](const runtime::multiblock::BoundaryEvaluationPoint&, MultiFab&, const MultiFab&, + const Geometry&, MultiFab&, const PreparedGridBoundarySession&) {}; + block.level_rhs_at_point = [](const runtime::multiblock::BoundaryEvaluationPoint&, MultiFab&, + const MultiFab&, const Geometry&, MultiFab&) { + throw std::runtime_error("legacy AMR boundary fallback was selected"); + }; + + BCRec poisson_boundary; + poisson_boundary.xlo = poisson_boundary.xhi = BCType::Foextrap; + poisson_boundary.ylo = poisson_boundary.yhi = BCType::Foextrap; + std::vector blocks; + blocks.push_back(std::move(block)); + AmrRuntime runtime(geometry, std::move(hierarchy), poisson_boundary, std::move(blocks), + Periodicity{false, false}, /*replicated_coarse=*/false); + test::install_second_order_amr_transfer_authorities(runtime, 1); + runtime.set_parent_child_temporal_relations({amr::ParentChildClockRelation( + 0, 1, amr::Rational(2, 1), amr::RemainderPolicy::IntegralOnly)}); + runtime.install_boundary_storage_routes({}); + + // Exercise both topology transitions. Boundary sessions and coarse/fine workspaces must be + // destroyed with the removed level and rematerialized for the new distributed fine layout. + test::install_prepared_threshold_decisions( + runtime, {{0, 0, Real(1e9), test::PreparedThresholdRelation::Above}}, + {{0, 0, Real(1e9), test::PreparedThresholdRelation::Below}}, "test::adc749::remove-fine@1"); + runtime.regrid(); + const bool removed = runtime.nlev() == 1; + + // Fine-to-coarse removal legitimately averages the former fine state into the parent. Re-seed + // the coarse tagging field so this fixture requests a second, independent topology transition. + seed_centered_refinement_marker(runtime.level_state(0, 0), n); + test::install_prepared_threshold_decisions( + runtime, {{0, 0, Real(1.05), test::PreparedThresholdRelation::Above}}, + {{0, 0, Real(1.05), test::PreparedThresholdRelation::Below}}, "test::adc749::regrow-fine@1"); + runtime.regrid(); + const bool regrown = runtime.nlev() == 2 && runtime.n_patches() > 0; + + // Do not enter a boundary fill collectively unless every rank published both topology + // transitions. A broken regrid consensus must fail this fixture, never strand only a subset of + // ranks inside the MPI halo exchange exercised below. + const bool topology_ready = all_reduce_max(removed && regrown ? 0L : 1L) == 0L; + long local_failures = topology_ready ? 0L : 1L; + long local_cf_ghosts = 0; + long local_fine_physical_touches = 0; + long local_physical_face_ghosts = 0; + if (topology_ready) { + MultiFab& coarse = runtime.level_state(0, 0); + MultiFab& fine = runtime.level_state(0, 1); + coarse.set_val(kCoarseValue); + fine.set_val(kUntouchedGhost); + for (int local = 0; local < fine.local_size(); ++local) { + const Array4 values = fine.fab(local).array(); + for_each_cell(fine.box(local), [=] POPS_HD(int i, int j) { values(i, j, 0) = kFineValue; }); + } + device_fence(); + + MultiFab residual(fine.box_array(), fine.dmap(), fine.ncomp(), 0); + const runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.adc749-amr-boundary", 0, 1, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + runtime.level_rhs_into_at(0, 1, point, fine, residual); + device_fence(); + fine.sync_host(); + + const Box2D fine_domain = runtime.level_geom(1).domain; + const BoxArray& valid_boxes = fine.box_array(); + for (const Box2D& box : valid_boxes.boxes()) + if (box.lo[0] == fine_domain.lo[0] || box.hi[0] == fine_domain.hi[0] || + box.lo[1] == fine_domain.lo[1] || box.hi[1] == fine_domain.hi[1]) + ++local_fine_physical_touches; + + for (int local = 0; local < fine.local_size(); ++local) { + const Fab2D& values = fine.fab(local); + const Box2D grown = values.grown_box(); + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) { + if (!fine_domain.contains(i, j) || covered_by(valid_boxes, i, j)) + continue; + ++local_cf_ghosts; + if (std::fabs(values(i, j, 0) - kCoarseValue) > Real(1e-12)) + ++local_failures; + } + } + + // The same rematerialized plan must still own actual base-domain faces. This companion check + // prevents an inert boundary plan from making the internal-interface assertion vacuous. + MultiFab coarse_residual(coarse.box_array(), coarse.dmap(), coarse.ncomp(), 0); + const runtime::multiblock::BoundaryEvaluationPoint coarse_point{ + "clock.adc749-amr-boundary", 0, 0, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + runtime.level_rhs_into_at(0, 0, coarse_point, coarse, coarse_residual); + device_fence(); + coarse.sync_host(); + const Box2D coarse_domain = runtime.level_geom(0).domain; + for (int local = 0; local < coarse.local_size(); ++local) { + const Fab2D& values = coarse.fab(local); + const Box2D valid = coarse.box(local); + auto check = [&](int i, int j) { + ++local_physical_face_ghosts; + if (std::fabs(values(i, j, 0) - kExpectedPhysicalGhost) > Real(1e-12)) + ++local_failures; + }; + if (valid.lo[0] == coarse_domain.lo[0]) + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + check(coarse_domain.lo[0] - 1, j); + if (valid.hi[0] == coarse_domain.hi[0]) + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + check(coarse_domain.hi[0] + 1, j); + if (valid.lo[1] == coarse_domain.lo[1]) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) + check(i, coarse_domain.lo[1] - 1); + if (valid.hi[1] == coarse_domain.hi[1]) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) + check(i, coarse_domain.hi[1] + 1); + } + } + + const long failures = all_reduce_sum(local_failures); + const long cf_ghosts = all_reduce_sum(local_cf_ghosts); + const long fine_physical_touches = all_reduce_max(local_fine_physical_touches); + const long physical_face_ghosts = all_reduce_sum(local_physical_face_ghosts); + const double patch_spread = all_reduce_max(static_cast(runtime.n_patches())) - + (-all_reduce_max(-static_cast(runtime.n_patches()))); + const bool qualified = topology_ready && failures == 0 && cf_ghosts > 0 && + fine_physical_touches == 0 && physical_face_ghosts > 0 && + patch_spread == 0.0 && runtime.regrid_count() == 2; + + if (me == 0) + std::printf( + "ADC749_BOUNDARY_CF np=%d | removed=%d regrown=%d regrids=%d patches=%d | " + "cf_ghosts=%ld fine_physical_touches=%ld physical_face_ghosts=%ld failures=%ld " + "spread=%.1f\n", + np, removed ? 1 : 0, regrown ? 1 : 0, runtime.regrid_count(), runtime.n_patches(), + cf_ghosts, fine_physical_touches, physical_face_ghosts, failures, patch_spread); + return qualified ? 0 : 1; +} + +int pops_run_test_mpi_amr_prepared_boundary_cf(int argc, char** argv) { + comm_init(&argc, &argv); +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard(argc, argv); +#else + (void)argc; + (void)argv; +#endif + const int result = run_prepared_boundary_cf_regrid(my_rank(), n_ranks()); + comm_finalize(); + return result; +} + +} // namespace + +TEST(test_mpi_amr_prepared_boundary_cf, Runs) { + EXPECT_EQ(pops::test::RunTestBody(&pops_run_test_mpi_amr_prepared_boundary_cf, + "test_mpi_amr_prepared_boundary_cf"), + 0); +} diff --git a/tests/cpp/integration/mpi/test_mpi_amr_rebalance_migration.cpp b/tests/cpp/integration/mpi/test_mpi_amr_rebalance_migration.cpp new file mode 100644 index 000000000..a97806cad --- /dev/null +++ b/tests/cpp/integration/mpi/test_mpi_amr_rebalance_migration.cpp @@ -0,0 +1,269 @@ +// Accepted-boundary AMR owner migration: a collective RebalanceDecision redistributes one live +// fine level without changing its scientific boxes, clocks, values or regrid counter. The Program +// context must rematerialize topology-qualified history/flux authority and stale or malformed +// decisions must fail before any accepted byte changes. + +#include + +#include "amr_tagging_test_authority.hpp" +#include "explicit_amr_program.hpp" +#include "gtest_compat.hpp" +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +using namespace pops; +using namespace pops::runtime::program; + +namespace { + +ModelSpec exb_spec() { + ModelSpec spec; + spec.transport = "exb"; + spec.source = "none"; + spec.elliptic = "charge"; + spec.q = 0.0; + spec.B0 = 1.0; + return spec; +} + +std::vector uniform_estimates(const AmrRuntime& runtime, int level) { + std::vector estimates(runtime.level_owner_ranks(level).size()); + for (ResourceEstimate& estimate : estimates) { + estimate.topology_epoch = runtime.topology_epoch(); + estimate.materialization_generation = runtime.topology_materialization_generation(); + estimate.samples = 1; + estimate.cell_updates = 1; + estimate.compute_nanoseconds = 1000; + estimate.memory_bytes = 64; + estimate.resident_bytes = 64; + } + return estimates; +} + +RebalancePolicy migration_policy() { + RebalancePolicy policy; + policy.minimum_improvement_ppm = 0; + policy.amortization_steps = 100; + policy.migration_bandwidth_bytes_per_second = 1'000'000'000'000LL; + policy.per_patch_migration_latency_nanoseconds = 0; + return policy; +} + +AmrProgramRankOwnership ownership_snapshot(const AmrRuntime& runtime) { + AmrProgramRankOwnership ownership; + ownership.rank_count = n_ranks(); + ownership.level_patch_owners.reserve(static_cast(runtime.nlev())); + for (int level = 0; level < runtime.nlev(); ++level) + ownership.level_patch_owners.push_back(runtime.level_owner_ranks(level)); + return ownership; +} + +std::vector> gather_program_payloads( + const std::vector& local) { + std::string payload; + payload.reserve(local.size()); + for (const std::uint8_t byte : local) + payload.push_back(static_cast(byte)); + const std::vector gathered = WorldCommunicator::world().allgather_bytes(payload); + std::vector> result; + result.reserve(gathered.size()); + for (const std::string& rank_payload : gathered) { + std::vector bytes; + bytes.reserve(rank_payload.size()); + for (const char byte : rank_payload) + bytes.push_back(static_cast(byte)); + result.push_back(std::move(bytes)); + } + return result; +} + +int run_mpi_amr_rebalance_migration(int argc, char** argv) { + comm_init(&argc, &argv); +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard(argc, argv); +#endif + const int rank = my_rank(); + const int ranks = n_ranks(); + long failures = 0; + if (ranks < 2) { + if (rank == 0) + std::printf("FAIL test_mpi_amr_rebalance_migration requires at least two ranks\n"); + comm_finalize(); + return 1; + } + + AmrSystemConfig config; + config.n = 8; + config.L = 1.0; + config.level_count = 2; + config.regrid_every = 0; + config.periodicity = {true, true}; + AmrSystem system(config); + system.set_temporal_relations({2}, {1}, {"integral_only"}); + system.add_block("tracer", exb_spec(), "none", "rusanov", "conservative", "explicit", 1); + system.set_poisson("charge_density", "geometric_mg", "periodic"); + std::vector density(static_cast(config.n * config.n), 1.0); + for (int j = 0; j < config.n; ++j) + for (int i = 0; i < config.n; ++i) + density[static_cast(j * config.n + i)] += + 0.1 * std::sin(2.0 * 3.14159265358979323846 * (i + 0.5) / config.n); + system.set_density("tracer", density); + test::install_prepared_threshold_union(system, {{"tracer", "n", 1.0e29}}); + const std::vector fine_boxes{ + {1, 4, 4, 7, 7}, {1, 8, 4, 11, 7}, {1, 4, 8, 7, 11}, {1, 8, 8, 11, 11}}; + const auto context = test::install_forward_euler_program_context(system, [&](AmrSystem& built) { + built.rebuild_hierarchy(fine_boxes, std::vector(fine_boxes.size(), 0)); + }); + system.step(1.0e-3); + + AmrRuntime& runtime = *system.engine(); + if (runtime.nlev() != 2) { + ++failures; + } else { + constexpr int fine_level = 1; + const std::vector state_before = system.block_level_state_global("tracer", fine_level); + const std::uint64_t program_revision_before = system.program_accepted_state_revision(); + const double time_before = system.time(); + const int step_before = system.macro_step(); + const int regrid_before = runtime.regrid_count(); + const std::uint64_t epoch_before = runtime.topology_epoch(); + const std::uint64_t generation_before = runtime.topology_materialization_generation(); + + const AmrProgramAcceptedState accepted_before = + deserialize_amr_program_accepted_state(system.program_accepted_state()); + failures += accepted_before.accepted_flux_ledger.empty(); + failures += accepted_before.accepted_sync.empty(); + + RebalanceDecision decision = runtime.decide_rebalance( + fine_level, uniform_estimates(runtime, fine_level), migration_policy()); + failures += !decision.accepted || decision.reason != RebalanceReason::NetBenefit; + const std::vector proposed = decision.proposed_mapping.ranks(); + const AmrProgramRankOwnership source_ownership = ownership_snapshot(runtime); + AmrProgramRankOwnership target_ownership = source_ownership; + target_ownership.level_patch_owners[static_cast(fine_level)] = proposed; + AmrProgramAcceptedState expected_state = + deserialize_amr_program_accepted_state(rematerialize_amr_program_accepted_state_bytes( + gather_program_payloads(system.program_accepted_state()), source_ownership, + target_ownership, rank)); + expected_state.accepted_flux_ledger.clear(); + expected_state.accepted_interface_flux_ledger.clear(); + expected_state.accepted_sync.clear(); + const std::vector expected_program = + serialize_amr_program_accepted_state(expected_state); + bool applied = false; + try { + applied = context->apply_rebalance_decision(fine_level, decision); + } catch (const std::exception& error) { + if (rank == 0) + std::printf("rebalance migration threw: %s\n", error.what()); + ++failures; + } + failures += !applied; + failures += runtime.level_owner_ranks(fine_level) != proposed; + failures += runtime.topology_epoch() != epoch_before + 1; + failures += runtime.topology_materialization_generation() <= generation_before; + failures += runtime.regrid_count() != regrid_before; + failures += system.time() != time_before || system.macro_step() != step_before; + failures += system.block_level_state_global("tracer", fine_level) != state_before; + failures += context->history_flux_topology_epoch() != runtime.topology_epoch(); + failures += system.program_accepted_state() != expected_program; + + const AmrProgramAcceptedState migrated = + deserialize_amr_program_accepted_state(system.program_accepted_state()); + failures += migrated.level_clocks.size() != 2; + failures += !migrated.accepted_flux_ledger.empty(); + failures += !migrated.accepted_interface_flux_ledger.empty(); + failures += !migrated.accepted_sync.empty(); + failures += system.program_accepted_state_revision() != program_revision_before + 1; + + const std::vector stable_program = system.program_accepted_state(); + const std::uint64_t stable_program_revision = system.program_accepted_state_revision(); + const std::uint64_t stable_epoch = runtime.topology_epoch(); + const std::uint64_t stable_generation = runtime.topology_materialization_generation(); + const std::vector stable_owners = runtime.level_owner_ranks(fine_level); + bool stale_rejected = false; + try { + static_cast(context->apply_rebalance_decision(fine_level, decision)); + } catch (const std::invalid_argument&) { + stale_rejected = true; + } + failures += !stale_rejected; + failures += runtime.topology_epoch() != stable_epoch; + failures += runtime.topology_materialization_generation() != stable_generation; + failures += runtime.level_owner_ranks(fine_level) != stable_owners; + failures += system.program_accepted_state() != stable_program; + failures += system.program_accepted_state_revision() != stable_program_revision; + + RebalanceDecision malformed = runtime.decide_rebalance( + fine_level, uniform_estimates(runtime, fine_level), migration_policy()); + malformed.source_contract.push_back('x'); + malformed.exact_contract = pops::detail::exact_rebalance_decision(malformed); + bool malformed_rejected = false; + try { + static_cast(context->apply_rebalance_decision(fine_level, malformed)); + } catch (const std::invalid_argument&) { + malformed_rejected = true; + } + failures += !malformed_rejected; + failures += runtime.topology_epoch() != stable_epoch; + failures += runtime.topology_materialization_generation() != stable_generation; + failures += system.program_accepted_state() != stable_program; + failures += system.program_accepted_state_revision() != stable_program_revision; + + const RebalanceDecision refusal = runtime.decide_rebalance( + fine_level, uniform_estimates(runtime, fine_level), migration_policy()); + failures += refusal.accepted || refusal.reason != RebalanceReason::MappingUnchanged; + try { + failures += context->apply_rebalance_decision(fine_level, refusal); + } catch (const std::exception& error) { + if (rank == 0) + std::printf("unchanged rebalance refusal threw: %s\n", error.what()); + ++failures; + } + failures += runtime.topology_epoch() != stable_epoch; + failures += runtime.topology_materialization_generation() != stable_generation; + failures += system.program_accepted_state() != stable_program; + failures += system.program_accepted_state_revision() != stable_program_revision; + + try { + system.step(1.0e-3); + } catch (const std::exception& error) { + if (rank == 0) + std::printf("post-rebalance step threw: %s\n", error.what()); + ++failures; + } + failures += !(system.time() > time_before) || system.macro_step() <= step_before; + const std::vector resumed_state = system.block_level_state_global("tracer", fine_level); + for (const double value : resumed_state) + failures += !std::isfinite(value); + } + + failures = all_reduce_sum(failures); + if (rank == 0) + std::printf("%s test_mpi_amr_rebalance_migration (np=%d)\n", failures == 0 ? "OK" : "FAIL", + ranks); + comm_finalize(); + return failures == 0 ? 0 : 1; +} + +} // namespace + +TEST(test_mpi_amr_rebalance_migration, Runs) { + EXPECT_EQ( + pops::test::RunTestBody(&run_mpi_amr_rebalance_migration, "test_mpi_amr_rebalance_migration"), + 0); +} diff --git a/tests/cpp/integration/mpi/test_mpi_cell_temporal_program_refusal.cpp b/tests/cpp/integration/mpi/test_mpi_cell_temporal_program_refusal.cpp new file mode 100644 index 000000000..e7293ff33 --- /dev/null +++ b/tests/cpp/integration/mpi/test_mpi_cell_temporal_program_refusal.cpp @@ -0,0 +1,74 @@ +#include + +#include "gtest_compat.hpp" +#include "test_harness.hpp" + +#include +#include +#include +#include + +#include +#include + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +using namespace pops; + +namespace { + +int run_collective_refusal() { + AmrSystemConfig config; + config.n = 4; + config.L = 1.0; + config.level_count = 1; + config.regrid_every = 0; + config.periodicity = {true, true}; + + ModelSpec model; + model.transport = "exb"; + model.source = "none"; + model.elliptic = "charge"; + + AmrSystem system(config); + system.add_block("tracer", model, "none", "rusanov", "conservative", "euler"); + system.install_program_step([](double) {}); + if (!system.uses_runtime_engine() || system.engine() == nullptr) + return 1; + auto context = std::make_shared(system.engine(), &system); + context->configure_primary_clock("test.clock.cell-local-mpi-refusal"); + + bool refused = false; + try { + context->prepare_same_level_cell_temporal_execution("test.clock.cell-local-mpi-refusal", 100, + 0); + } catch (const std::runtime_error& error) { + refused = std::string(error.what()).find("no MPI-safe multi-box") != std::string::npos; + } + const long refusing_ranks = all_reduce_sum(refused ? 1L : 0L); + const bool unchanged = system.program_accepted_state().empty(); + return refusing_ranks == n_ranks() && unchanged ? 0 : 1; +} + +int pops_run_test_mpi_cell_temporal_program_refusal(int argc, char** argv) { + comm_init(&argc, &argv); +#if defined(POPS_HAS_KOKKOS) + Kokkos::ScopeGuard guard(argc, argv); +#else + (void)argc; + (void)argv; +#endif + const int result = run_collective_refusal(); + comm_finalize(); + return result; +} + +} // namespace + +TEST(test_mpi_cell_temporal_program_refusal, Runs) { + EXPECT_EQ(pops::test::RunTestBody(&pops_run_test_mpi_cell_temporal_program_refusal, + "test_mpi_cell_temporal_program_refusal"), + 0); +} diff --git a/tests/cpp/integration/mpi/test_mpi_composite_fac.cpp b/tests/cpp/integration/mpi/test_mpi_composite_fac.cpp index b86345635..c169494da 100644 --- a/tests/cpp/integration/mpi/test_mpi_composite_fac.cpp +++ b/tests/cpp/integration/mpi/test_mpi_composite_fac.cpp @@ -23,8 +23,10 @@ #include #include +#include #include #include +#include #include #if defined(POPS_HAS_KOKKOS) @@ -105,6 +107,12 @@ static double spread(double x) { return all_reduce_max(x) - (-all_reduce_max(-x)); } +static void boundary_prepare_noop(int, const MultiFab&, MultiFab&, const Geometry&, + const FieldBoundaryExecutionContext&) {} + +static void boundary_residual_noop(int, const MultiFab&, MultiFab&, const Geometry&, + const FieldBoundaryExecutionContext&) {} + static int pops_run_test_mpi_composite_fac(int argc, char** argv) { comm_init(&argc, &argv); #if defined(POPS_HAS_KOKKOS) @@ -124,6 +132,114 @@ static int pops_run_test_mpi_composite_fac(int argc, char** argv) { int fails = 0; + // A public level-qualified refresh is collective. Different level identities must fail on every + // rank before one rank can enter the complete-batch collective while another returns early. + { + const Box2D fine_box{{n / 2, n / 2}, {n - 1, n - 1}}; + CompositeFacPoisson fac(geom_c, ba_c, bc, fine_box, r); + fac.set_boundary_kernel(CompiledFieldBoundaryKernel{ + "tests.mpi.composite-fac.level-consensus@1", + "tests.mpi.composite-fac.level-consensus.residual@1", + "", + boundary_prepare_noop, + nullptr, + boundary_residual_noop, + nullptr, + false, + }); + bool rejected = false; + try { + fac.set_boundary_context_at_level(np > 1 ? me % 2 : 0, {}); + } catch (const std::invalid_argument&) { + rejected = true; + } + const long rejection_count = all_reduce_sum(rejected ? 1L : 0L); + if ((np > 1 && rejection_count != np) || (np == 1 && rejection_count != 0)) { + if (me == 0) + std::printf("FAIL level-qualified boundary carrier level identity was not collective\n"); + ++fails; + } + } + + // Matching level identities are insufficient: the complete semantic carrier contract must also + // agree before any rank stages the level. An exact parameter mismatch is a compact adversarial + // witness for all scalar and state/field layout metadata carried by the same consensus payload. + { + const Box2D fine_box{{n / 2, n / 2}, {n - 1, n - 1}}; + CompositeFacPoisson fac(geom_c, ba_c, bc, fine_box, r); + fac.set_boundary_kernel(CompiledFieldBoundaryKernel{ + "tests.mpi.composite-fac.context-consensus@1", + "tests.mpi.composite-fac.context-consensus.residual@1", + "", + boundary_prepare_noop, + nullptr, + boundary_residual_noop, + nullptr, + false, + }); + const std::vector parameters{static_cast(me)}; + FieldBoundaryExecutionContext context; + context.parameters = ¶meters; + context.parameter_count = 1; + bool rejected = false; + try { + fac.set_boundary_context_at_level(0, context); + } catch (const std::invalid_argument&) { + rejected = true; + } + const long rejection_count = all_reduce_sum(rejected ? 1L : 0L); + if ((np > 1 && rejection_count != np) || (np == 1 && rejection_count != 0)) { + if (me == 0) + std::printf("FAIL level-qualified boundary carrier payload was not collective\n"); + ++fails; + } + } + + // Equal layouts are not logical identities. Swap two same-layout carriers and their authenticated + // names on alternating ranks: the ordered identity contract must reject the permutation before + // either pointer table can become the pending level-0 context. + { + const Box2D fine_box{{n / 2, n / 2}, {n - 1, n - 1}}; + CompositeFacPoisson fac(geom_c, ba_c, bc, fine_box, r); + fac.set_boundary_kernel(CompiledFieldBoundaryKernel{ + "tests.mpi.composite-fac.dependency-identity-consensus@1", + "tests.mpi.composite-fac.dependency-identity-consensus.residual@1", + "", + boundary_prepare_noop, + nullptr, + boundary_residual_noop, + nullptr, + false, + }); + MultiFab alternate(fac.rhs_level(0).box_array(), fac.rhs_level(0).dmap(), + fac.rhs_level(0).ncomp(), fac.rhs_level(0).n_grow()); + const MultiFab* states[] = {&fac.rhs_level(0), &alternate}; + std::string identities[] = {"tests.mpi.state.a", "tests.mpi.state.b"}; + if (np > 1 && me % 2 != 0) { + std::swap(states[0], states[1]); + std::swap(identities[0], identities[1]); + } + const FieldDistribution distributions[] = {FieldDistribution::Replicated, + FieldDistribution::Replicated}; + FieldBoundaryExecutionContext context; + context.states = states; + context.state_distributions = distributions; + context.state_identities = identities; + context.state_count = 2; + bool rejected = false; + try { + fac.set_boundary_context_at_level(0, context); + } catch (const std::invalid_argument&) { + rejected = true; + } + const long rejection_count = all_reduce_sum(rejected ? 1L : 0L); + if ((np > 1 && rejection_count != np) || (np == 1 && rejection_count != 0)) { + if (me == 0) + std::printf("FAIL same-layout boundary dependency permutation was not collective\n"); + ++fails; + } + } + // --- (A) 2-level, non-adjacent (routed to the general path via the MPI dispatch at np>1) --- { const int Ic0 = n / 4, Ic1 = 3 * n / 4 - 1; diff --git a/tests/cpp/integration/mpi/test_mpi_field_plan_consensus.cpp b/tests/cpp/integration/mpi/test_mpi_field_plan_consensus.cpp index e7afa4999..c5423f994 100644 --- a/tests/cpp/integration/mpi/test_mpi_field_plan_consensus.cpp +++ b/tests/cpp/integration/mpi/test_mpi_field_plan_consensus.cpp @@ -1,22 +1,38 @@ -// Exact collective consensus for resolved field-plan registries. Each scenario uses a fresh facade: -// setters are intentionally local/non-collective, then mark_bound compares one canonical std::map- -// ordered sequence of (provider_slot, plan_identity) before field-plan materialization. +// Exact collective consensus for resolved field-plan registries and level-qualified AMR stage +// packs. Registry scenarios keep setters local/non-collective, then mark_bound compares one +// canonical std::map-ordered sequence of (provider_slot, plan_identity). The stage-pack scenario +// drives distributed L0/L1 storage and proves successful publication, while a second supported +// replicated-L0/distributed-L1 scenario proves the composite field-coupled residual JVP against an +// independent finite difference. It also proves a level-local solved-field physical-boundary JVP +// and pre-solve rejection when provider, evaluation point, or pack presence differs between ranks. +// The deliberately unsupported distributed-L0 composite topology is rejected collectively before +// RHS assembly, solve, publication, or mutation of already accepted field/provider state. #include +#include "amr_transfer_test_authority.hpp" #include "gtest_compat.hpp" +#include "load_balance_test_authority.hpp" +#include +#include #include #include #include #include +#include #include #include #include +#include #include +#include #include +#include #include #include +#include +#include #include #include #include @@ -420,6 +436,163 @@ AmrFieldHierarchyPolicyAuthority composite_hierarchy_policy() { }; } +AmrFieldHierarchyPolicyAuthority level_local_hierarchy_policy() { + return { + "pops.field-hierarchy.level-local", + 1, + {"pops.field-hierarchy.options.empty@1", {}}, + }; +} + +using StagePackModel = CompositeModel; + +StagePackModel stage_pack_model() { + return StagePackModel{ExBVelocity{Real(1)}, NoSource{}, ChargeDensity{Real(1)}}; +} + +std::vector stage_pack_density(int n, double amplitude) { + std::vector density(static_cast(n) * n, Real(0)); + for (int j = 0; j < n; ++j) + for (int i = 0; i < n; ++i) { + const double x = (static_cast(i) + 0.5) / static_cast(n) - 0.5; + const double y = (static_cast(j) + 0.5) / static_cast(n) - 0.5; + density[static_cast(j) * n + i] = amplitude * std::exp(-(x * x + y * y) / 0.025); + } + return density; +} + +Real global_max_allocated_diff(const MultiFab& lhs, const MultiFab& rhs) { + if (lhs.box_array().boxes() != rhs.box_array().boxes() || + lhs.dmap().ranks() != rhs.dmap().ranks() || lhs.ncomp() != rhs.ncomp() || + lhs.n_grow() != rhs.n_grow()) + throw std::invalid_argument("MPI stage-pack comparison requires identical layouts"); + device_fence(); + Real local = Real(0); + for (int li = 0; li < lhs.local_size(); ++li) { + const ConstArray4 left = lhs.fab(li).const_array(); + const ConstArray4 right = rhs.fab(li).const_array(); + const Box2D grown = lhs.fab(li).grown_box(); + for (int component = 0; component < lhs.ncomp(); ++component) + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + local = std::max(local, std::fabs(left(i, j, component) - right(i, j, component))); + } + return all_reduce_max(local); +} + +Real global_max_valid_scalar_diff(const MultiFab& lhs, const MultiFab& rhs) { + if (lhs.box_array().boxes() != rhs.box_array().boxes() || + lhs.dmap().ranks() != rhs.dmap().ranks()) + throw std::invalid_argument("MPI scalar comparison requires identical layouts"); + device_fence(); + Real local = Real(0); + for (int li = 0; li < lhs.local_size(); ++li) { + const ConstArray4 left = lhs.fab(li).const_array(); + const ConstArray4 right = rhs.fab(li).const_array(); + const Box2D valid = lhs.box(li); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) + local = std::max(local, std::fabs(left(i, j, 0) - right(i, j, 0))); + } + return all_reduce_max(local); +} + +std::pair global_physical_boundary_support(const MultiFab& values, + const Box2D& domain) { + device_fence(); + Real local_boundary = Real(0); + Real local_interior = Real(0); + for (int li = 0; li < values.local_size(); ++li) { + const ConstArray4 data = values.fab(li).const_array(); + const Box2D valid = values.box(li); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) { + const Real magnitude = std::fabs(data(i, j, 0)); + const bool physical = + i == domain.lo[0] || i == domain.hi[0] || j == domain.lo[1] || j == domain.hi[1]; + Real& maximum = physical ? local_boundary : local_interior; + maximum = std::max(maximum, magnitude); + } + } + return {all_reduce_max(local_boundary), all_reduce_max(local_interior)}; +} + +void add_valid_constant(MultiFab& field, Real value) { + device_fence(); + for (int li = 0; li < field.local_size(); ++li) { + Array4 destination = field.fab(li).array(); + const Box2D valid = field.box(li); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) + destination(i, j, 0) += value; + } +} + +std::pair global_stage_pack_superposition_error(const MultiFab& both, + const MultiFab& only_a, + const MultiFab& only_b, + const MultiFab& base) { + if (both.box_array().boxes() != only_a.box_array().boxes() || + both.box_array().boxes() != only_b.box_array().boxes() || + both.box_array().boxes() != base.box_array().boxes() || + both.dmap().ranks() != only_a.dmap().ranks() || + both.dmap().ranks() != only_b.dmap().ranks() || both.dmap().ranks() != base.dmap().ranks()) + throw std::invalid_argument("MPI stage-pack superposition requires identical layouts"); + device_fence(); + Real local_error = Real(0); + Real local_response = Real(0); + for (int li = 0; li < both.local_size(); ++li) { + const ConstArray4 simultaneous = both.fab(li).const_array(); + const ConstArray4 a = only_a.fab(li).const_array(); + const ConstArray4 b = only_b.fab(li).const_array(); + const ConstArray4 origin = base.fab(li).const_array(); + const Box2D valid = both.box(li); + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) { + const Real simultaneous_response = simultaneous(i, j) - origin(i, j); + const Real separate_response = (a(i, j) - origin(i, j)) + (b(i, j) - origin(i, j)); + local_error = std::max(local_error, std::fabs(simultaneous_response - separate_response)); + local_response = std::max(local_response, std::fabs(simultaneous_response)); + } + } + return {all_reduce_max(local_error), all_reduce_max(local_response)}; +} + +bool mapping_is_distributed_across_two_ranks(const DistributionMapping& mapping) { + const auto& owners = mapping.ranks(); + return std::find(owners.begin(), owners.end(), 0) != owners.end() && + std::find(owners.begin(), owners.end(), 1) != owners.end(); +} + +DistributionMapping split_xlow_face_across_ranks(const BoxArray& boxes, const Box2D& domain) { + if (n_ranks() <= 0) + throw std::logic_error("physical-boundary distribution requires an active communicator"); + std::vector owners(static_cast(boxes.size()), 0); + int next_face_owner = 0; + int next_other_owner = 0; + for (int box = 0; box < boxes.size(); ++box) { + if (boxes[box].lo[0] == domain.lo[0]) + owners[static_cast(box)] = next_face_owner++ % n_ranks(); + else + owners[static_cast(box)] = next_other_owner++ % n_ranks(); + } + return DistributionMapping(std::move(owners)); +} + +bool xlow_face_is_distributed_across_two_ranks(const BoxArray& boxes, + const DistributionMapping& mapping, + const Box2D& domain) { + bool rank_zero = false; + bool rank_one = false; + for (int box = 0; box < boxes.size(); ++box) { + if (boxes[box].lo[0] != domain.lo[0]) + continue; + rank_zero = rank_zero || mapping[box] == 0; + rank_one = rank_one || mapping[box] == 1; + } + return rank_zero && rank_one; +} + void install(AmrSystem& system, const std::string& slot, const std::string& plan_identity, double provider_coefficient = 1.0) { system.set_field_solver_plan(slot, plan_identity, "provider:" + slot, "output-owner", "plasma", @@ -469,6 +642,729 @@ bool duplicate_rejected(System& system) { return false; } +long prove_field_jacvec_route(AmrRuntime& runtime, const std::string& jacvec_field, + const std::string& block_name, int block_index, + const AmrFieldHierarchyPolicyAuthority& hierarchy_policy, + std::string_view topology_digest, std::string_view route_label) { + constexpr Real c_dt = Real(0.01); + constexpr Real h = Real(2e-4); + long failures = 0; + const auto require = [&failures, route_label](bool condition, std::string_view check) { + if (!condition) { + std::fprintf(stderr, "rank %d: %.*s failed: %.*s\n", my_rank(), + static_cast(route_label.size()), route_label.data(), + static_cast(check.size()), check.data()); + ++failures; + } + }; + const auto consume_solved = [route_label](SolveOutcome outcome, std::string_view check) { + if (!outcome.report().solved()) { + const SolveConsumption action = outcome.report().action == SolveAction::kRejectAttempt + ? SolveConsumption::kRejectAttempt + : SolveConsumption::kFailRun; + const SolveReport failed = outcome.consume(action); + char metrics[192]; + std::snprintf(metrics, sizeof(metrics), + " (iters=%d, residual=%.17g, reference=%.17g, relative=%.17g)", failed.iters, + static_cast(failed.residual_norm), + static_cast(failed.reference_residual_norm), + static_cast(failed.rel_residual)); + throw std::runtime_error(std::string(route_label) + " " + std::string(check) + + " failed: " + failed.reason + metrics); + } + return outcome.consume(SolveConsumption::kAccept); + }; + + AmrFieldSolveConfig jacvec_plan; + CompositeFacOptions fac_options; + // Preserve the production tolerance while giving the distributed partial-refinement FAC route + // enough outer cycles to reach it; the default 30 cycles stops near 2e-7 on this tiny hierarchy. + fac_options.max_iters = 80; + jacvec_plan.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, fac_options); + jacvec_plan.plan_identity = "tests.mpi." + jacvec_field + ".plan@1"; + jacvec_plan.provider_identity = "tests.mpi." + jacvec_field; + jacvec_plan.topology_provider_kind = "structured"; + jacvec_plan.topology_provenance = "tests.mpi.periodic-cartesian"; + jacvec_plan.topology_digest = std::string(topology_digest); + jacvec_plan.output_owner_identity = "tests.mpi.stage-pack." + block_name; + jacvec_plan.output_block = block_name; + jacvec_plan.output_key = jacvec_field; + jacvec_plan.hierarchy_policy = hierarchy_policy; + jacvec_plan.nullspace = operator_topology_zero_mean_nullspace(); + jacvec_plan.has_reaction = true; + jacvec_plan.reaction = Real(2); + jacvec_plan.providers.push_back(FieldProviderBinding{"tests.mpi." + jacvec_field + "/rhs", + block_name, jacvec_field, Real(1)}); + runtime.install_field_plan(jacvec_field, jacvec_plan); + runtime.register_named_field(block_name, jacvec_field, 0, 1, 2, + /*gradient_sign=*/-1); + runtime.set_block_named_elliptic_rhs( + block_index, jacvec_field, + [](const MultiFab& state, MultiFab& rhs) { add_scaled_component(state, Real(1), 0, rhs); }); + + require(consume_solved(runtime.solve_named_fields(&jacvec_field), "baseline").solved(), + "baseline consumption"); + for (int level = 0; level < runtime.nlev(); ++level) { + const ::pops::runtime::multiblock::BoundaryEvaluationPoint point{ + "main", + 31 + 10 * block_index + level, + level, + level, + 13, + ::pops::amr::Rational(1, 2), + 0.01 / static_cast(1 << level), + 0.305}; + MultiFab iterate = runtime.level_state(block_index, level); + MultiFab direction = iterate; + scale(direction, Real(0.75)); + + require(consume_solved( + runtime.solve_named_fields_from_state_at(point, jacvec_field, block_index, iterate), + "base") + .solved(), + "base consumption"); + std::vector base_phi; + base_phi.reserve(static_cast(runtime.nlev())); + for (int provider_level = 0; provider_level < runtime.nlev(); ++provider_level) + base_phi.emplace_back(runtime.provider_potential_level(jacvec_field, provider_level)); + + auto residual_at = [&](Real shift, bool coupled) { + MultiFab state = iterate; + saxpy(state, shift, direction); + MultiFab residual(iterate.box_array(), iterate.dmap(), iterate.ncomp(), 0); + residual.set_val(Real(0)); + if (coupled) { + require(consume_solved(runtime.solve_named_fields_from_state_at(point, jacvec_field, + block_index, state), + "perturbed") + .solved(), + "perturbed consumption"); + } + runtime.level_rhs_core_into_at(block_index, level, point, state, residual, + /*flux_only=*/false); + if (coupled) { + require(consume_solved(runtime.solve_named_fields_from_state_at(point, jacvec_field, + block_index, iterate), + "restore") + .solved(), + "restore consumption"); + } + return residual; + }; + + const MultiFab r0 = residual_at(Real(0), /*coupled=*/false); + const MultiFab plus = residual_at(h, /*coupled=*/true); + const MultiFab minus = residual_at(-h, /*coupled=*/true); + const MultiFab stale_plus = residual_at(h, /*coupled=*/false); + const MultiFab restored_r0 = residual_at(Real(0), /*coupled=*/false); + + MultiFab generated = direction; + saxpy(generated, -c_dt / h, plus); + saxpy(generated, c_dt / h, r0); + MultiFab centered = direction; + saxpy(centered, -c_dt / (Real(2) * h), plus); + saxpy(centered, c_dt / (Real(2) * h), minus); + const Real response = global_max_valid_scalar_diff(centered, direction); + require(response > Real(1e-7), "field-coupled response"); + require(global_max_valid_scalar_diff(generated, centered) < Real(2e-2) * response + Real(2e-7), + "centered-difference parity"); + + MultiFab stale = direction; + saxpy(stale, -c_dt / h, stale_plus); + saxpy(stale, c_dt / h, r0); + require(global_max_valid_scalar_diff(stale, centered) > Real(1e-7), + level == 0 ? "L0 rejects a frozen provider" : "L1 rejects a frozen provider"); + for (int provider_level = 0; provider_level < runtime.nlev(); ++provider_level) + require(global_max_valid_scalar_diff( + runtime.provider_potential_level(jacvec_field, provider_level), + base_phi[static_cast(provider_level)]) < Real(1e-8), + "restores its complete provider hierarchy"); + require(global_max_valid_scalar_diff(restored_r0, r0) < Real(1e-8), + "restores its residual carrier"); + } + return failures; +} + +long prove_exact_distributed_stage_pack() { + constexpr int n = 8; + constexpr int phi_component = kAuxNamedBase; + long failures = 0; + const auto require = [&failures](bool condition, std::string_view label) { + if (!condition) { + std::fprintf(stderr, "rank %d: exact stage-pack check failed: %.*s\n", my_rank(), + static_cast(label.size()), label.data()); + ++failures; + } + }; + + try { + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{true, true}; + params.mesh.n = n; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + params.mesh.distribute_coarse = true; + params.mesh.coarse_max_grid = n / 2; + params.poisson.bc = BCRec{}; + detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + // Exercise a genuinely distributed stage pack on both materialized levels. The ordinary + // bootstrap fine seed is one patch; replace it with full-domain tiles so np=2 owns live and + // staged pieces on L0 and L1 instead of merely carrying empty local views on one rank. + layout.dm[0] = DistributionMapping(layout.ba[0].size(), n_ranks()); + layout.dm_coarse = layout.dm[0]; + const Box2D fine_domain = layout.geom.domain.refine(kAmrRefRatio); + layout.ba[1] = BoxArray::from_domain(fine_domain, n); + layout.dm[1] = DistributionMapping(layout.ba[1].size(), n_ranks()); + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(stage_pack_model(), "minmod", "rusanov", layout, + "a", stage_pack_density(n, 0.35), + /*has_density=*/true, 1.4, 1, false)); + blocks.push_back(detail::dispatch_amr_block(stage_pack_model(), "minmod", "rusanov", layout, + "b", stage_pack_density(n, 0.65), + /*has_density=*/true, 1.4, 1, false)); + for (AmrRuntimeBlock& block : blocks) + block.aux_ncomp = phi_component + 1; + + int rhs_assembly_calls = 0; + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, + std::move(blocks), layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 2); + runtime.set_parent_child_temporal_relations({::pops::amr::ParentChildClockRelation( + 0, 1, ::pops::amr::Rational(2, 1), ::pops::amr::RemainderPolicy::IntegralOnly)}); + + AmrFieldSolveConfig plan; + plan.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, CompositeFacOptions{}); + plan.plan_identity = "tests.mpi.stage-pack.coupled-screened.plan@1"; + plan.provider_identity = "tests.mpi.stage-pack.coupled-screened"; + plan.topology_provider_kind = "structured"; + plan.topology_provenance = "tests.mpi.periodic-cartesian"; + plan.topology_digest = "tests.mpi.periodic-cartesian.full-refinement@1"; + plan.output_owner_identity = "tests.mpi.stage-pack.a"; + plan.output_block = "a"; + plan.output_key = "coupled_screened"; + plan.hierarchy_policy = level_local_hierarchy_policy(); + plan.nullspace = operator_topology_zero_mean_nullspace(); + plan.has_reaction = true; + plan.reaction = Real(2); + plan.providers.push_back( + FieldProviderBinding{"tests.mpi.stage-pack.a/rhs", "a", "coupled_screened", Real(1)}); + plan.providers.push_back( + FieldProviderBinding{"tests.mpi.stage-pack.b/rhs", "b", "coupled_screened", Real(1)}); + runtime.install_field_plan("coupled_screened", plan); + runtime.register_named_field("a", "coupled_screened", phi_component, + /*gx=*/-1, /*gy=*/-1, /*gradient_sign=*/Real(1)); + runtime.set_block_named_elliptic_rhs( + 0, "coupled_screened", [&rhs_assembly_calls](const MultiFab& state, MultiFab& rhs) { + ++rhs_assembly_calls; + add_scaled_component(state, Real(1), 0, rhs); + }); + runtime.set_block_named_elliptic_rhs( + 1, "coupled_screened", [&rhs_assembly_calls](const MultiFab& state, MultiFab& rhs) { + ++rhs_assembly_calls; + add_scaled_component(state, Real(1), 0, rhs); + }); + + const std::string field = "coupled_screened"; + { + SolveOutcome baseline = runtime.solve_named_fields(&field); + require(baseline.report().solved(), "baseline report"); + require(baseline.consume(SolveConsumption::kAccept).solved(), "baseline consumption"); + } + require(runtime.nlev() == 2, "two materialized levels"); + + for (int level = 0; level < runtime.nlev(); ++level) { + const MultiFab& live_a = runtime.level_state(0, level); + const MultiFab& live_b = runtime.level_state(1, level); + require(mapping_is_distributed_across_two_ranks(live_a.dmap()), "block a distributed"); + require(mapping_is_distributed_across_two_ranks(live_b.dmap()), "block b distributed"); + require(live_a.local_size() > 0, "block a has a local piece"); + require(live_b.local_size() > 0, "block b has a local piece"); + + const MultiFab base_phi = runtime.provider_potential_level(field, level); + const MultiFab accepted_a = live_a; + const MultiFab accepted_b = live_b; + MultiFab stage_a = accepted_a; + MultiFab stage_b = accepted_b; + add_valid_constant(stage_a, Real(0.05)); + add_valid_constant(stage_b, Real(0.08)); + const ::pops::runtime::multiblock::BoundaryEvaluationPoint point{ + "main", + 17, + level, + level, + 29, + ::pops::amr::Rational(1, 2), + 0.01 / static_cast(1 << level), + 0.205}; + + auto solve_and_accept = [&](const std::vector& stages) { + const MultiFab visible_before = runtime.provider_potential_level(field, level); + const int assemblies_before = rhs_assembly_calls; + SolveOutcome pending = runtime.solve_named_fields_from_states_at(point, field, stages); + require(rhs_assembly_calls == assemblies_before + 2 * runtime.nlev(), + "successful request assembled every block and level"); + require(global_max_allocated_diff(runtime.level_state(0, level), accepted_a) == Real(0), + "block a live state restored before consumption"); + require(global_max_allocated_diff(runtime.level_state(1, level), accepted_b) == Real(0), + "block b live state restored before consumption"); + require(global_max_allocated_diff(runtime.provider_potential_level(field, level), + visible_before) == Real(0), + "candidate private before consumption"); + require(pending.report().solved(), "stage-pack report solved"); + require(pending.consume(SolveConsumption::kAccept).solved(), "stage-pack result consumed"); + require(global_max_allocated_diff(runtime.level_state(0, level), accepted_a) == Real(0), + "block a live state restored after consumption"); + require(global_max_allocated_diff(runtime.level_state(1, level), accepted_b) == Real(0), + "block b live state restored after consumption"); + return MultiFab(runtime.provider_potential_level(field, level)); + }; + + std::vector stages(2, nullptr); + stages[0] = &stage_a; + const MultiFab only_a = solve_and_accept(stages); + stages[0] = nullptr; + stages[1] = &stage_b; + const MultiFab only_b = solve_and_accept(stages); + stages[0] = &stage_a; + const MultiFab both = solve_and_accept(stages); + + const auto [superposition_error, response] = + global_stage_pack_superposition_error(both, only_a, only_b, base_phi); + require(response > Real(1e-7), "both stage states contribute"); + require(superposition_error < Real(5e-4) * response + Real(1e-10), + "stage-pack superposition"); + + stages[0] = &accepted_a; + stages[1] = &accepted_b; + const MultiFab restored = solve_and_accept(stages); + require(global_max_valid_scalar_diff(restored, base_phi) < Real(1e-8), + "accepted stage pack restores provider result"); + } + + // This runtime deliberately de-replicates both L0 and L1. The builtin composite provider + // refuses that ownership contract, so this route proves only the level-local policy here. + failures += prove_field_jacvec_route( + runtime, "distributed_jacvec", "a", 0, level_local_hierarchy_policy(), + "tests.mpi.periodic-cartesian.full-refinement@1", "distributed level-local JVP"); + + // These request bytes are collective inputs. Keep every local request structurally valid so + // each mismatch reaches the exact consensus, then prove no solver or publication ran. + const int level = 0; + const MultiFab accepted_a = runtime.level_state(0, level); + const MultiFab accepted_b = runtime.level_state(1, level); + MultiFab stage_a = accepted_a; + MultiFab stage_b = accepted_b; + add_valid_constant(stage_a, Real(0.03)); + add_valid_constant(stage_b, Real(0.04)); + const ::pops::runtime::multiblock::BoundaryEvaluationPoint common_point{ + "main", 41, level, 0, 7, ::pops::amr::Rational(1, 2), 0.01, 0.41}; + const MultiFab visible_before = runtime.provider_potential_level(field, level); + const int assemblies_before = rhs_assembly_calls; + + auto require_collective_pre_solve_rejection = + [&](const ::pops::runtime::multiblock::BoundaryEvaluationPoint& point, + const std::string& provider, const std::vector& stages) { + bool rejected = false; + bool exact_error = false; + try { + (void)runtime.solve_named_fields_from_states_at(point, provider, stages); + } catch (const std::invalid_argument& error) { + rejected = true; + exact_error = + std::string_view(error.what()) == + "AmrRuntime::solve_named_fields_from_states_at request differs between MPI ranks"; + } catch (...) { + } + require(rejected, "divergent request rejected collectively"); + require(exact_error, "divergent request exact diagnostic"); + require(rhs_assembly_calls == assemblies_before, + "divergence refused before RHS assembly and solve"); + require(!runtime.field_solve_transaction_active(), "divergence leaves no transaction"); + require(global_max_allocated_diff(runtime.level_state(0, level), accepted_a) == Real(0), + "divergence preserves block a live state"); + require(global_max_allocated_diff(runtime.level_state(1, level), accepted_b) == Real(0), + "divergence preserves block b live state"); + require(global_max_allocated_diff(runtime.provider_potential_level(field, level), + visible_before) == Real(0), + "divergence preserves published provider"); + }; + + { + auto point = common_point; + if (my_rank() == 1) + ++point.stage; + require_collective_pre_solve_rejection(point, field, {&stage_a, &stage_b}); + } + { + const std::string provider = my_rank() == 1 ? "rank-one-provider" : field; + require_collective_pre_solve_rejection(common_point, provider, {&stage_a, &stage_b}); + } + { + std::vector stages{&stage_a, &stage_b}; + if (my_rank() == 1) + stages[1] = nullptr; + require_collective_pre_solve_rejection(common_point, field, stages); + } + + // CompositeFAC deliberately rejects a distributed coarse hierarchy. Prove the collective + // capability guard runs before any RHS, solve, publication, or transaction mutation by keeping + // this accepted level-local field as a witness on the exact same distributed L0/L1 runtime. + std::vector live_a_before, live_b_before, provider_before; + for (int provider_level = 0; provider_level < runtime.nlev(); ++provider_level) { + live_a_before.emplace_back(runtime.level_state(0, provider_level)); + live_b_before.emplace_back(runtime.level_state(1, provider_level)); + provider_before.emplace_back(runtime.provider_potential_level(field, provider_level)); + } + const int rejected_assemblies_before = rhs_assembly_calls; + const std::size_t fields_before = runtime.n_named_fields(); + const std::vector slots_before = runtime.provider_slots(); + AmrFieldSolveConfig rejected_plan = plan; + rejected_plan.plan_identity = "tests.mpi.distributed-composite-rejected.plan@1"; + rejected_plan.provider_identity = "tests.mpi.distributed-composite-rejected"; + rejected_plan.output_key = "distributed_composite_rejected"; + rejected_plan.hierarchy_policy = composite_hierarchy_policy(); + rejected_plan.providers = {FieldProviderBinding{"tests.mpi.distributed-composite-rejected/rhs", + "a", "distributed_composite_rejected", + Real(1)}}; + + constexpr std::string_view expected = + "AMR field solver provider rejected request (code 14): composite hierarchy cannot " + "represent this coarse distribution or active region"; + bool rejected = false; + bool exact_diagnostic = false; + try { + runtime.install_field_plan("distributed_composite_rejected", rejected_plan); + } catch (const std::invalid_argument& error) { + rejected = true; + exact_diagnostic = std::string_view(error.what()) == expected; + } catch (...) { + } + require(rejected, "distributed-L0 composite rejected on every rank"); + require(exact_diagnostic, "distributed-L0 composite exact code-14 diagnostic"); + require(rhs_assembly_calls == rejected_assemblies_before, + "distributed-L0 composite rejected before RHS assembly and solve"); + require(!runtime.field_solve_transaction_active(), + "distributed-L0 composite leaves no field transaction"); + require(runtime.n_named_fields() == fields_before, + "distributed-L0 composite publishes no field plan"); + require(runtime.provider_slots() == slots_before, + "distributed-L0 composite preserves the provider registry"); + require(!runtime.has_named_field("distributed_composite_rejected"), + "distributed-L0 composite provider slot remains absent"); + for (int provider_level = 0; provider_level < runtime.nlev(); ++provider_level) { + const auto index = static_cast(provider_level); + require(global_max_allocated_diff(runtime.level_state(0, provider_level), + live_a_before[index]) == Real(0), + "distributed-L0 composite preserves block a live state"); + require(global_max_allocated_diff(runtime.level_state(1, provider_level), + live_b_before[index]) == Real(0), + "distributed-L0 composite preserves block b live state"); + require(global_max_allocated_diff(runtime.provider_potential_level(field, provider_level), + provider_before[index]) == Real(0), + "distributed-L0 composite preserves accepted provider publication"); + } + } catch (const std::exception& error) { + if (my_rank() == 0) + std::fprintf(stderr, "exact distributed stage-pack proof failed: %s\n", error.what()); + ++failures; + } catch (...) { + if (my_rank() == 0) + std::fprintf(stderr, "exact distributed stage-pack proof failed with an unknown error\n"); + ++failures; + } + return failures; +} + +long prove_replicated_coarse_composite_jvp() { + constexpr int n = 8; + long failures = 0; + const auto require = [&failures](bool condition, std::string_view label) { + if (!condition) { + std::fprintf(stderr, "rank %d: replicated-coarse composite JVP failed: %.*s\n", my_rank(), + static_cast(label.size()), label.data()); + ++failures; + } + }; + + try { + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{true, true}; + params.mesh.n = n; + params.mesh.regrid_every = 0; + params.mesh.distribute_coarse = false; + detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + // CompositeFAC's current MPI contract keeps a complete coarse copy on every rank while the + // refined level is genuinely partitioned. Tile the central fine seed so both ranks own live + // pieces while uncovered L0 cells continue to exercise the coarse part of the composite solve. + const Box2D fine_region = layout.ba[1].boxes().front(); + layout.ba[1] = BoxArray::from_domain(fine_region, n / 2); + layout.dm[1] = DistributionMapping(layout.ba[1].size(), n_ranks()); + require(layout.replicated_coarse, "coarse ownership is explicitly replicated"); + require(mapping_is_distributed_across_two_ranks(layout.dm[1]), "L1 mapping is distributed"); + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(stage_pack_model(), "minmod", "rusanov", layout, + "composite", stage_pack_density(n, 0.5), + /*has_density=*/true, 1.4, 1, false)); + blocks.back().aux_ncomp = kAuxNamedBase + 1; + + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, + std::move(blocks), layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 1); + runtime.set_parent_child_temporal_relations({::pops::amr::ParentChildClockRelation( + 0, 1, ::pops::amr::Rational(2, 1), ::pops::amr::RemainderPolicy::IntegralOnly)}); + + require(runtime.nlev() == 2, "composite hierarchy has L0/L1"); + require(runtime.level_state(0, 0).local_size() > 0, + "each rank owns its replicated coarse copy"); + require(runtime.level_state(0, 1).local_size() > 0, "each rank owns a fine piece"); + failures += prove_field_jacvec_route(runtime, "replicated_coarse_composite_jacvec", "composite", + 0, composite_hierarchy_policy(), + "tests.mpi.periodic-cartesian.central-refinement@1", + "replicated-coarse distributed-fine composite JVP"); + } catch (const std::exception& error) { + if (my_rank() == 0) + std::fprintf(stderr, "replicated-coarse composite JVP proof failed: %s\n", error.what()); + ++failures; + } catch (...) { + if (my_rank() == 0) + std::fprintf(stderr, "replicated-coarse composite JVP proof failed with an unknown error\n"); + ++failures; + } + return failures; +} + +long prove_distributed_physical_boundary_jvp() { + constexpr int n = 8; + constexpr int phi_component = kAuxNamedBase; + constexpr Real c_dt = Real(0.01); + constexpr Real h = Real(2e-4); + const std::string state_identity = "tests://mpi/physical-boundary/state/a"; + const std::string field_identity = "tests://mpi/physical-boundary/field/jacvec"; + const std::string field = "distributed_boundary_jacvec"; + long failures = 0; + const auto require = [&failures](bool condition, std::string_view label) { + if (!condition) { + std::fprintf(stderr, "rank %d: distributed physical-boundary JVP failed: %.*s\n", my_rank(), + static_cast(label.size()), label.data()); + ++failures; + } + }; + + try { + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{false, false}; + params.mesh.n = n; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + params.mesh.distribute_coarse = true; + params.mesh.coarse_max_grid = n / 2; + BCRec physical_field_bc; + physical_field_bc.xlo = physical_field_bc.xhi = BCType::Dirichlet; + physical_field_bc.ylo = physical_field_bc.yhi = BCType::Dirichlet; + params.poisson.bc = physical_field_bc; + detail::SharedAmrLayout layout = detail::make_shared_amr_layout(params); + + layout.dm[0] = split_xlow_face_across_ranks(layout.ba[0], layout.geom.domain); + layout.dm_coarse = layout.dm[0]; + const Box2D fine_domain = layout.geom.domain.refine(kAmrRefRatio); + layout.ba[1] = BoxArray::from_domain(fine_domain, n); + layout.dm[1] = split_xlow_face_across_ranks(layout.ba[1], fine_domain); + require(mapping_is_distributed_across_two_ranks(layout.dm[0]), + "physical-boundary L0 is distributed"); + require(mapping_is_distributed_across_two_ranks(layout.dm[1]), + "physical-boundary L1 is distributed"); + require( + xlow_face_is_distributed_across_two_ranks(layout.ba[0], layout.dm[0], layout.geom.domain), + "physical x-low face is split across ranks on L0"); + require(xlow_face_is_distributed_across_two_ranks(layout.ba[1], layout.dm[1], fine_domain), + "physical x-low face is split across ranks on L1"); + + BCRec transport_bc; + transport_bc.xlo = transport_bc.xhi = BCType::Foextrap; + transport_bc.ylo = transport_bc.yhi = BCType::Foextrap; + auto boundary_plan = std::make_shared( + "tests://mpi/physical-boundary/plan", 1, std::vector{transport_bc}, + std::vector{}, state_identity, PreparedBoundaryReadDependencies{{}, {field_identity}}); + const PreparedBoundaryFieldRead field_read = boundary_plan->prepare_field_read(field_identity); + std::map> boundary_plans{ + {"a", boundary_plan}}; + layout.boundary_plans = &boundary_plans; + + std::vector blocks; + blocks.push_back(detail::dispatch_amr_block(stage_pack_model(), "minmod", "rusanov", layout, + "a", stage_pack_density(n, 0.5), + /*has_density=*/true, 1.4, 1, false)); + blocks.back().aux_ncomp = phi_component + 1; + blocks.back().state_identity = state_identity; + blocks.back().level_boundary_residual_at_point_prepared = + [field_read](const ::pops::runtime::multiblock::BoundaryEvaluationPoint& point, + MultiFab& state, const MultiFab&, const Geometry& geometry, MultiFab& residual, + const PreparedGridBoundarySession& boundary) { + const PreparedBoundaryReadView reads = boundary.bind_reads(point, state); + const MultiFab& solved_field = reads.field(field_read); + for (int local = 0; local < residual.local_size(); ++local) { + const int field_local = solved_field.local_index_of(residual.global_index(local)); + if (field_local < 0) + throw std::logic_error( + "distributed physical boundary lost co-distributed field ownership"); + const Box2D valid = residual.box(local); + if (valid.lo[0] > geometry.domain.lo[0] || valid.hi[0] < geometry.domain.lo[0]) + continue; + const ConstArray4 phi = solved_field.fab(field_local).const_array(); + const Array4 output = residual.fab(local).array(); + const int i = geometry.domain.lo[0]; + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + output(i, j, 0) += Real(100) * phi(i, j, 0); + } + }; + + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, + std::move(blocks), layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 1); + runtime.set_parent_child_temporal_relations({::pops::amr::ParentChildClockRelation( + 0, 1, ::pops::amr::Rational(2, 1), ::pops::amr::RemainderPolicy::IntegralOnly)}); + + AmrFieldSolveConfig plan; + plan.solver_options = + geometric_mg_amr_field_solver_options(GeometricMgOptions{}, CompositeFacOptions{}); + plan.plan_identity = "tests.mpi.distributed-boundary-jacvec.plan@1"; + plan.provider_identity = "tests.mpi.distributed-boundary-jacvec"; + plan.topology_provider_kind = "structured"; + plan.topology_provenance = "tests.mpi.physical-cartesian"; + plan.topology_digest = "tests.mpi.physical-cartesian.full-refinement@1"; + plan.output_owner_identity = "tests.mpi.physical-boundary.a"; + plan.output_block = "a"; + plan.output_key = field; + plan.hierarchy_policy = level_local_hierarchy_policy(); + plan.nullspace = operator_topology_zero_mean_nullspace(); + plan.has_reaction = true; + plan.reaction = Real(2); + plan.providers.push_back( + FieldProviderBinding{"tests.mpi.distributed-boundary-jacvec/rhs", "a", field, Real(1)}); + runtime.install_field_plan(field, plan); + runtime.register_named_field("a", field, 0, 1, 2, /*gradient_sign=*/-1); + runtime.set_block_named_elliptic_rhs(0, field, [](const MultiFab& state, MultiFab& rhs) { + add_scaled_component(state, Real(1), 0, rhs); + }); + runtime.install_boundary_storage_routes({{field_identity, field}}); + + require(runtime.nlev() == 2, "physical-boundary hierarchy has L0/L1"); + for (int level = 0; level < runtime.nlev(); ++level) { + const ::pops::runtime::multiblock::BoundaryEvaluationPoint point{ + "main", + 71 + level, + level, + 0, + 17, + ::pops::amr::Rational(1, 2), + 0.01 / static_cast(1 << level), + 0.405}; + MultiFab iterate = runtime.level_state(0, level); + MultiFab direction = iterate; + scale(direction, Real(0.75)); + + { + SolveOutcome base = runtime.solve_named_fields_from_state_at(point, field, 0, iterate); + require(base.report().solved(), "physical-boundary base report"); + require(base.consume(SolveConsumption::kAccept).solved(), + "physical-boundary base consumption"); + } + const MultiFab base_phi = runtime.provider_potential_level(field, level); + + auto residual_at = [&](Real shift, bool coupled, bool include_boundary) { + MultiFab state = iterate; + saxpy(state, shift, direction); + MultiFab residual(iterate.box_array(), iterate.dmap(), iterate.ncomp(), 0); + residual.set_val(Real(0)); + if (coupled) { + SolveOutcome perturbed = runtime.solve_named_fields_from_state_at(point, field, 0, state); + require(perturbed.report().solved(), "physical-boundary perturbed report"); + require(perturbed.consume(SolveConsumption::kAccept).solved(), + "physical-boundary perturbed consumption"); + } + if (include_boundary) + runtime.level_rhs_into_at(0, level, point, state, residual); + else + runtime.level_rhs_core_into_at(0, level, point, state, residual, /*flux_only=*/false); + if (coupled) { + SolveOutcome restored = + runtime.solve_named_fields_from_state_at(point, field, 0, iterate); + require(restored.report().solved(), "physical-boundary restore report"); + require(restored.consume(SolveConsumption::kAccept).solved(), + "physical-boundary restore consumption"); + } + return residual; + }; + + const MultiFab r0 = residual_at(Real(0), /*coupled=*/false, /*include_boundary=*/true); + const MultiFab plus = residual_at(h, /*coupled=*/true, /*include_boundary=*/true); + const MultiFab minus = residual_at(-h, /*coupled=*/true, /*include_boundary=*/true); + const MultiFab stale_plus = residual_at(h, /*coupled=*/false, /*include_boundary=*/true); + const MultiFab plus_core = residual_at(h, /*coupled=*/true, /*include_boundary=*/false); + const MultiFab minus_core = residual_at(-h, /*coupled=*/true, /*include_boundary=*/false); + const MultiFab stale_plus_core = + residual_at(h, /*coupled=*/false, /*include_boundary=*/false); + const MultiFab restored_r0 = + residual_at(Real(0), /*coupled=*/false, /*include_boundary=*/true); + + MultiFab generated = direction; + saxpy(generated, -c_dt / h, plus); + saxpy(generated, c_dt / h, r0); + MultiFab centered = direction; + saxpy(centered, -c_dt / (Real(2) * h), plus); + saxpy(centered, c_dt / (Real(2) * h), minus); + const Real response = global_max_valid_scalar_diff(centered, direction); + require(response > Real(1e-7), "physical-boundary field-coupled JVP response"); + require( + global_max_valid_scalar_diff(generated, centered) < Real(2e-2) * response + Real(2e-7), + "physical-boundary field-coupled JVP centered-difference parity"); + + MultiFab centered_core = direction; + saxpy(centered_core, -c_dt / (Real(2) * h), plus_core); + saxpy(centered_core, c_dt / (Real(2) * h), minus_core); + require(global_max_valid_scalar_diff(centered, centered_core) > Real(1e-8), + "physical boundary affects distributed JVP"); + + MultiFab coupled_boundary = plus; + saxpy(coupled_boundary, Real(-1), plus_core); + MultiFab stale_boundary = stale_plus; + saxpy(stale_boundary, Real(-1), stale_plus_core); + require(global_max_valid_scalar_diff(coupled_boundary, stale_boundary) > Real(1e-8), + "frozen provider changes distributed physical boundary"); + + const auto [boundary_support, interior_support] = + global_physical_boundary_support(coupled_boundary, runtime.level_geom(level).domain); + require(boundary_support > Real(1e-8), "distributed physical-boundary contribution exists"); + require(interior_support < Real(1e-13), + "distributed physical-boundary contribution remains face-local"); + require(global_max_valid_scalar_diff(runtime.provider_potential_level(field, level), + base_phi) < Real(1e-8), + "distributed physical-boundary provider restores"); + require(global_max_valid_scalar_diff(restored_r0, r0) < Real(1e-8), + "distributed physical-boundary residual carrier restores"); + } + } catch (const std::exception& error) { + if (my_rank() == 0) + std::fprintf(stderr, "distributed physical-boundary JVP proof failed: %s\n", error.what()); + ++failures; + } catch (...) { + if (my_rank() == 0) + std::fprintf(stderr, + "distributed physical-boundary JVP proof failed with an unknown error\n"); + ++failures; + } + return failures; +} + int run_field_plan_consensus(int argc, char** argv) { comm_init(&argc, &argv); #if defined(POPS_HAS_KOKKOS) @@ -482,6 +1378,56 @@ int run_field_plan_consensus(int argc, char** argv) { ++failures; }; + failures += prove_exact_distributed_stage_pack(); + failures += prove_replicated_coarse_composite_jvp(); + failures += prove_distributed_physical_boundary_jvp(); + + // ADC-750: priority and first-failure diagnostics are separate integer collectives. Rank zero + // owns a large negative-index cell and rank one a large positive-index cell. A fatal rank-one + // failure first dominates the earlier recoverable cell; once both are fatal, lexicographic + // `(j, i, component)` order selects rank zero exactly. Binary64 packing corrupted both cases. + { + const BoxArray boxes( + std::vector{Box2D{{-1000000000, -700000000}, {-1000000000, -700000000}}, + Box2D{{1000000000, 700000000}, {1000000000, 700000000}}}); + const DistributionMapping mapping(std::vector{0, 1}); + MultiFab statistics(boxes, mapping, 11, 0); + statistics.set_val(Real(0)); + const int recoverable = + local_nonlinear_status_priority(LocalNonlinearStatus::kEvaluationReject); + const int fatal = local_nonlinear_status_priority(LocalNonlinearStatus::kInvalidEvaluation); + for (int local = 0; local < statistics.local_size(); ++local) { + const Box2D box = statistics.box(local); + const Array4 values = statistics.fab(local).array(); + for_each_cell(box, [=] POPS_HD(int i, int j) { + const bool negative = i < 0; + values(i, j, 8) = negative ? Real(7) : Real(3); + values(i, j, 9) = Real(1); + values(i, j, 10) = static_cast(negative ? recoverable : fatal); + }); + } + + int priority = static_cast(reduce_max(statistics, 10)); + LocalNonlinearFailureLocation location = + collective_first_local_nonlinear_failure(statistics, priority, 10, 8); + require(priority == fatal); + require(location.found && location.priority == fatal); + require(location.i == 1000000000 && location.j == 700000000 && location.component == 3); + + for (int local = 0; local < statistics.local_size(); ++local) { + const Box2D box = statistics.box(local); + const Array4 values = statistics.fab(local).array(); + for_each_cell(box, [=] POPS_HD(int i, int j) { + if (i < 0) + values(i, j, 10) = static_cast(fatal); + }); + } + priority = static_cast(reduce_max(statistics, 10)); + location = collective_first_local_nonlinear_failure(statistics, priority, 10, 8); + require(location.found && location.priority == fatal); + require(location.i == -1000000000 && location.j == -700000000 && location.component == 7); + } + // A hierarchy provider cannot split publication by returning individually valid but different // reports. Both outcome divergence and equal-length reason-byte divergence are rejected with one // uniform error on every rank; an identical report remains publishable. diff --git a/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp b/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp index 43ada0850..343e6d5fa 100644 --- a/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp +++ b/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp @@ -116,15 +116,13 @@ static int pops_run_test_mpi_fillboundary(int argc, char** argv) { for (int i = valid.lo[0]; i <= valid.hi[0]; ++i) field(i, j, 0) = mapped_value(i, j); } - BCRec boundary; - boundary.xlo = BCType::Periodic; - boundary.xhi = BCType::Periodic; - boundary.ylo = BCType::Foextrap; - boundary.yhi = BCType::Foextrap; const PeriodicIdentification2D reflected_x{0, 1, std::array{{0, 1}}, std::array{{1, -1}}}; - PreparedBoundaryPlan plan("test::mpi::reflected-periodic", mapped_ng, {boundary}, {}, "", {}, - {reflected_x}); + auto boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "periodic", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"test::mpi::xlo", "test::mpi::xhi", "test::mpi::ylo", "test::mpi::yhi"}, {"Scalar"}, true); + PreparedBoundaryPlan plan("test::mpi::reflected-periodic", mapped_ng, std::move(boundary), {}, + "", {}, {reflected_x}); plan.fill_same_level_and_physical(mapped, mapped_domain); diff --git a/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp b/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp index ac257a3b5..78836b4a9 100644 --- a/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp +++ b/tests/cpp/integration/mpi/test_mpi_flux_failure_collective.cpp @@ -32,6 +32,15 @@ struct RecordOneFailure { } }; +struct RecordOneRecovery { + pops::FluxEvaluationRecorder recorder; + pops::RecoveryReport report; + + POPS_HD void operator()(int, int, std::uint64_t& failure) const { + recorder.record_recovery(report, failure); + } +}; + int run_mpi_flux_failure_collective(int argc, char** argv) { pops::comm_init(&argc, &argv); const int rank = pops::my_rank(); @@ -50,6 +59,21 @@ int run_mpi_flux_failure_collective(int argc, char** argv) { ++failures; } + { + pops::FluxEvaluationTracker tracker{pops::process_world_flux_collective}; + pops::RecoveryReport recovery; + recovery.status = + rank == 0 ? pops::RecoveryStatus::kRecovered : pops::RecoveryStatus::kRejected; + recovery.cause = + rank == 0 ? pops::RecoveryCause::kNone : pops::RecoveryCause::kExplicitRejection; + recovery.reason_code = rank == 0 ? 0u : 0x755u; + tracker.merge(pops::reduce_max_uint64_cell(pops::Box2D{{0, 0}, {0, 0}}, + RecordOneRecovery{tracker.recorder(), recovery})); + const pops::FluxFailureReport report = tracker.collective_report(); + if (report.status != pops::EvaluationStatus::kReject || report.reason_code != 0x755u) + ++failures; + } + { pops::FluxEvaluationTracker tracker{pops::process_world_flux_collective}; const auto status = rank == 0 ? pops::EvaluationStatus::kFailed : pops::EvaluationStatus::kOk; diff --git a/tests/cpp/integration/mpi/test_mpi_hdf5_collective.cpp b/tests/cpp/integration/mpi/test_mpi_hdf5_collective.cpp index 615d33c52..8f8a43df2 100644 --- a/tests/cpp/integration/mpi/test_mpi_hdf5_collective.cpp +++ b/tests/cpp/integration/mpi/test_mpi_hdf5_collective.cpp @@ -139,6 +139,7 @@ TEST(MpiHdf5Collective, WritesDisjointHyperslabsAndReopensNatively) { FAIL() << "this target must never be registered without native parallel HDF5"; #else auto& world = pops::WorldCommunicator::world(); + const auto communicator = world.communicator(); const int rank = world.rank(); const int ranks = world.size(); ASSERT_GE(rank, 0); @@ -180,7 +181,7 @@ TEST(MpiHdf5Collective, WritesDisjointHyperslabsAndReopensNatively) { local_values.size() * sizeof(double)}}}, }}; const std::string manifest = R"({"format":"native-test","version":1})"; - pops::runtime::output::write_collective_hdf5(world, path_text, manifest, arrays, fields); + pops::runtime::output::write_collective_hdf5(communicator, path_text, manifest, arrays, fields); std::string validation_error; if (rank == 0) { @@ -203,6 +204,7 @@ TEST(MpiHdf5Collective, RejectsOneRankInvalidDescriptorBeforeCreatingFile) { FAIL() << "this target must never be registered without native parallel HDF5"; #else auto& world = pops::WorldCommunicator::world(); + const auto communicator = world.communicator(); const int rank = world.rank(); const int ranks = world.size(); if (ranks < 2) @@ -240,7 +242,7 @@ TEST(MpiHdf5Collective, RejectsOneRankInvalidDescriptorBeforeCreatingFile) { std::string error; try { pops::runtime::output::write_collective_hdf5( - world, path, R"({"format":"native-invalid-test","version":1})", arrays, fields); + communicator, path, R"({"format":"native-invalid-test","version":1})", arrays, fields); } catch (const std::exception& failure) { error = failure.what(); } @@ -266,6 +268,7 @@ TEST(MpiHdf5Collective, RejectsCrossRankOverlappingHyperslabsBeforeCreatingFile) FAIL() << "this target must never be registered without native parallel HDF5"; #else auto& world = pops::WorldCommunicator::world(); + const auto communicator = world.communicator(); const int rank = world.rank(); const int ranks = world.size(); if (ranks < 2) @@ -303,7 +306,7 @@ TEST(MpiHdf5Collective, RejectsCrossRankOverlappingHyperslabsBeforeCreatingFile) std::string error; try { pops::runtime::output::write_collective_hdf5( - world, path, R"({"format":"native-overlap-test","version":1})", arrays, fields); + communicator, path, R"({"format":"native-overlap-test","version":1})", arrays, fields); } catch (const std::exception& failure) { error = failure.what(); } @@ -329,6 +332,7 @@ TEST(MpiHdf5Collective, RepeatedIdenticalWritesAreByteIdenticalAcrossTime) { FAIL() << "this target must never be registered without native parallel HDF5"; #else auto& world = pops::WorldCommunicator::world(); + const auto communicator = world.communicator(); const int rank = world.rank(); const int ranks = world.size(); const std::string first_path = shared_temporary_path(world, "native-parallel-hdf5-exact-a"); @@ -369,9 +373,9 @@ TEST(MpiHdf5Collective, RepeatedIdenticalWritesAreByteIdenticalAcrossTime) { }}; const std::string manifest = R"({"format":"native-exact-test","version":1})"; - pops::runtime::output::write_collective_hdf5(world, first_path, manifest, arrays, fields); + pops::runtime::output::write_collective_hdf5(communicator, first_path, manifest, arrays, fields); std::this_thread::sleep_for(std::chrono::milliseconds(1200)); - pops::runtime::output::write_collective_hdf5(world, second_path, manifest, arrays, fields); + pops::runtime::output::write_collective_hdf5(communicator, second_path, manifest, arrays, fields); std::string validation_error; if (rank == 0) { diff --git a/tests/cpp/integration/mpi/test_mpi_load_balance_authority.cpp b/tests/cpp/integration/mpi/test_mpi_load_balance_authority.cpp index 99d36257f..357a66fbf 100644 --- a/tests/cpp/integration/mpi/test_mpi_load_balance_authority.cpp +++ b/tests/cpp/integration/mpi/test_mpi_load_balance_authority.cpp @@ -142,6 +142,43 @@ int run_mpi_load_balance_authority(int argc, char** argv) { if (owner < 0 || owner >= ranks) ++failures; + // The prepared authority, not the migration consumer, owns cost interpretation. Start from an + // intentionally concentrated map so the measured uniform workload produces a deterministic + // beneficial proposal and an exact topology-qualified RebalanceDecision on every rank. + constexpr std::uint64_t topology_epoch = 10; + constexpr std::uint64_t materialization_generation = 20; + std::vector estimates(static_cast(box_count)); + for (ResourceEstimate& estimate : estimates) { + estimate.topology_epoch = topology_epoch; + estimate.materialization_generation = materialization_generation; + estimate.samples = 1; + estimate.cell_updates = 1; + estimate.compute_nanoseconds = 1000; + estimate.memory_bytes = 64; + estimate.resident_bytes = 64; + } + RebalancePolicy policy; + policy.minimum_improvement_ppm = 0; + policy.amortization_steps = 100; + policy.migration_bandwidth_bytes_per_second = 1'000'000'000'000LL; + policy.per_patch_migration_latency_nanoseconds = 0; + const DistributionMapping concentrated(std::vector(static_cast(box_count), 0)); + const RebalanceDecision beneficial = authority.decide_rebalance( + 1, boxes, concentrated, ranks, topology_epoch, materialization_generation, estimates, policy); + if (!beneficial.accepted || beneficial.reason != RebalanceReason::NetBenefit || + beneficial.moved_patches <= 0 || + beneficial.proposed_mapping.ranks() == concentrated.ranks() || + beneficial.exact_contract != detail::exact_rebalance_decision(beneficial)) + ++failures; + + const RebalanceDecision unchanged = + authority.decide_rebalance(1, boxes, beneficial.proposed_mapping, ranks, topology_epoch, + materialization_generation, estimates, policy); + if (unchanged.accepted || unchanged.reason != RebalanceReason::MappingUnchanged || + unchanged.moved_patches != 0 || + unchanged.exact_contract != detail::exact_rebalance_decision(unchanged)) + ++failures; + if (ranks > 1) { auto divergent_weights = weights; if (rank == 1) diff --git a/tests/cpp/integration/mpi/test_mpi_multiblock_interface_scheduler.cpp b/tests/cpp/integration/mpi/test_mpi_multiblock_interface_scheduler.cpp index 144db15f0..6e3363789 100644 --- a/tests/cpp/integration/mpi/test_mpi_multiblock_interface_scheduler.cpp +++ b/tests/cpp/integration/mpi/test_mpi_multiblock_interface_scheduler.cpp @@ -1,7 +1,7 @@ #include -#include "amr_transfer_test_authority.hpp" #include "amr_tagging_test_authority.hpp" +#include "amr_transfer_test_authority.hpp" #include "gtest_compat.hpp" #include #include @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -40,7 +41,33 @@ void authenticate_cell_average_trace(AxisAlignedInterface& route) { route.right_trace_required_depth = 1; } -PopsExecutionContextV1 mpi_world_execution() { +class ScopedMpiCommunicator { + public: + explicit ScopedMpiCommunicator(MPI_Comm source) { + if (MPI_Comm_dup(source, &communicator_) != MPI_SUCCESS) + throw std::runtime_error("MPI_Comm_dup failed for the interface scheduler test lane"); + if (MPI_Comm_set_errhandler(communicator_, MPI_ERRORS_RETURN) != MPI_SUCCESS) { + MPI_Comm_free(&communicator_); + throw std::runtime_error( + "MPI_Comm_set_errhandler failed for the interface scheduler test lane"); + } + } + + ~ScopedMpiCommunicator() { + if (communicator_ != MPI_COMM_NULL) + MPI_Comm_free(&communicator_); + } + + ScopedMpiCommunicator(const ScopedMpiCommunicator&) = delete; + ScopedMpiCommunicator& operator=(const ScopedMpiCommunicator&) = delete; + + MPI_Comm get() const { return communicator_; } + + private: + MPI_Comm communicator_ = MPI_COMM_NULL; +}; + +PopsExecutionContextV1 mpi_lane_execution(MPI_Comm communicator) { return {sizeof(PopsExecutionContextV1), 1u, "test::mpi-multiblock-execution", @@ -54,9 +81,9 @@ PopsExecutionContextV1 mpi_world_execution() { POPS_PRECISION_FLOAT64_V1, 0, "host::synchronous", - static_cast(MPI_Comm_c2f(MPI_COMM_WORLD)), + static_cast(MPI_Comm_c2f(communicator)), static_cast(MPI_Type_c2f(MPI_DOUBLE)), - "MPI_COMM_WORLD", + "test::mpi-multiblock-interface-lane", "MPI_DOUBLE"}; } @@ -108,6 +135,284 @@ bool field_is_zero(const MultiFab& field) { return true; } +template +void append_exact(std::string& bytes, const Value& value) { + bytes.append(reinterpret_cast(&value), sizeof(Value)); +} + +void append_exact_text(std::string& bytes, const std::string& value) { + append_exact(bytes, static_cast(value.size())); + bytes.append(value); +} + +std::string exact_layout_identity(const MultiFab& field) { + std::string bytes; + const auto& boxes = field.box_array().boxes(); + const auto& ranks = field.dmap().ranks(); + append_exact(bytes, static_cast(boxes.size())); + for (std::size_t index = 0; index < boxes.size(); ++index) { + const Box2D& box = boxes[index]; + append_exact(bytes, box.lo[0]); + append_exact(bytes, box.lo[1]); + append_exact(bytes, box.hi[0]); + append_exact(bytes, box.hi[1]); + append_exact(bytes, ranks[index]); + } + return bytes; +} + +template +std::string exact_fragment_identity(const Fragment& fragment) { + std::string bytes; + append_exact_text(bytes, fragment.key.interface_identity); + append_exact(bytes, fragment.key.topology_epoch); + append_exact(bytes, fragment.key.coarse_level); + append_exact(bytes, fragment.key.fine_level); + append_exact(bytes, fragment.key.clock.level); + append_exact(bytes, fragment.key.clock.macro_step); + append_exact(bytes, fragment.key.clock.phase.numerator); + append_exact(bytes, fragment.key.clock.phase.denominator); + append_exact(bytes, fragment.key.clock.physical_time); + append_exact_text(bytes, fragment.key.stage_identity); + append_exact(bytes, fragment.key.interval.begin.level); + append_exact(bytes, fragment.key.interval.begin.macro_step); + append_exact(bytes, fragment.key.interval.begin.phase.numerator); + append_exact(bytes, fragment.key.interval.begin.phase.denominator); + append_exact(bytes, fragment.key.interval.begin.physical_time); + append_exact(bytes, fragment.key.interval.end.level); + append_exact(bytes, fragment.key.interval.end.macro_step); + append_exact(bytes, fragment.key.interval.end.phase.numerator); + append_exact(bytes, fragment.key.interval.end.phase.denominator); + append_exact(bytes, fragment.key.interval.end.physical_time); + append_exact(bytes, fragment.key.orientation); + append_exact(bytes, fragment.key.left_block); + append_exact(bytes, fragment.key.right_block); + append_exact(bytes, fragment.measure.stage_weight.numerator); + append_exact(bytes, fragment.measure.stage_weight.denominator); + append_exact(bytes, fragment.measure.stage_weight_resolved); + append_exact(bytes, fragment.measure.substep_duration); + append_exact(bytes, fragment.measure.face_measure); + append_exact(bytes, static_cast(fragment.payload.size())); + for (const Real value : fragment.payload) + append_exact(bytes, value); + return bytes; +} + +struct OneShotRematerializationFailure { + std::shared_ptr fail_next_copy; + std::array* evaluator_calls = nullptr; + int level = 0; + + OneShotRematerializationFailure(std::shared_ptr fail, std::array* calls, int k) + : fail_next_copy(std::move(fail)), evaluator_calls(calls), level(k) {} + + OneShotRematerializationFailure(const OneShotRematerializationFailure& other) + : fail_next_copy(other.fail_next_copy), + evaluator_calls(other.evaluator_calls), + level(other.level) { + if (*fail_next_copy && my_rank() == 1) { + *fail_next_copy = false; + throw std::runtime_error("injected rank-local interface rematerialization failure"); + } + } + + OneShotRematerializationFailure(OneShotRematerializationFailure&&) noexcept = default; + OneShotRematerializationFailure& operator=(const OneShotRematerializationFailure&) = default; + OneShotRematerializationFailure& operator=(OneShotRematerializationFailure&&) noexcept = default; + + void operator()(const BoundaryEvaluationPoint&, const InterfaceFluxBatch& batch) const { + ++(*evaluator_calls)[static_cast(level)]; + for (int face = 0; face < batch.face_count; ++face) + for (int component = 0; component < batch.component_count; ++component) + batch.shared_flux[static_cast(face) * batch.component_count + component] = + Real(level + face + component + 1); + } +}; + +AmrRuntime make_dynamic_mpi_interface_runtime( + std::array& evaluator_calls, + const std::shared_ptr& fail_next_rematerialization_copy) { + constexpr int cells = 4; + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{true, true}; + params.mesh.n = cells; + params.mesh.L = 1.0; + params.mesh.regrid_every = 1; + params.mesh.distribute_coarse = true; + params.mesh.coarse_max_grid = 2; + params.poisson.bc = BCRec{}; + detail::SharedAmrLayout layout = detail::make_shared_amr_layout_levels(params, 2); + layout.ba[1] = BoxArray(std::vector{layout.geom.domain.refine(kAmrRefRatio)}); + layout.dm[1] = layout.load_balance->distribute(layout.ba[1], n_ranks()); + + std::vector blocks; + for (const char* name : {"left", "right"}) { + AmrRuntimeBlock block = detail::dispatch_amr_block( + scalar_model(), "none", "rusanov", layout, name, + std::vector(static_cast(cells) * cells, 1.0), true, 1.4, 1, false, 1); + block.state_identity = std::string("test://dynamic-mpi-interface/block/") + name + "/state/U"; + const auto omit_local_interface = [](MultiFab&, const MultiFab&, const Geometry&, MultiFab& fx, + MultiFab& fy, MultiFab& rhs) { + fx.set_val(Real(0)); + fy.set_val(Real(0)); + rhs.set_val(Real(0)); + }; + block.level_flux_capture = omit_local_interface; + block.level_flux_capture_neg_div = omit_local_interface; + block.level_rhs_without_prepared_interfaces = [](const BoundaryEvaluationPoint&, MultiFab&, + const MultiFab&, const Geometry&, + MultiFab& rhs) { rhs.set_val(Real(0)); }; + block.level_neg_div_flux_without_prepared_interfaces = + block.level_rhs_without_prepared_interfaces; + blocks.push_back(std::move(block)); + } + + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, std::move(blocks), + layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 2); + runtime.set_parent_child_temporal_relations({amr::ParentChildClockRelation( + 0, 1, amr::Rational(2, 1), amr::RemainderPolicy::IntegralOnly)}); + runtime.set_regrid(/*every=*/1, /*grow=*/0, /*margin=*/0); + + const PopsExecutionContextV1 execution = mpi_world_execution(); + for (int level = 0; level < 2; ++level) { + AxisAlignedInterface route; + route.identity = "mpi-two-rank.dynamic-refined-shared-flux"; + route.left_block = 0; + route.right_block = 1; + route.level = level; + route.left_axis = route.right_axis = InterfaceAxis::X; + route.left_side = InterfaceSide::High; + route.right_side = InterfaceSide::Low; + route.right_component_for_left = {0}; + route.affine_mapping_identity = "periodic-x-translation"; + route.right_normal_translation = Real(1); + authenticate_cell_average_trace(route); + runtime.install_level_interface_flux( + level, std::move(route), execution, + InterfaceFluxEvaluator(OneShotRematerializationFailure{fail_next_rematerialization_copy, + &evaluator_calls, level})); + } + runtime.require_complete_active_level_interfaces(); + return runtime; +} + +long exercise_dynamic_refined_interface_rematerialization() { + long failures = 0; + const auto require = [&failures](bool condition) { + if (!condition) + ++failures; + }; + + std::array evaluator_calls{0, 0}; + auto fail_next_rematerialization_copy = std::make_shared(false); + AmrRuntime runtime = + make_dynamic_mpi_interface_runtime(evaluator_calls, fail_next_rematerialization_copy); + const std::string interface_identity = "mpi-two-rank.dynamic-refined-shared-flux"; + const std::string accepted_layout = exact_layout_identity(runtime.level_state(0, 1)); + const std::uint64_t accepted_epoch = runtime.topology_epoch(); + + const auto evaluate_level = [&](std::int64_t tick) { + MultiFab& left = runtime.level_state(0, 1); + MultiFab& right = runtime.level_state(1, 1); + MultiFab left_rhs(left.box_array(), left.dmap(), 1, 0); + MultiFab right_rhs(right.box_array(), right.dmap(), 1, 0); + const BoundaryEvaluationPoint point{"clock.dynamic-mpi-interface", tick, 1, 0, 0, + amr::Rational(0, 1), 0.05, 0.05 * tick}; + runtime.level_rhs_with_interfaces(1, point, {&left, &right}, {&left_rhs, &right_rhs}); + require(all_reduce_sum(field_is_zero(left_rhs) ? 0L : 1L) > 0); + require(all_reduce_sum(field_is_zero(right_rhs) ? 0L : 1L) > 0); + }; + + evaluate_level(1); + require(evaluator_calls == (std::array{0, 1})); + require(runtime.interface_evaluation_count(interface_identity, 1) == 1u); + + runtime.set_clustering(/*min_efficiency=*/1.0, /*min_box_size=*/1, /*max_box_size=*/2); + test::install_prepared_threshold_union(runtime, {{0, 0, Real(0.5)}, {1, 0, Real(0.5)}}, + "test::dynamic-mpi-interface-full-domain@1"); + *fail_next_rematerialization_copy = my_rank() == 1; + bool collective_failure_observed = false; + try { + runtime.regrid(); + } catch (const std::runtime_error& error) { + const std::string message(error.what()); + collective_failure_observed = + my_rank() == 1 + ? message.find("injected rank-local interface rematerialization failure") != + std::string::npos + : message.find("replacement route/layout preflight failed on another MPI rank") != + std::string::npos; + } + require(collective_failure_observed); + require(runtime.topology_epoch() == accepted_epoch); + require(runtime.regrid_count() == 0); + require(exact_layout_identity(runtime.level_state(0, 1)) == accepted_layout); + require(runtime.interface_evaluation_count(interface_identity, 1) == 1u); + runtime.require_complete_active_level_interfaces(); + + evaluate_level(2); + require(evaluator_calls == (std::array{0, 2})); + require(runtime.interface_evaluation_count(interface_identity, 1) == 2u); + + runtime.regrid(); + require(runtime.topology_epoch() > accepted_epoch); + require(runtime.regrid_count() == 1); + const std::string replacement_layout = exact_layout_identity(runtime.level_state(0, 1)); + require(replacement_layout != accepted_layout); + require(all_ranks_agree_exact_ordered_byte_pairs( + {{std::string_view("dynamic-refined-layout"), std::string_view(replacement_layout)}})); + runtime.require_complete_active_level_interfaces(); + + MultiFab& left = runtime.level_state(0, 1); + MultiFab& right = runtime.level_state(1, 1); + MultiFab left_rhs(left.box_array(), left.dmap(), 1, 0); + MultiFab right_rhs(right.box_array(), right.dmap(), 1, 0); + left_rhs.set_val(Real(0)); + right_rhs.set_val(Real(0)); + const BoundaryEvaluationPoint point{"clock.dynamic-mpi-interface", 3, 1, 0, 1, + amr::Rational(1, 2), 0.05, 0.125}; + InterfaceFluxFragmentLedger ledger(runtime.topology_epoch()); + ledger.begin(); + const amr::ClockWindow interval{{1, 3, amr::Rational(0, 1), 0.1}, + {1, 3, amr::Rational(1, 1), 0.15}}; + InterfaceFluxFragmentPublication publication{&ledger, + runtime.topology_epoch(), + 2, + amr::ClockStamp{1, 3, amr::Rational(1, 2), 0.125}, + "program.group.dynamic-refined-mpi", + interval, + amr::Rational(1, 2)}; + runtime.publish_level_interface_flux_fragments(1, point, {0, 1}, {&left, &right}, + {&left_rhs, &right_rhs}, publication); + require(evaluator_calls == (std::array{0, 3})); + require(runtime.interface_evaluation_count(interface_identity, 1) == 3u); + require(ledger.pending_size() == 1u); + if (ledger.pending_size() == 1u) { + const auto& fragment = ledger.pending_entries().front(); + require(fragment.key.interface_identity == interface_identity); + require(fragment.key.topology_epoch == runtime.topology_epoch()); + require(fragment.key.coarse_level == 0 && fragment.key.fine_level == 1); + require(fragment.key.clock == publication.clock); + require(fragment.key.stage_identity == publication.stage_identity); + require(fragment.key.interval.begin == interval.begin && + fragment.key.interval.end == interval.end); + require(fragment.key.orientation == amr::InterfaceFluxOrientation::FineOutward); + require(fragment.measure.stage_weight == amr::Rational(1, 2)); + require(fragment.measure.stage_weight_resolved); + require(fragment.measure.substep_duration == point.dt); + const std::string fragment_identity = exact_fragment_identity(fragment); + require(all_ranks_agree_exact_ordered_byte_pairs( + {{std::string_view("dynamic-refined-fragment"), std::string_view(fragment_identity)}})); + } + ledger.commit(); + require(ledger.published_size() == 1u); + require(all_reduce_sum(field_is_zero(left_rhs) ? 0L : 1L) > 0); + require(all_reduce_sum(field_is_zero(right_rhs) ? 0L : 1L) > 0); + return failures; +} + int run_mpi_multiblock_interface_scheduler(int argc, char** argv) { comm_init(&argc, &argv); long failures = 0; @@ -123,6 +428,11 @@ int run_mpi_multiblock_interface_scheduler(int argc, char** argv) { { try { require(n_ranks() == 2); + const ScopedMpiCommunicator interface_lane(MPI_COMM_WORLD); + int world_relation = MPI_UNEQUAL; + require(MPI_Comm_compare(interface_lane.get(), MPI_COMM_WORLD, &world_relation) == + MPI_SUCCESS); + require(world_relation == MPI_CONGRUENT); const Box2D left_domain{{0, 0}, {1, 3}}; const Box2D right_domain{{2, 0}, {3, 3}}; @@ -153,7 +463,7 @@ int run_mpi_multiblock_interface_scheduler(int argc, char** argv) { const Geometry left_geometry{left_domain, Real(0), Real(1), Real(0), Real(1)}; const Geometry right_geometry{right_domain, Real(1), Real(2), Real(0), Real(1)}; - const PopsExecutionContextV1 execution = mpi_world_execution(); + const PopsExecutionContextV1 execution = mpi_lane_execution(interface_lane.get()); const BoundaryEvaluationPoint point{"clock.mpi-interface", 3, 0, 0, 1, amr::Rational(1, 1), 0.125, 0.375}; @@ -176,6 +486,14 @@ int run_mpi_multiblock_interface_scheduler(int argc, char** argv) { batch.shared_flux[offset] = shared_flux(face, component); } }); + bool implicit_mpi_rejected = false; + try { + scheduler.require_exact_jacvec_pair(0, 0, 1); + } catch (const std::runtime_error& error) { + implicit_mpi_rejected = + std::string(error.what()).find("serial rank-one") != std::string::npos; + } + require(implicit_mpi_rejected); std::vector states{&left_state, &right_state}; std::vector rhs{&left_rhs, &right_rhs}; scheduler.apply(point, states, rhs); @@ -759,6 +1077,8 @@ int run_mpi_multiblock_interface_scheduler(int argc, char** argv) { : message.find("preflight failed on another MPI rank") != std::string::npos; } require(incomplete_registry_rejected); + + failures += exercise_dynamic_refined_interface_rematerialization(); } catch (const std::exception& error) { ++failures; std::cerr << "rank " << my_rank() diff --git a/tests/cpp/integration/mpi/test_mpi_nd_translation_completion_failstop.cpp b/tests/cpp/integration/mpi/test_mpi_nd_translation_completion_failstop.cpp new file mode 100644 index 000000000..4ed077f5e --- /dev/null +++ b/tests/cpp/integration/mpi/test_mpi_nd_translation_completion_failstop.cpp @@ -0,0 +1,79 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace { + +using namespace pops; +using namespace pops::mesh::nd_proof; + +constexpr char kCompletionFailstopToken[] = "POPS_ND_COMPLETION_FAILSTOP_OBSERVED"; +TranslationExchange<1>* g_exchange = nullptr; + +[[noreturn]] void completion_terminate_handler() noexcept { + const bool verified = + g_exchange != nullptr && g_exchange->sealed() && + g_exchange->diagnostic_stage() == TranslationExchangeDiagnosticStage::completion && + g_exchange->live_request_count() == 0; + std::fputs(verified ? kCompletionFailstopToken : "POPS_ND_COMPLETION_FAILSTOP_INVALID", stderr); + std::fputc('\n', stderr); + std::fflush(stderr); + std::_Exit(verified ? 0 : 2); +} + +TranslationSchedule<1> completion_schedule() { + const Box<1> domain{Index<1>{0}, Index<1>{1}}; + const BoxArray<1> layout(std::vector>{domain}); + const RankSpace<1> ranks{Index<1>{}, Extent<1>{1}}; + const Distribution<1> distribution = + Distribution<1>::partitioned(layout, ranks, std::vector>{Index<1>{}}); + return TranslationSchedule<1>{ + layout, + distribution, + domain, + PeriodicTopology<1>::axis_translations(std::array{true}), + Extent<1>{1}, + 1, + 0, + 1, + Index<1>{}, + std::array{2}, + BoxHashBudget{64, 64, 64}, + TranslationScheduleBudget{64, 8, 256, 256, 256, + LocalNeighborWorkBudget{64, 64, {64, 4096}, {4096, 4096}}}}; +} + +} // namespace + +int main(int argc, char** argv) { + try { + comm_init(&argc, &argv); + Kokkos::ScopeGuard kokkos(argc, argv); + auto lane = ExecutionLane::duplicate_world_collectively("nd-exchange-completion-failstop"); + auto schedule = completion_schedule(); + if (schedule.local_job_count() == 0) + return 10; + MultiFab<1> fields(schedule.layout(), schedule.distribution(), Index<1>{}, 1, + schedule.ghosts()); + TranslationExchangeContext context{131, 137}; + context.fail_completion_rank = 0; + TranslationExchange<1> exchange(schedule, lane, context); + g_exchange = &exchange; + std::set_terminate(completion_terminate_handler); + try { + exchange.execute(fields, lane); + } catch (...) { + return 11; + } + return 12; + } catch (...) { + return 13; + } +} diff --git a/tests/cpp/integration/mpi/test_mpi_nd_translation_exchange.cpp b/tests/cpp/integration/mpi/test_mpi_nd_translation_exchange.cpp new file mode 100644 index 000000000..c8ffbbd4d --- /dev/null +++ b/tests/cpp/integration/mpi/test_mpi_nd_translation_exchange.cpp @@ -0,0 +1,414 @@ +#include + +#include "gtest_compat.hpp" +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace pops; +using namespace pops::mesh::nd_proof; + +static_assert(std::is_nothrow_move_assignable_v); + +namespace { + +constexpr Real kGhost = Real{-777}; + +template +TranslationScheduleBudget budget() { + return TranslationScheduleBudget{ + 4096, 128, 65536, + 65536, 65536, LocalNeighborWorkBudget{4096, 4096, {4096, 1'000'000}, {1'000'000, 1'000'000}}}; +} + +template +Index rank_coordinate(int rank) { + Index coordinate{}; + coordinate[0] = rank; + return coordinate; +} + +template +Real value_for(const Index& index, int component, Real bias) { + Real value = bias + static_cast(component * 10'000); + Real scale = Real{1}; + for (int axis = 0; axis < Dim; ++axis) { + value += scale * static_cast(index[axis]); + scale *= Real{97}; + } + return value; +} + +template +Index index_from_cell(const Box& box, std::size_t cell) { + Index index{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t extent = static_cast(box.length(axis)); + index[axis] = box.lo[axis] + static_cast(cell % extent); + cell /= extent; + } + return index; +} + +template +TranslationSchedule make_schedule(int ranks, int rank, bool replicated, int boxes_per_rank, + int ghost, int first_component = 1, + int component_count = 2) { + Index lower{}; + Index upper{}; + upper[0] = ranks * boxes_per_rank * 2 - 1; + for (int axis = 1; axis < Dim; ++axis) + upper[axis] = 2; + const Box domain{lower, upper}; + + std::vector> boxes; + std::vector> owners; + boxes.reserve(static_cast(ranks * boxes_per_rank)); + owners.reserve(static_cast(ranks * boxes_per_rank)); + for (int box = 0; box < ranks * boxes_per_rank; ++box) { + Index box_lower = lower; + Index box_upper = upper; + box_lower[0] = 2 * box; + box_upper[0] = 2 * box + 1; + boxes.push_back(Box{box_lower, box_upper}); + owners.push_back(rank_coordinate(box % ranks)); + } + const BoxArray layout(std::move(boxes)); + Extent rank_extent{}; + rank_extent[0] = ranks; + for (int axis = 1; axis < Dim; ++axis) + rank_extent[axis] = 1; + const RankSpace rank_space{Index{}, rank_extent}; + const Distribution distribution = + replicated ? Distribution::replicated(layout, rank_space) + : Distribution::partitioned(layout, rank_space, std::move(owners)); + Extent ghosts{}; + ghosts[0] = ghost; + for (int axis = 1; axis < Dim; ++axis) + ghosts[axis] = 1; + std::array hash_bins{}; + hash_bins.fill(2); + std::array periodic{}; + periodic[0] = true; + return TranslationSchedule{layout, + distribution, + domain, + PeriodicTopology::axis_translations(periodic), + ghosts, + 3, + first_component, + component_count, + rank_coordinate(rank), + hash_bins, + BoxHashBudget{4096, 4096, 4096}, + budget()}; +} + +template +void fill_valid(MultiFab& fields, Real bias) { + for (std::size_t global_box : fields.local_global_indices()) { + auto& fab = fields.fab(global_box); + auto host = fab.create_host_mirror(); + const Box& grown = fab.grown_box(); + const std::size_t cells = static_cast(grown.numPts()); + for (int component = 0; component < fab.ncomp(); ++component) + for (std::size_t cell = 0; cell < cells; ++cell) { + const Index index = index_from_cell(grown, cell); + host(static_cast(component) * cells + cell) = + fab.box().contains(index) ? value_for(index, component, bias) : kGhost; + } + fab.copy_from_host(host); + } +} + +template +Real value_at(const MultiFab& fields, std::size_t global_box, const Index& index, + int component) { + const auto& fab = fields.fab(global_box); + const Box& grown = fab.grown_box(); + std::size_t stride = 1; + std::size_t cell = 0; + for (int axis = 0; axis < Dim; ++axis) { + cell += static_cast(index[axis] - grown.lo[axis]) * stride; + stride *= static_cast(grown.length(axis)); + } + auto host = fab.create_host_mirror(); + fab.copy_to_host(host); + return host(static_cast(component) * stride + cell); +} + +template +void expect_replayed(const TranslationSchedule& schedule, const MultiFab& fields, + Real bias, bool check_untouched) { + const auto expect_job = [&](const typename TranslationSchedule::Job& job) { + for (int component = schedule.first_component(); + component < schedule.first_component() + schedule.component_count(); ++component) + for (std::size_t cell = 0; cell < static_cast(job.destination_region.numPts()); + ++cell) { + const Index destination = index_from_cell(job.destination_region, cell); + Index source{}; + for (int axis = 0; axis < Dim; ++axis) + source[axis] = static_cast(static_cast(destination[axis]) + + job.source_from_destination[axis]); + EXPECT_EQ(value_at(fields, job.destination_box, destination, component), + value_for(source, component, bias)); + if (check_untouched) + EXPECT_EQ(value_at(fields, job.destination_box, destination, 0), kGhost); + } + }; + for (const auto& job : schedule.local_jobs()) + expect_job(job); + for (const auto& plan : schedule.receive_plans()) + for (const auto& job : plan.jobs) + expect_job(job); +} + +template +MultiFab make_fields(const TranslationSchedule& schedule, int rank, Real bias) { + MultiFab fields(schedule.layout(), schedule.distribution(), rank_coordinate(rank), 3, + schedule.ghosts()); + fill_valid(fields, bias); + return fields; +} + +template +void expect_cross_rank_plan_structure(const TranslationSchedule& schedule, + const ExecutionLane& lane) { + bool multi_job_receive = false; + bool later_job_offset = false; + bool periodic_remote_job = false; + for (const auto& plan : schedule.receive_plans()) { + multi_job_receive = multi_job_receive || plan.jobs.size() > 1; + for (const auto& job : plan.jobs) { + later_job_offset = later_job_offset || job.offset > 0; + for (int axis = 0; axis < Dim; ++axis) + periodic_remote_job = periodic_remote_job || job.source_from_destination[axis] != 0; + } + } + + // The alternating two-box-per-rank layout gives every rank a multi-job receive plan with a + // checked later offset. Only the two end ranks own periodic-wrap receives, so that witness is + // intentionally collective-global rather than per-rank. + EXPECT_TRUE(multi_job_receive); + EXPECT_TRUE(later_job_offset); + EXPECT_EQ(all_reduce_max(multi_job_receive ? 0L : 1L, lane.communicator()), 0L); + EXPECT_EQ(all_reduce_max(later_job_offset ? 0L : 1L, lane.communicator()), 0L); + EXPECT_EQ(all_reduce_max(periodic_remote_job ? 1L : 0L, lane.communicator()), 1L); +} + +template +void expect_two_replays(int ranks, int rank, bool replicated) { + auto schedule = make_schedule(ranks, rank, replicated, 2, 1); + auto lane = ExecutionLane::duplicate_world_collectively("nd-exchange-replay"); + TranslationExchange exchange(schedule, lane, TranslationExchangeContext{17, 23}); + auto fields = make_fields(schedule, rank, Real{0}); + EXPECT_TRUE(lane.owns_communicator()); + EXPECT_EQ(exchange.diagnostic_stage(), TranslationExchangeDiagnosticStage::none); + exchange.execute(fields, lane); + expect_replayed(schedule, fields, Real{0}, true); + EXPECT_EQ(exchange.live_request_count(), 0U); + exchange.execute(fields, lane); + expect_replayed(schedule, fields, Real{0}, true); + EXPECT_FALSE(exchange.sealed()); + EXPECT_EQ(exchange.diagnostic_stage(), TranslationExchangeDiagnosticStage::none); + EXPECT_EQ(exchange.live_request_count(), 0U); +} + +void expect_unborrowed_lane_move_assignment() { + auto destination = ExecutionLane::duplicate_world_collectively("nd-exchange-move-destination"); + auto source = ExecutionLane::duplicate_world_collectively("nd-exchange-move-source"); + const std::string source_identity(source.identity()); + destination = std::move(source); + EXPECT_EQ(destination.identity(), source_identity); + EXPECT_TRUE(destination.active()); + EXPECT_FALSE(source.active()); + destination = std::move(destination); + EXPECT_EQ(destination.identity(), source_identity); + EXPECT_TRUE(destination.active()); +} + +template +void expect_collective_constructor_failure(const TranslationSchedule& schedule, + const ExecutionLane& lane, + TranslationExchangeContext context) { + bool threw = false; + try { + TranslationExchange exchange(schedule, lane, context); + (void)exchange; + } catch (const std::exception&) { + threw = true; + } + EXPECT_EQ(all_reduce_max(threw ? 0L : 1L, lane.communicator()), 0L); +} + +template +void expect_sealed_failure(const TranslationSchedule& schedule, const ExecutionLane& lane, + TranslationExchangeContext context, + TranslationExchangeDiagnosticStage expected_stage) { + TranslationExchange exchange(schedule, lane, context); + auto fields = make_fields(schedule, lane.rank(), Real{0}); + bool threw = false; + try { + exchange.execute(fields, lane); + } catch (const std::exception&) { + threw = true; + } + EXPECT_EQ(all_reduce_max(threw ? 0L : 1L, lane.communicator()), 0L); + EXPECT_TRUE(exchange.sealed()); + EXPECT_EQ(exchange.diagnostic_stage(), expected_stage); + EXPECT_EQ(exchange.live_request_count(), 0U); + EXPECT_THROW(exchange.execute(fields, lane), std::runtime_error); +} + +int run_mpi_nd_translation_exchange(int argc, char** argv) { + comm_init(&argc, &argv); + int result = 0; + { + Kokkos::ScopeGuard kokkos(argc, argv); + const int rank = my_rank(); + const int ranks = n_ranks(); + EXPECT_GE(mpi_thread_level(), MPI_THREAD_MULTIPLE); + expect_unborrowed_lane_move_assignment(); + + if (ranks == 1) { + expect_two_replays<1>(ranks, rank, false); + expect_two_replays<2>(ranks, rank, false); + expect_two_replays<3>(ranks, rank, false); + expect_two_replays<1>(ranks, rank, true); + expect_two_replays<2>(ranks, rank, true); + expect_two_replays<3>(ranks, rank, true); + } + + if (ranks >= 2) { + auto schedule_1d = make_schedule<1>(ranks, rank, false, 2, ranks == 2 ? 1 : 3); + auto lane = ExecutionLane::duplicate_world_collectively("nd-exchange-traffic"); + TranslationExchange<1> exchange(schedule_1d, lane, TranslationExchangeContext{31, 37}); + auto fields = make_fields(schedule_1d, rank, Real{0}); + EXPECT_GT(schedule_1d.send_plan_count(), 0U); + EXPECT_GT(schedule_1d.receive_plan_count(), 0U); + EXPECT_GT(exchange.peer_count(), 0U); + expect_cross_rank_plan_structure(schedule_1d, lane); + exchange.execute(fields, lane); + expect_replayed(schedule_1d, fields, Real{0}, true); + EXPECT_EQ(exchange.live_request_count(), 0U); + + auto schedule_2d = make_schedule<2>(ranks, rank, false, 2, ranks == 2 ? 1 : 3); + auto fields_2d = make_fields(schedule_2d, rank, Real{0}); + TranslationExchange<2> exchange_2d(schedule_2d, lane, TranslationExchangeContext{41, 43}); + if (ranks >= 4) + EXPECT_GE(exchange_2d.peer_count(), 2U); + expect_cross_rank_plan_structure(schedule_2d, lane); + exchange_2d.execute(fields_2d, lane); + expect_replayed(schedule_2d, fields_2d, Real{0}, true); + + auto schedule_3d = make_schedule<3>(ranks, rank, false, 2, ranks == 2 ? 1 : 3); + auto fields_3d = make_fields(schedule_3d, rank, Real{0}); + TranslationExchange<3> exchange_3d(schedule_3d, lane, TranslationExchangeContext{47, 53}); + if (ranks >= 4) + EXPECT_GE(exchange_3d.peer_count(), 2U); + expect_cross_rank_plan_structure(schedule_3d, lane); + exchange_3d.execute(fields_3d, lane); + expect_replayed(schedule_3d, fields_3d, Real{0}, true); + + expect_collective_constructor_failure( + schedule_1d, lane, + TranslationExchangeContext{static_cast(rank == 0 ? 59 : 61), 67}); + expect_collective_constructor_failure( + schedule_1d, lane, + TranslationExchangeContext{71, static_cast(rank == 0 ? 73 : 79)}); + expect_collective_constructor_failure( + schedule_1d, lane, TranslationExchangeContext{83, 89, 2, rank == 0 ? 0 : -1}); + + expect_sealed_failure(schedule_1d, lane, + TranslationExchangeContext{79, 83, 2, -1, rank == 0 ? 0 : -1}, + TranslationExchangeDiagnosticStage::receive_post); + expect_sealed_failure(schedule_1d, lane, + TranslationExchangeContext{89, 97, 2, -1, -1, rank == 0 ? 0 : -1}, + TranslationExchangeDiagnosticStage::send_post); + expect_sealed_failure(schedule_1d, lane, + TranslationExchangeContext{101, 103, 2, -1, -1, -1, rank == 0 ? 0 : -1}, + TranslationExchangeDiagnosticStage::wait); + } + + if (ranks >= 2) { + auto lane_a = ExecutionLane::duplicate_world_collectively("nd-exchange-concurrent-a"); + auto lane_b = ExecutionLane::duplicate_world_collectively("nd-exchange-concurrent-b"); + auto schedule_a = make_schedule<1>(ranks, rank, false, 2, 1); + auto schedule_b = make_schedule<1>(ranks, rank, false, 2, 1); + auto fields_a = make_fields(schedule_a, rank, Real{0}); + auto fields_b = make_fields(schedule_b, rank, Real{1'000'000}); + TranslationExchangeContext context_a{107, 109}; + TranslationExchangeContext context_b{113, 127}; + EXPECT_NE(lane_a.identity(), lane_b.identity()); + EXPECT_EQ(context_a.tag, context_b.tag); + EXPECT_EQ(context_a.tag, ExecutionLane::translation_message_tag); + EXPECT_NE(context_a.context_generation, 0U); + EXPECT_NE(context_b.context_generation, 0U); + EXPECT_NE(context_a.schedule_generation, 0U); + EXPECT_NE(context_b.schedule_generation, 0U); + EXPECT_NE(context_a.context_generation, context_b.context_generation); + EXPECT_NE(context_a.schedule_generation, context_b.schedule_generation); + int lane_relation = MPI_UNEQUAL; + EXPECT_EQ(MPI_Comm_compare(lane_a.native_handle(), lane_b.native_handle(), &lane_relation), + MPI_SUCCESS); + EXPECT_EQ(lane_relation, MPI_CONGRUENT); + TranslationExchange<1> exchange_a(schedule_a, lane_a, context_a); + TranslationExchange<1> exchange_b(schedule_b, lane_b, context_b); + std::exception_ptr failure_a; + std::exception_ptr failure_b; + std::latch workers_ready{2}; + std::latch release_workers{1}; + std::jthread first([&] { + try { + workers_ready.count_down(); + release_workers.wait(); + exchange_a.execute(fields_a, lane_a); + } catch (...) { + failure_a = std::current_exception(); + } + }); + std::jthread second([&] { + try { + workers_ready.count_down(); + release_workers.wait(); + exchange_b.execute(fields_b, lane_b); + } catch (...) { + failure_b = std::current_exception(); + } + }); + workers_ready.wait(); + release_workers.count_down(); + first.join(); + second.join(); + EXPECT_EQ(all_reduce_max((failure_a || failure_b) ? 1L : 0L), 0L); + expect_replayed(schedule_a, fields_a, Real{0}, true); + expect_replayed(schedule_b, fields_b, Real{1'000'000}, true); + EXPECT_EQ(exchange_a.live_request_count(), 0U); + EXPECT_EQ(exchange_b.live_request_count(), 0U); + } + result = ::testing::Test::HasFailure() ? 1 : 0; + } + comm_finalize(); + return result; +} + +} // namespace + +TEST(test_mpi_nd_translation_exchange, RunsProofMatrix) { + EXPECT_EQ( + pops::test::RunTestBody(&run_mpi_nd_translation_exchange, "test_mpi_nd_translation_exchange"), + 0); +} diff --git a/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp b/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp index 71579a3db..2d61f53c3 100644 --- a/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp +++ b/tests/cpp/integration/mpi/test_mpi_system_analytic_level_set.cpp @@ -1,6 +1,9 @@ #include #include "gtest_compat.hpp" +#include +#include +#include #include #include #include @@ -147,6 +150,41 @@ int run_analytic_level_set_collective_preflight(int argc, char** argv) { require(all_reduce_sum(valid_state_installed ? 1L : 0L) == n_ranks(), "a rejected rank mismatch must not poison later System materialization"); + // The materializer owns one patch on rank zero, but recovery is a collective publication gate: + // one owner-local inadmissible candidate must preserve the accepted state and reject every rank. + expression_system.set_block_conversion( + "plasma", [](const double* in, double* out) { out[0] = in[0]; }, + [](const double* in, double* out) { + RecoveryReport report; + if (in[0] > 0.8) { + report.status = RecoveryStatus::kRejected; + report.cause = RecoveryCause::kInadmissibleCandidate; + report.failing_component = 0; + return report; + } + out[0] = in[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; + }); + const std::vector before_recovery_rejection = expression_system.get_state("plasma"); + bool recovery_rejected = false; + std::string recovery_message; + try { + expression_system.set_analytic_expression_state( + "plasma", "cell", "cell", "conservative_cell_average", {{"constant"}}, {{1.0}}); + } catch (const std::runtime_error& error) { + recovery_rejected = true; + recovery_message = error.what(); + } + require(all_reduce_sum(recovery_rejected ? 1L : 0L) == n_ranks(), + "one owner-local recovery failure must reject analytic publication on every rank"); + require( + recovery_rejected && recovery_message.find("prepared variable recovery") != std::string::npos, + "collective analytic rejection must identify prepared variable recovery"); + require(expression_system.get_state("plasma") == before_recovery_rejection, + "collective recovery rejection must preserve the accepted analytic state"); + // The AMR registration path has no halo yet, but it feeds later collective hierarchy setup. Rank // one supplies an unknown opcode while rank zero has a valid program: both ranks must leave the // registration without publishing either the provider or its block binding. @@ -190,6 +228,151 @@ int run_analytic_level_set_collective_preflight(int argc, char** argv) { require(all_reduce_sum(valid_amr_registered ? 1L : 0L) == n_ranks(), "a rejected local AMR error must not leak a partial registration"); + const std::vector boundary_types{"dirichlet", "foextrap", "foextrap", "foextrap"}; + const std::vector boundary_values(4, 0.0); + const std::vector boundary_faces{"case::boundary::xlo", "case::boundary::xhi", + "case::boundary::ylo", "case::boundary::yhi"}; + const std::vector boundary_roles{"Scalar"}; + const std::vector boundary_representations(4, "conservative"); + const std::vector boundary_converters(4, ""); + const std::vector boundary_clocks(4, ""); + const std::vector> boundary_literals{{1.0}, {}, {}, {}}; + + // Boundary programs are prepared and allocate native tables during installation. A malformed + // opcode on one rank must reject every rank before the prepared-plan map publishes a node. + System boundary_system(SystemConfig{12, 1.0, Periodicity{false, false}}); + const std::string boundary_state = "case::boundary::uniform::state"; + boundary_system.install_block_state_route("tracer", boundary_state); + bool malformed_boundary_rejected = false; + std::string malformed_boundary_message; + try { + boundary_system.install_boundary_plan( + "tracer", "case::boundary::uniform::plan", 1, boundary_types, boundary_values, + boundary_faces, boundary_roles, {}, boundary_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, + {{rank == 0 ? "constant" : "not-an-analytic-opcode"}, {}, {}, {}}, boundary_literals, + boundary_clocks); + } catch (const std::runtime_error& error) { + malformed_boundary_rejected = true; + malformed_boundary_message = error.what(); + } + require(all_reduce_sum(malformed_boundary_rejected ? 1L : 0L) == n_ranks(), + "one malformed uniform analytic boundary must reject collectively"); + require(malformed_boundary_rejected && + malformed_boundary_message.find( + "rank-local analytic validation failed collectively") != std::string::npos, + "uniform boundary rejection must identify rank-local collective validation"); + + bool valid_boundary_installed = true; + try { + boundary_system.install_boundary_plan( + "tracer", "case::boundary::uniform::plan", 1, boundary_types, boundary_values, + boundary_faces, boundary_roles, {}, boundary_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, {{"constant"}, {}, {}, {}}, + boundary_literals, boundary_clocks); + } catch (const std::exception& error) { + valid_boundary_installed = false; + std::cerr << "valid uniform analytic boundary failed after malformed payload on rank " << rank + << ": " << error.what() << '\n'; + } + require(all_reduce_sum(valid_boundary_installed ? 1L : 0L) == n_ranks(), + "a rejected uniform boundary must not publish a partial plan"); + + // AMR uses the same exact transaction. Change non-program metadata only, proving that consensus + // covers the complete boundary request rather than merely its postfix rows. + AmrSystem boundary_amr(amr_config); + const std::string boundary_amr_state = "case::boundary::amr::state"; + boundary_amr.install_block_state_route("tracer", boundary_amr_state); + auto rank_faces = boundary_faces; + if (rank == 1) + rank_faces[0] = "case::boundary::rank-one-xlo"; + bool boundary_metadata_rejected = false; + std::string boundary_metadata_message; + try { + boundary_amr.install_boundary_plan( + "tracer", "case::boundary::amr::plan", 1, boundary_types, boundary_values, rank_faces, + boundary_roles, {}, boundary_amr_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, {{"constant"}, {}, {}, {}}, + boundary_literals, boundary_clocks); + } catch (const std::runtime_error& error) { + boundary_metadata_rejected = true; + boundary_metadata_message = error.what(); + } + require(all_reduce_sum(boundary_metadata_rejected ? 1L : 0L) == n_ranks(), + "rank-dependent AMR analytic boundary metadata must reject collectively"); + require(boundary_metadata_rejected && + boundary_metadata_message.find("differs across MPI ranks") != std::string::npos, + "AMR boundary metadata rejection must identify exact MPI disagreement"); + + bool valid_amr_boundary_installed = true; + try { + boundary_amr.install_boundary_plan( + "tracer", "case::boundary::amr::plan", 1, boundary_types, boundary_values, boundary_faces, + boundary_roles, {}, boundary_amr_state, PreparedBoundaryReadDependencies{}, {}, + boundary_representations, boundary_converters, {{"constant"}, {}, {}, {}}, + boundary_literals, boundary_clocks); + } catch (const std::exception& error) { + valid_amr_boundary_installed = false; + std::cerr << "valid AMR analytic boundary failed after metadata mismatch on rank " << rank + << ": " << error.what() << '\n'; + } + require(all_reduce_sum(valid_amr_boundary_installed ? 1L : 0L) == n_ranks(), + "a rejected AMR boundary mismatch must not publish a partial plan"); + + // The analytic finite-value scan must precede every same-level, periodic and MPI halo write. + // This distributed multibox field exercises all three paths and compares the complete grown + // storage after the collective refusal. + const Box2D halo_domain = Box2D::from_extents(8, 4); + const Geometry halo_geometry(halo_domain, Real(0), Real(8), Real(0), Real(4)); + const BoxArray halo_boxes = BoxArray::from_domain(halo_domain, 2); + MultiFab halo_state(halo_boxes, DistributionMapping(halo_boxes.size(), n_ranks()), 1, 1); + halo_state.set_val(Real(-99)); + for (int local = 0; local < halo_state.local_size(); ++local) { + const Array4 values = halo_state.fab(local).array(); + for_each_cell(halo_state.box(local), + [=](int i, int j) { values(i, j, 0) = Real(2 + i + 10 * j); }); + } + device_fence(); + const MultiFab halo_before = halo_state; + PreparedBoundaryPlan invalid_halo_plan( + "case::boundary::invalid-halo-plan", 1, + prepare_hyperbolic_boundary<2>( + {"dirichlet", "foextrap", "periodic", "periodic"}, std::vector(4, 0.0), + {"case::invalid-halo::xlo", "case::invalid-halo::xhi", "case::invalid-halo::ylo", + "case::invalid-halo::yhi"}, + {"Scalar"}, false, {}, {}, + {{"constant", "log"}, + std::vector{}, + std::vector{}, + std::vector{}}, + {{-1.0, 0.0}, std::vector{}, std::vector{}, std::vector{}}, + {"", "", "", ""})); + const auto invalid_halo_lane = ExecutionLane::world("case::boundary::invalid-halo-lane"); + auto invalid_halo_session = invalid_halo_plan.make_session(invalid_halo_lane); + const runtime::multiblock::BoundaryEvaluationPoint halo_point{"clock.boundary", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}; + bool invalid_halo_rejected = false; + try { + invalid_halo_session.fill_same_level_and_physical(halo_state, halo_geometry, halo_point); + } catch (const std::runtime_error&) { + invalid_halo_rejected = true; + } + require(all_reduce_sum(invalid_halo_rejected ? 1L : 0L) == n_ranks(), + "non-finite analytic halo values must reject collectively"); + halo_state.sync_host(); + halo_before.sync_host(); + long local_halo_mutations = 0; + for (int local = 0; local < halo_state.local_size(); ++local) { + const Fab2D& observed = halo_state.fab(local); + const Fab2D& expected = halo_before.fab(local); + const Box2D grown = observed.grown_box(); + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + local_halo_mutations += observed(i, j, 0) == expected(i, j, 0) ? 0L : 1L; + } + require(all_reduce_sum(local_halo_mutations) == 0, + "analytic refusal must preserve complete same-level, periodic, MPI and physical storage"); + const long failures = all_reduce_sum(local_failures); comm_finalize(); return failures == 0 ? 0 : 1; diff --git a/tests/cpp/integration/mpi/test_mpi_system_io_gather.cpp b/tests/cpp/integration/mpi/test_mpi_system_io_gather.cpp index 918ca7c7b..de7f4a1d8 100644 --- a/tests/cpp/integration/mpi/test_mpi_system_io_gather.cpp +++ b/tests/cpp/integration/mpi/test_mpi_system_io_gather.cpp @@ -53,6 +53,7 @@ #include #include +#include #include #include @@ -144,12 +145,12 @@ static int pops_run_test_mpi_system_io_gather(int argc, char** argv) { // === T1 : gather == reference connue (np-invariant), sur le champ fraichement pose =========== // Tous les rangs appellent les accesseurs collectifs ; le resultat egale BIT-A-BIT la reference. + auto output_lane = ObserverMpiLane::duplicate_world_collectively("test/system-io/root-output"); { const std::vector dG = sys.density_global("gas"); const std::vector sG = sys.state_global("gas"); const std::vector local = sys.output_state_local_pieces("gas", 0); - const std::vector root = - sys.output_state_root_pieces(WorldCommunicator::world(), "gas", 0); + const std::vector root = sys.output_state_root_pieces(output_lane, "gas", 0); chk(dG.size() == nn, "T1_density_global_size"); chk(sG.size() == 4 * nn, "T1_state_global_size"); chk(dG == rho_ref, "T1_density_global_eq_ref_no_double_count"); @@ -172,6 +173,7 @@ static int pops_run_test_mpi_system_io_gather(int argc, char** argv) { chk(piece.ncomp == 4 && piece.values == sG, "T1_output_state_root_values"); } } + output_lane.close_collectively(); // === T2 : apres des pas COLLECTIFS, gather == accesseur local sur le proprietaire ============ const double dt = 0.01; diff --git a/tests/cpp/integration/mpi/test_mpi_system_layout_transfer.cpp b/tests/cpp/integration/mpi/test_mpi_system_layout_transfer.cpp index 14ab2bfae..28bb85225 100644 --- a/tests/cpp/integration/mpi/test_mpi_system_layout_transfer.cpp +++ b/tests/cpp/integration/mpi/test_mpi_system_layout_transfer.cpp @@ -141,8 +141,8 @@ struct PassiveScalar { using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { return pops::Real(1); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD pops::Real max_wave_speed(const State&, const auto&, int) const { return pops::Real(1); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD pops::Real elliptic_rhs(const State&) const { return pops::Real(0); } POPS_HD Prim to_primitive(const State& state) const { return state; } @@ -179,9 +179,34 @@ pops::SystemLayoutTransferSpec transfer_spec() { POPS_TRANSFER_OPERATION_CONSERVATIVE_CELL_AVERAGE_V1}; } -pops::SystemLayoutTransferExecution transfer_execution() { +class ScopedMpiCommunicator { + public: + explicit ScopedMpiCommunicator(MPI_Comm source) { + if (MPI_Comm_dup(source, &communicator_) != MPI_SUCCESS) + throw std::runtime_error("MPI_Comm_dup failed for the layout-transfer test lane"); + if (MPI_Comm_set_errhandler(communicator_, MPI_ERRORS_RETURN) != MPI_SUCCESS) { + MPI_Comm_free(&communicator_); + throw std::runtime_error("MPI_Comm_set_errhandler failed for the layout-transfer test lane"); + } + } + + ~ScopedMpiCommunicator() { + if (communicator_ != MPI_COMM_NULL) + MPI_Comm_free(&communicator_); + } + + ScopedMpiCommunicator(const ScopedMpiCommunicator&) = delete; + ScopedMpiCommunicator& operator=(const ScopedMpiCommunicator&) = delete; + + MPI_Comm get() const { return communicator_; } + + private: + MPI_Comm communicator_ = MPI_COMM_NULL; +}; + +pops::SystemLayoutTransferExecution transfer_execution(MPI_Comm communicator) { return {1, - "test::execution::mpi-world-host", + "test::execution::mpi-lane-host", POPS_MEMORY_SPACE_HOST_V1, "test::backend::mpi-cpu", "test::device::cpu:0", @@ -192,9 +217,9 @@ pops::SystemLayoutTransferExecution transfer_execution() { POPS_PRECISION_FLOAT64_V1, 0, "test::stream::host-synchronous", - static_cast(MPI_Comm_c2f(MPI_COMM_WORLD)), + static_cast(MPI_Comm_c2f(communicator)), static_cast(MPI_Type_c2f(MPI_DOUBLE)), - "MPI_COMM_WORLD", + "test::mpi-system-layout-transfer-lane", "MPI_DOUBLE"}; } @@ -289,6 +314,13 @@ int run_mpi_system_layout_transfer(int argc, char** argv) { return finish(); { + const ScopedMpiCommunicator transfer_lane(MPI_COMM_WORLD); + int world_relation = MPI_UNEQUAL; + check(MPI_Comm_compare(transfer_lane.get(), MPI_COMM_WORLD, &world_relation) == MPI_SUCCESS, + "layout-transfer lane comparison succeeds"); + check(world_relation == MPI_CONGRUENT, + "layout-transfer test executes on a distinct world-congruent communicator"); + std::shared_ptr component; bool healthy = phase("authenticated Transfer DSO load", [&] { component = std::make_shared( @@ -335,7 +367,7 @@ int run_mpi_system_layout_transfer(int argc, char** argv) { "coarse System has one owner and one empty peer"); healthy = phase("collective prepared Transfer construction", [&] { transfer = pops::PreparedSystemLayoutTransfer::prepare( - *fine, *coarse, component, transfer_spec(), transfer_execution()); + *fine, *coarse, component, transfer_spec(), transfer_execution(transfer_lane.get())); }); } @@ -348,7 +380,7 @@ int run_mpi_system_layout_transfer(int argc, char** argv) { receipt.source_layout_identity == kFineLayout && receipt.target_layout_identity == kCoarseLayout && receipt.source_block == "fine" && receipt.target_block == "coarse" && - receipt.execution_identity == "test::execution::mpi-world-host" && + receipt.execution_identity == "test::execution::mpi-lane-host" && receipt.operation == POPS_TRANSFER_OPERATION_CONSERVATIVE_CELL_AVERAGE_V1 && receipt.generation == generation && receipt.attempt == attempt && receipt.source_element_count == 16 && receipt.destination_element_count == 4, diff --git a/tests/cpp/integration/native_loader/test_amr_imex_native.cpp b/tests/cpp/integration/native_loader/test_amr_imex_native.cpp index d13c74e97..d847a073c 100644 --- a/tests/cpp/integration/native_loader/test_amr_imex_native.cpp +++ b/tests/cpp/integration/native_loader/test_amr_imex_native.cpp @@ -254,7 +254,8 @@ void install_single_block_test_program(AmrSystem& system, Model model, bool impl context->install([context, model, implicit_source](double macro_dt) { context->advance_hierarchy(macro_dt, [context, model, implicit_source](double level_dt) { context->set_stage_time(0, 1); - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); MultiFab& live = context->state(0); MultiFab& candidate = context->scratch_state(1000, 0, live); diff --git a/tests/cpp/integration/native_loader/test_amr_native_loader.cpp b/tests/cpp/integration/native_loader/test_amr_native_loader.cpp index 0879038c2..01ae43ffc 100644 --- a/tests/cpp/integration/native_loader/test_amr_native_loader.cpp +++ b/tests/cpp/integration/native_loader/test_amr_native_loader.cpp @@ -94,6 +94,48 @@ std::string component_source() { return 0; } + int transform_boundary_flux(void* state, const PopsBoundaryFluxRequestV1* request, + PopsBoundaryFluxResultV1* result) { + if (result == nullptr) + return 42; + if (state == nullptr || request == nullptr || request->region.kind != POPS_BOUNDARY_FACE_V1 || + request->region.axis_count != 1 || request->region.axes == nullptr || + request->region.sides == nullptr || request->outward_normals.data == nullptr || + request->face_measures == nullptr || result->outward_normal_flux.data == nullptr) { + result->status = {sizeof(PopsComponentStatusV1), 42, POPS_COMPONENT_ABORT_RUN_V1, + "boundary flux contract is incomplete"}; + return 42; + } + const auto* base = static_cast(request->base_outward_normal_flux.data); + const auto* normals = static_cast(request->outward_normals.data); + auto* output = static_cast(result->outward_normal_flux.data); + const auto points = request->base_outward_normal_flux.extents[0] * + request->base_outward_normal_flux.extents[1]; + const auto axis = static_cast(request->region.axes[0]); + const double side = static_cast(request->region.sides[0]); + for (std::size_t point = 0; point < points; ++point) { + const auto normal_offset = + point * static_cast(request->outward_normals.axis_strides[0]) + + axis * static_cast(request->outward_normals.component_stride); + if (normals[normal_offset] != side || request->face_measures[point] <= 0.0) { + result->status = {sizeof(PopsComponentStatusV1), 43, POPS_COMPONENT_ABORT_RUN_V1, + "boundary flux orientation is inconsistent"}; + return 43; + } + for (std::size_t component = 0; + component < request->base_outward_normal_flux.component_count; ++component) { + const auto index = + point * static_cast(request->base_outward_normal_flux.axis_strides[0]) + + component * + static_cast(request->base_outward_normal_flux.component_stride); + output[index] = base[index] + 10.0; + } + result->actions[point] = POPS_COMPONENT_CONTINUE_V1; + } + result->status = {sizeof(PopsComponentStatusV1), 0, POPS_COMPONENT_CONTINUE_V1, nullptr}; + return 0; + } + int tag_batch(void*, const PopsTaggerRequestV2* request, PopsComponentStatusV1* status) { ++tag_call_count; last_tag_state_data = request->states[0].values.data; @@ -276,6 +318,10 @@ std::string component_source() { {sizeof(PopsGhostBoundaryApiV1), POPS_COMPONENT_PROTOCOL_ABI_V1, POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1, 1, &prepare, &destroy}, &apply_ghost}; + const PopsBoundaryFluxApiV1 boundary_flux{ + {sizeof(PopsBoundaryFluxApiV1), POPS_COMPONENT_PROTOCOL_ABI_V1, + POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, 1, &prepare, &destroy}, + &transform_boundary_flux}; const PopsTaggerApiV2 tagger{{sizeof(PopsTaggerApiV2), POPS_COMPONENT_PROTOCOL_ABI_V1, POPS_NATIVE_INTERFACE_TAGGER_V2, 2, &prepare, &destroy}, &tag_batch}; @@ -292,6 +338,7 @@ std::string component_source() { #endif {POPS_NATIVE_INTERFACE_TRANSFER_V1, 1, sizeof(PopsTransferApiV1), &transfer}, {POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1, 1, sizeof(PopsGhostBoundaryApiV1), &ghost}, + {POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, 1, sizeof(PopsBoundaryFluxApiV1), &boundary_flux}, {POPS_NATIVE_INTERFACE_TAGGER_V2, 2, sizeof(PopsTaggerApiV2), &tagger}, {POPS_NATIVE_INTERFACE_CLUSTERING_V1, 1, sizeof(PopsClusteringApiV1), &clustering}}; const PopsComponentApiV1 component{ @@ -306,7 +353,7 @@ std::string component_source() { "pops://test/final-flux@1.0.0", "semantic-final-flux", "manifest-final-flux", - 5, + 6, interfaces}; } // namespace @@ -377,6 +424,7 @@ pops::component::ExpectedNativeComponent expected() { {{POPS_NATIVE_INTERFACE_NUMERICAL_FLUX_V1, 1, sizeof(PopsNumericalFluxApiV1)}, {POPS_NATIVE_INTERFACE_TRANSFER_V1, 1, sizeof(PopsTransferApiV1)}, {POPS_NATIVE_INTERFACE_GHOST_BOUNDARY_V1, 1, sizeof(PopsGhostBoundaryApiV1)}, + {POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1, 1, sizeof(PopsBoundaryFluxApiV1)}, {POPS_NATIVE_INTERFACE_TAGGER_V2, 2, sizeof(PopsTaggerApiV2)}, {POPS_NATIVE_INTERFACE_CLUSTERING_V1, 1, sizeof(PopsClusteringApiV1)}}}; } @@ -1092,12 +1140,13 @@ TEST(test_amr_native_loader, BoundaryPlanSessionsOwnFreshLaneQualifiedComponentS spec.target_json = R"({"identity":"case::boundary::ghost-target"})"; spec.execution = prepared_execution(); - pops::BCRec bc; - bc.xlo = pops::BCType::Foextrap; - bc.xhi = pops::BCType::Foextrap; - bc.ylo = pops::BCType::Foextrap; - bc.yhi = pops::BCType::Foextrap; - pops::PreparedBoundaryPlan plan("case::boundary::plan", 1, {bc}, {}, spec.state_identity); + auto hyperbolic = pops::prepare_hyperbolic_boundary<2>( + {"foextrap", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::boundary::xlo", "case::boundary::xhi", "case::boundary::ylo", + "case::boundary::yhi"}, + {"Scalar"}); + pops::PreparedBoundaryPlan plan("case::boundary::plan", 1, std::move(hyperbolic), {}, + spec.state_identity); plan.install_ghost_component(std::move(spec), component); const auto lane = @@ -1136,6 +1185,91 @@ TEST(test_amr_native_loader, BoundaryPlanSessionsOwnFreshLaneQualifiedComponentS std::filesystem::remove(library); } +TEST(test_amr_native_loader, + PostRiemannBoundaryFluxUsesOutwardOrientationAndPreservesCanonicalFaceStorage) { + const auto library = compile_component(); + { + auto component = std::make_shared( + pops::component::LoadedComponent::load(library.string(), expected())); + const auto make_spec = [&](std::string target, std::string boundary, int side) { + pops::PreparedBoundaryComponentSpec spec; + spec.target_identity = std::move(target); + spec.component_id = kComponentId; + spec.manifest_identity = kManifestIdentity; + spec.interface_version = 1; + spec.producer_identity = spec.target_identity; + spec.state_identity = "case::state::u"; + spec.ghost_identity = boundary; + spec.layout_identity = "case::layout::cells"; + spec.region.kind = POPS_BOUNDARY_FACE_V1; + spec.region.dimension = 2; + spec.region.codimension = 1; + spec.region.axes = {0}; + spec.region.sides = {side}; + spec.region.identity = std::move(boundary); + spec.outputs = {spec.state_identity}; + spec.parameters_json = R"({"outward_shift":10.0})"; + spec.target_json = R"({"kind":"post-riemann-flux"})"; + spec.execution = prepared_execution(); + return spec; + }; + + auto hyperbolic = pops::prepare_hyperbolic_boundary<2>( + {"foextrap", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::boundary::xlo", "case::boundary::xhi", "case::boundary::ylo", + "case::boundary::yhi"}, + {"Scalar"}); + pops::PreparedBoundaryPlan plan("case::boundary::flux-plan", 1, std::move(hyperbolic), {}, + "case::state::u"); + plan.install_flux_component(make_spec("case::boundary-flux::xlo", "case::boundary::xlo", -1), + component); + plan.install_flux_component(make_spec("case::boundary-flux::xhi", "case::boundary::xhi", 1), + component); + EXPECT_TRUE(plan.has_flux_transformations()); + + const pops::Box2D domain = pops::Box2D::from_extents(3, 3); + const pops::Geometry geometry(domain, pops::Real(0), pops::Real(1), pops::Real(0), + pops::Real(1)); + const pops::BoxArray cell_boxes = pops::BoxArray::from_domain(domain, domain.nx()); + pops::MultiFab state(cell_boxes, pops::DistributionMapping(cell_boxes.size(), pops::n_ranks()), + 1, 1); + state.set_val(pops::Real(1)); + pops::Box2D xface_box = domain; + ++xface_box.hi[0]; + const pops::BoxArray xface_boxes(std::vector{xface_box}); + pops::MultiFab fx(xface_boxes, pops::DistributionMapping(xface_boxes.size(), pops::n_ranks()), + 1, 0); + pops::Box2D yface_box = domain; + ++yface_box.hi[1]; + const pops::BoxArray yface_boxes(std::vector{yface_box}); + pops::MultiFab fy(yface_boxes, pops::DistributionMapping(yface_boxes.size(), pops::n_ranks()), + 1, 0); + fx.set_val(pops::Real(3)); + fy.set_val(pops::Real(4)); + + pops::detail::BoundaryFieldRegistry fields; + fields.configure_states(plan.required_state_identities()); + fields.configure_fields(plan.required_field_identities()); + fields.begin_binding(); + const auto lane = pops::ExecutionLane::world("case::boundary::flux-session"); + auto session = plan.make_session(lane); + session.prepare_flux_executor(state, fields, geometry); + const pops::runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.boundary-flux", 0, 0, 0, 0, pops::amr::Rational(0, 1), 0.1, 0.0}; + session.transform_fluxes(point, state, fields, geometry, fx, fy); + + if (fx.local_size() != 0) { + // The provider receives outward flux. Adding +10 outward therefore scatters as -7 on the + // lower face (-(-3 + 10)) and +13 on the upper face (+(+3 + 10)). + EXPECT_EQ(fx.fab(0)(domain.lo[0], 1, 0), pops::Real(-7)); + EXPECT_EQ(fx.fab(0)(domain.hi[0] + 1, 1, 0), pops::Real(13)); + EXPECT_EQ(fx.fab(0)(1, 1, 0), pops::Real(3)); + EXPECT_EQ(fy.fab(0)(1, 1, 0), pops::Real(4)); + } + } + std::filesystem::remove(library); +} + TEST(test_amr_native_loader, RefusesIdentityInterfaceAndTableSizeMismatches) { const auto library = compile_component(); auto forged = expected(); diff --git a/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp b/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp index 9029d8818..1d7820e2c 100644 --- a/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp +++ b/tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp @@ -54,6 +54,7 @@ std::string package_source() { #include #include +#include #include #include #include @@ -64,8 +65,16 @@ std::string package_source() { using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { return pops::Real(1); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD pops::Real max_wave_speed(const State&, const auto&, int) const { return pops::Real(1); } + POPS_HD State roe_dissipation(const State& left, const auto&, const State& right, const auto&, + int) const { + State result{}; + if ((left[0] > pops::Real(0.902) && left[0] < pops::Real(0.908)) || + (right[0] > pops::Real(0.902) && right[0] < pops::Real(0.908))) + result[0] = std::numeric_limits::quiet_NaN(); + return result; + } POPS_HD State source(const State& state, const Aux&) const { return State{-state[0]}; } POPS_HD pops::Real elliptic_rhs(const State&) const { return pops::Real(0); } POPS_HD Prim to_primitive(const State& state) const { return state; } @@ -144,6 +153,8 @@ std::string package_source() { install_attempt_block(system, name, substeps, evolve != 0, stride); else if (params[0] == 1.0 && std::string(time) == "explicit") install_attempt_block(system, name, substeps, evolve != 0, stride); + else if (params[0] == 2.0 && std::string(time) == "explicit") + install_attempt_block(system, name, substeps, evolve != 0, stride); else throw std::invalid_argument("attempt-control test package received an invalid mode"); } @@ -216,6 +227,9 @@ int run_flux_failure_loader_transaction() { 0x52545259u); failures += exercise_attempt( library, 1.0, pops::runtime::program::StepAttemptDisposition::kReject, 0x524a4354u); + failures += exercise_attempt( + library, 2.0, pops::runtime::program::StepAttemptDisposition::kReject, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeNonFiniteDissipation)); std::remove(source.c_str()); std::remove(library.c_str()); std::remove((library + ".log").c_str()); diff --git a/tests/cpp/integration/native_loader/test_native_aux_named.cpp b/tests/cpp/integration/native_loader/test_native_aux_named.cpp index 01c8a2121..118ad382f 100644 --- a/tests/cpp/integration/native_loader/test_native_aux_named.cpp +++ b/tests/cpp/integration/native_loader/test_native_aux_named.cpp @@ -35,8 +35,8 @@ std::string package_source() { using Aux = pops::Aux; static constexpr int n_vars = 1; static constexpr int n_aux = pops::kAuxNamedBase + 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { return pops::Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD pops::Real max_wave_speed(const State&, const auto&, int) const { return pops::Real(0); } POPS_HD State source(const State& u, const Aux& aux) const { return State{aux.extra_field(0) * u[0]}; } diff --git a/tests/cpp/integration/runtime/test_aux_system_bz.cpp b/tests/cpp/integration/runtime/test_aux_system_bz.cpp index 09693d775..8825b8d83 100644 --- a/tests/cpp/integration/runtime/test_aux_system_bz.cpp +++ b/tests/cpp/integration/runtime/test_aux_system_bz.cpp @@ -28,8 +28,8 @@ struct BzGrow { using Aux = pops::Aux; static constexpr int n_vars = 1; static constexpr int n_aux = 4; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux& a) const { State s{}; s[0] = a.B_z * u[0]; @@ -43,8 +43,8 @@ struct Scalar { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } }; diff --git a/tests/cpp/integration/runtime/test_facade_routing.cpp b/tests/cpp/integration/runtime/test_facade_routing.cpp index 6f076ba7d..539251c08 100644 --- a/tests/cpp/integration/runtime/test_facade_routing.cpp +++ b/tests/cpp/integration/runtime/test_facade_routing.cpp @@ -84,6 +84,16 @@ ModelSpec periodic_exb_model() { return spec; } +ModelSpec compressible_model() { + ModelSpec spec; + spec.transport = "compressible"; + spec.source = "none"; + spec.elliptic = "background"; + spec.gamma = 1.4; + spec.n0 = 0.0; + return spec; +} + // Construit un System scalaire ExB diocotron pret a stepper. Le disque/mode est pose par l'appelant. void build_exb(System& s, double R_wall) { ModelSpec spec; @@ -347,3 +357,104 @@ TEST(FacadeRouting, PeriodicAnalyticLevelSetUsesTopologyAtTheSeam) { EXPECT_EQ(topology.get_state("n"), explicit_wrap.get_state("n")); EXPECT_GT(max_abs_diff(topology.get_state("n"), rho0), 0.0); } + +TEST(FacadeRouting, PrimitiveMaterializationFailsClosedWithoutMutatingAcceptedState) { +#if defined(POPS_HAS_KOKKOS) + (void)kokkos_scope(); +#endif + constexpr int n = 4; + System system(SystemConfig{n, 1.0, Periodicity{true, true}}); + system.add_block("gas", compressible_model(), "none", "rusanov", "conservative"); + + // All components are finite, but Euler conservative -> primitive is undefined at rho=0. + // This exercises the real runtime registry and its prepared conversion, not a test-only callback. + const std::vector accepted(static_cast(4 * n * n), 0.0); + system.set_state("gas", accepted); + + bool rejected = false; + try { + (void)system.get_primitive_state("gas"); + } catch (const std::runtime_error& error) { + const std::string message = error.what(); + rejected = message.find("variable recovery failed") != std::string::npos && + message.find("status=invalid_contract") != std::string::npos && + message.find("cause=non_finite_candidate") != std::string::npos && + message.find("attempted_methods=1") != std::string::npos; + } + EXPECT_TRUE(rejected); + EXPECT_EQ(system.get_state("gas"), accepted) + << "failed diagnostic recovery must not mutate the accepted conservative state"; +} + +TEST(FacadeRouting, PrimitiveMaterializationRefusesMissingPreparedBatchAuthority) { +#if defined(POPS_HAS_KOKKOS) + (void)kokkos_scope(); +#endif + constexpr int n = 4; + System system(SystemConfig{n, 1.0, Periodicity{true, true}}); + system.add_block("gas", compressible_model(), "none", "rusanov", "conservative"); + + const std::vector accepted = system.get_state("gas"); + system.set_block_conversion( + "gas", [](const double* in, double* out) { + for (int component = 0; component < 4; ++component) + out[component] = in[component]; + }, + [](const double* in, double* out) { + for (int component = 0; component < 4; ++component) + out[component] = in[component]; + RecoveryReport report; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; + }); + + bool rejected = false; + try { + (void)system.get_primitive_state("gas"); + } catch (const std::runtime_error& error) { + rejected = std::string(error.what()).find( + "no generation-qualified prepared batch recovery consumer") != + std::string::npos; + } + EXPECT_TRUE(rejected); + EXPECT_EQ(system.get_state("gas"), accepted) + << "missing prepared batch authority must not mutate accepted conservative state"; +} + +TEST(FacadeRouting, PrimitiveInputRequiresPreparedRecoveryBeforeConservativePublication) { +#if defined(POPS_HAS_KOKKOS) + (void)kokkos_scope(); +#endif + constexpr int n = 4; + const std::size_t cells = static_cast(n) * n; + System system(SystemConfig{n, 1.0, Periodicity{true, true}}); + system.add_block("gas", compressible_model(), "none", "rusanov", "conservative"); + + std::vector accepted(4 * cells, 0.0); + for (std::size_t cell = 0; cell < cells; ++cell) { + accepted[cell] = 1.0; + accepted[3 * cells + cell] = 2.5; + } + system.set_state("gas", accepted); + + std::vector inadmissible_primitive(4 * cells, 0.0); + for (std::size_t cell = 0; cell < cells; ++cell) + inadmissible_primitive[3 * cells + cell] = 1.0; + EXPECT_THROW(system.set_primitive_state("gas", inadmissible_primitive), std::runtime_error); + EXPECT_EQ(system.get_state("gas"), accepted) + << "failed forward conversion validation must not publish a partial conservative state"; + + std::vector admissible_primitive(4 * cells, 0.0); + for (std::size_t cell = 0; cell < cells; ++cell) { + admissible_primitive[cell] = 1.0; + admissible_primitive[cells + cell] = 0.2; + admissible_primitive[2 * cells + cell] = -0.1; + admissible_primitive[3 * cells + cell] = 1.0; + } + EXPECT_NO_THROW(system.set_primitive_state("gas", admissible_primitive)); + const std::vector recovered = system.get_primitive_state("gas"); + ASSERT_EQ(recovered.size(), admissible_primitive.size()); + for (std::size_t value = 0; value < recovered.size(); ++value) + EXPECT_NEAR(recovered[value], admissible_primitive[value], 1e-12); +} diff --git a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp index d0a1d450a..815d96f8a 100644 --- a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp +++ b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp @@ -1321,6 +1321,8 @@ TEST(test_multiblock_interface_scheduler, batch.shared_flux[face] = Real(0.5) * (batch.left_state[face] + batch.right_state[face]); }); + EXPECT_THROW(scheduler.require_exact_jacvec_pair(0, 0, 1), std::runtime_error) + << "MPI_COMM_WORLD remains an MPI execution identity even with one rank"; const BoundaryEvaluationPoint point{"clock.mpi-one-rank", 1, 0, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; std::vector states{&left_state, &right_state}; @@ -1335,6 +1337,39 @@ TEST(test_multiblock_interface_scheduler, #endif } +TEST(test_multiblock_interface_scheduler, + NativeImplicitPairAdmissionRejectsDeviceAndManagedMemory) { + ensure_runtime(); + const Box2D left_box{{0, 0}, {1, 2}}; + const Box2D right_box{{2, 0}, {3, 2}}; + const Geometry left_geometry{left_box, Real(0), Real(1), Real(0), Real(3)}; + const Geometry right_geometry{right_box, Real(1), Real(2), Real(0), Real(3)}; + + const auto require_host_refusal = [&](PopsMemorySpaceV1 memory_space, + const char* device_identity, + const char* route_identity) { + MultiFab left_state = make_field(left_box, 1); + MultiFab right_state = make_field(right_box, 1); + AxisAlignedInterface route = aligned_x_route(route_identity); + PopsExecutionContextV1 execution = serial_interface_execution(); + execution.memory_space = memory_space; + execution.device_identity = device_identity; + InterfaceFluxScheduler scheduler; + scheduler.install( + route, left_state, left_geometry, right_state, right_geometry, execution, + [](const BoundaryEvaluationPoint&, const InterfaceFluxBatch& batch) { + for (int face = 0; face < batch.face_count; ++face) + batch.shared_flux[face] = Real(0); + }); + EXPECT_THROW(scheduler.require_exact_jacvec_pair(0, 0, 1), std::runtime_error); + }; + + require_host_refusal( + POPS_MEMORY_SPACE_DEVICE_V1, "gpu", "device-memory-explicit-interface"); + require_host_refusal( + POPS_MEMORY_SPACE_MANAGED_V1, "cpu", "managed-memory-explicit-interface"); +} + TEST(test_multiblock_interface_scheduler, UnsupportedOrUnauthenticatedMappingsFailAtInstall) { ensure_runtime(); const Box2D left_box{{0, 0}, {3, 2}}; @@ -1513,6 +1548,113 @@ TEST(test_multiblock_interface_scheduler, EXPECT_EQ(left_result(box.hi[0], j, 0) + right_result(box.lo[0], j, 0), Real(0)); } +TEST(test_multiblock_interface_scheduler, + FrozenTwoLevelImplicitPairFiniteDifferencesBothFineInterfaceTracesAtomically) { + ensure_runtime(); + constexpr int cells = 4; + AmrBuildParams params; + params.mesh.load_balance = test::prepare_test_space_filling_curve_load_balance(); + params.mesh.periodicity = Periodicity{true, true}; + params.mesh.n = cells; + params.mesh.L = 1.0; + params.mesh.regrid_every = 0; + params.poisson.bc = BCRec{}; + detail::SharedAmrLayout layout = detail::make_shared_amr_layout_levels(params, 2); + layout.ba[1] = BoxArray(std::vector{layout.geom.domain.refine(kAmrRefRatio)}); + layout.dm[1] = layout.load_balance->distribute(layout.ba[1], n_ranks()); + + std::vector blocks; + for (const char* name : {"left", "right"}) { + AmrRuntimeBlock block = detail::dispatch_amr_block( + scalar_model(), "none", "rusanov", layout, name, + std::vector(static_cast(cells) * cells, 1.0), true, 1.4, 1, false, 1); + block.state_identity = std::string("test://implicit-pair/") + name + "/U"; + block.level_rhs_without_prepared_interfaces = + [](const BoundaryEvaluationPoint&, MultiFab&, const MultiFab&, const Geometry&, + MultiFab& rhs) { rhs.set_val(Real(0)); }; + block.level_neg_div_flux_without_prepared_interfaces = + block.level_rhs_without_prepared_interfaces; + blocks.push_back(std::move(block)); + } + AmrRuntime runtime(layout.geom, layout.runtime_hierarchy(), layout.poisson_bc, std::move(blocks), + layout.base_per, layout.replicated_coarse, layout.wall); + test::install_second_order_amr_transfer_authorities(runtime, 2); + runtime.set_parent_child_temporal_relations({amr::ParentChildClockRelation( + 0, 1, amr::Rational(2, 1), amr::RemainderPolicy::IntegralOnly)}); + + std::array evaluator_calls{0, 0}; + for (int level = 0; level < 2; ++level) { + AxisAlignedInterface route = aligned_x_route("amr.implicit-pair.shared-flux"); + route.level = level; + route.affine_mapping_identity = "periodic-x-translation"; + route.right_normal_translation = Real(1); + runtime.install_level_interface_flux( + level, route, serial_interface_execution(), + [&, level](const BoundaryEvaluationPoint&, const InterfaceFluxBatch& batch) { + ++evaluator_calls[static_cast(level)]; + for (int face = 0; face < batch.face_count; ++face) { + const Real left = batch.left_state[face]; + const Real right = batch.right_state[face]; + batch.shared_flux[face] = + left * left + Real(3) * left * right + Real(2) * right * right; + } + }); + } + runtime.require_complete_active_level_interfaces(); + + constexpr int fine = 1; + const BoundaryEvaluationPoint point{ + "clock.implicit-pair", 7, fine, 1, 2, amr::Rational(1, 2), 0.01, 0.07}; + MultiFab base_left = runtime.level_state(0, fine); + MultiFab base_right = runtime.level_state(1, fine); + base_left.set_val(Real(1.25)); + base_right.set_val(Real(-0.5)); + MultiFab base_left_rhs(base_left.box_array(), base_left.dmap(), 1, 0); + MultiFab base_right_rhs(base_right.box_array(), base_right.dmap(), 1, 0); + runtime.level_rhs_jacvec_pair(fine, point, 0, base_left, base_left_rhs, false, + 1, base_right, base_right_rhs, false); + + constexpr Real h = Real(1.0e-6); + constexpr Real left_direction = Real(0.3); + constexpr Real right_direction = Real(-0.7); + MultiFab perturbed_left = base_left; + MultiFab perturbed_right = base_right; + perturbed_left.set_val(Real(1.25) + h * left_direction); + perturbed_right.set_val(Real(-0.5) + h * right_direction); + MultiFab perturbed_left_rhs(perturbed_left.box_array(), perturbed_left.dmap(), 1, 0); + MultiFab perturbed_right_rhs(perturbed_right.box_array(), perturbed_right.dmap(), 1, 0); + runtime.level_rhs_jacvec_pair(fine, point, 0, perturbed_left, perturbed_left_rhs, false, + 1, perturbed_right, perturbed_right_rhs, false); + + EXPECT_EQ(evaluator_calls[0], 0); + EXPECT_EQ(evaluator_calls[1], 2) + << "one base and one perturbed grouped residual must each evaluate the shared flux once"; + EXPECT_EQ(runtime.interface_evaluation_count("amr.implicit-pair.shared-flux", fine), 2u); + const Box2D fine_box = perturbed_left.box(0); + const int j = fine_box.lo[1]; + const Real left_fd = + (get_cell(perturbed_left_rhs, fine_box.hi[0], j, 0) - + get_cell(base_left_rhs, fine_box.hi[0], j, 0)) / + h; + const Real right_fd = + (get_cell(perturbed_right_rhs, fine_box.lo[0], j, 0) - + get_cell(base_right_rhs, fine_box.lo[0], j, 0)) / + h; + const Real directional_flux = + (Real(2) * Real(1.25) + Real(3) * Real(-0.5)) * left_direction + + (Real(3) * Real(1.25) + Real(4) * Real(-0.5)) * right_direction; + const Real expected_left = -directional_flux / runtime.level_geom(fine).dx(); + EXPECT_NEAR(left_fd, expected_left, Real(2.0e-5)); + EXPECT_NEAR(right_fd, -expected_left, Real(2.0e-5)); + EXPECT_NE(left_fd, Real(0)) << "the left residual must include the right-state direction"; + + runtime.set_regrid(/*every=*/1, /*grow=*/0, /*margin=*/0); + EXPECT_THROW(runtime.level_rhs_jacvec_pair( + fine, point, 0, perturbed_left, perturbed_left_rhs, false, + 1, perturbed_right, perturbed_right_rhs, false), + std::runtime_error); +} + TEST(test_multiblock_interface_scheduler, AmrBoundaryRegistryUsesOtherBlocksProvisionalStageState) { ensure_runtime(); AmrBuildParams params; @@ -1534,8 +1676,12 @@ TEST(test_multiblock_interface_scheduler, AmrBoundaryRegistryUsesOtherBlocksProv blocks[0].state_identity = a_state; blocks[1].state_identity = b_state; blocks[0].boundary_plan = std::make_shared( - "case::amr::a::boundary", 1, std::vector{BCRec{}}, std::vector{}, a_state, - PreparedBoundaryReadDependencies{{b_state}, {}}); + "case::amr::a::boundary", 1, + prepare_hyperbolic_boundary<2>( + {"periodic", "periodic", "periodic", "periodic"}, std::vector(4, 0.0), + {"case::amr::a::xlo", "case::amr::a::xhi", "case::amr::a::ylo", "case::amr::a::yhi"}, + {"Scalar"}), + std::vector{}, a_state, PreparedBoundaryReadDependencies{{b_state}, {}}); const auto b_read = blocks[0].boundary_plan->prepare_state_read(b_state); blocks[0].boundary_field_registry = std::make_shared(); blocks[0].level_rhs_core_at_point_prepared = diff --git a/tests/cpp/integration/runtime/test_polar_system_step.cpp b/tests/cpp/integration/runtime/test_polar_system_step.cpp index fe3d4553b..a0ce6d805 100644 --- a/tests/cpp/integration/runtime/test_polar_system_step.cpp +++ b/tests/cpp/integration/runtime/test_polar_system_step.cpp @@ -8,7 +8,7 @@ // aux[1] = grad_r = d phi/dr, // aux[2] = grad_theta = (1/r) d phi/d theta (derivee PHYSIQUE, deja divisee par r), // d'ou la vitesse ExB polaire de ExBVelocityPolar : v_r = -grad_theta/B, v_theta = grad_r/B ; -// (3) AVANCE SSPRK3 du transport polaire (assemble_rhs_polar) avec PAROI RADIALE solide (wall_radial) +// (3) AVANCE SSPRK3 du transport polaire avec un PreparedBoundaryPlan NoFlux radial // -> flux radial nul a r_min/r_max -> masse Sum_ij n_ij r_i dr dtheta conservee A LA MACHINE. // // Deux verifications : @@ -39,6 +39,11 @@ #include #include // ExBVelocityPolar, CompositeModel, NoSource, ChargeDensity #include // derive_aux_polar : MEME derivation aux que System::solve_fields_polar +#include +#include + +#include "explicit_system_program.hpp" +#include "polar_boundary_plan.hpp" #include #include @@ -90,6 +95,8 @@ static double min_density(const MultiFab& U, const Box2D& dom) { static void coupled_step(const PolarModel& model, MultiFab& U, MultiFab& aux, PolarPoissonSolver& solver, const PolarGeometry& g, const Box2D& dom, const BCRec& bc, double dt) { + const auto boundary_plan = + test_support::polar_boundary_plan(PolarModel::n_vars, true, Weno5::n_ghost); // --- solve_fields_polar : f = q n, resolu, puis aux = (phi, grad_r, grad_theta) --- { MultiFab& rhs = solver.rhs(); @@ -105,12 +112,12 @@ static void coupled_step(const PolarModel& model, MultiFab& U, MultiFab& aux, derive_aux_polar(solver.phi(), aux, g); fill_ghosts(aux, dom, bc); // theta periodique, r physique (extrapolation) } - // --- avance SSPRK3 du transport polaire avec PAROI RADIALE solide (wall_radial = true) --- + // --- avance SSPRK3 du transport polaire avec des faces radiales NoFlux preparees --- SSPRK3Step{}.take_step( [&](MultiFab& stage, MultiFab& R) { fill_ghosts(stage, dom, bc); - assemble_rhs_polar(model, stage, aux, g, R, /*recon_prim=*/false, - /*wall_radial=*/true); + assemble_rhs_polar(model, stage, aux, g, R, *boundary_plan, + /*recon_prim=*/false); }, U, static_cast(dt)); } @@ -122,9 +129,8 @@ TEST(PolarSystemStep, CoupledStepAdvectsDensityAndConservesMassUnderRadialWall) BoxArray ba(std::vector{dom}); DistributionMapping dm(1, n_ranks()); - // BC : radial Neumann homogene (Foextrap) pour le Poisson (paroi), theta periodique. (La paroi - // SOLIDE du transport est portee par wall_radial dans coupled_step, independamment de la BC du - // Poisson : le test verifie precisement que la masse est conservee a la machine grace a wall_radial.) + // BC : radial Neumann homogene (Foextrap) pour le Poisson, theta periodique. La paroi SOLIDE du + // transport est portee par le plan NoFlux de coupled_step, independamment de la BC du Poisson. BCRec bc; bc.xlo = bc.xhi = BCType::Foextrap; bc.ylo = bc.yhi = BCType::Periodic; @@ -213,3 +219,53 @@ TEST(PolarSystemStep, CoupledStepAdvectsDensityAndConservesMassUnderRadialWall) EXPECT_TRUE(minrho1 > 0.0) << "(B) densite devenue negative (pas couple instable) : minrho1=" << minrho1; } + +TEST(PolarSystemStep, BoundProgramUsesPersistentPreparedBoundaryClosures) { + SystemConfig config; + config.n = 8; + config.geometry = "polar"; + config.nr = 8; + config.ntheta = 16; + config.r_min = kRmin; + config.r_max = kRmax; + System system(config); + + ModelSpec model; + model.transport = "exb"; + model.source = "none"; + model.elliptic = "charge"; + model.q = kQ; + model.B0 = kB0; + system.add_block("density", model, "none"); + + std::vector density(static_cast(config.nr * config.ntheta)); + for (int j = 0; j < config.ntheta; ++j) + for (int i = 0; i < config.nr; ++i) + density[static_cast(j * config.nr + i)] = + 1.0 + 0.1 * std::cos(2.0 * kPiL * (static_cast(j) + 0.5) / config.ntheta); + system.set_density("density", density); + test::install_forward_euler_program(system); + system.mark_bound(); + + EXPECT_NO_THROW(system.step(1e-4)); + for (const double value : system.get_state("density")) + EXPECT_TRUE(std::isfinite(value)); +} + +TEST(PolarSystemStep, RefusesUnsupportedPostRiemannBoundaryComponentAtInstallation) { + SystemConfig config; + config.geometry = "polar"; + config.nr = 8; + config.ntheta = 16; + config.r_min = kRmin; + config.r_max = kRmax; + System system(config); + + ModelSpec model; + model.transport = "exb"; + model.source = "none"; + model.elliptic = "charge"; + system.add_block("density", model, "none"); + + EXPECT_THROW(system.install_boundary_flux_component("density", {}, {}), std::runtime_error); +} diff --git a/tests/cpp/integration/runtime/test_program_runtime.cpp b/tests/cpp/integration/runtime/test_program_runtime.cpp index 1d85df251..874793766 100644 --- a/tests/cpp/integration/runtime/test_program_runtime.cpp +++ b/tests/cpp/integration/runtime/test_program_runtime.cpp @@ -15,7 +15,8 @@ #include // CompositeModel #include // Euler #include // add_compiled_model -#include // ProgramContext (the seam under test) +#include +#include // ProgramContext (the seam under test) #include #include #include @@ -63,6 +64,16 @@ struct UnitDensitySource { }; using SourcedGasModel = CompositeModel; +struct DrainingDensitySource { + template + POPS_HD State apply(const State&, const Aux&) const { + State source{}; + source[0] = Real(-1); + return source; + } +}; +using DrainingGasModel = CompositeModel; + struct ProjectingEuler : Euler { POPS_HD State project(const State& input, const Aux&) const { State output = input; @@ -104,6 +115,12 @@ static void add_sourced_gas(System& system, double gamma) { "none", "rusanov", "conservative", "explicit", gamma); } +static void add_draining_gas(System& system, const std::string& name, double gamma) { + add_compiled_model( + system, name, DrainingGasModel{Euler{gamma}, DrainingDensitySource{}, NoEll{}}, "none", + "rusanov", "conservative", "explicit", gamma); +} + static void add_projecting_gas(System& system, double gamma) { ProjectingEuler transport; transport.gamma = gamma; @@ -117,6 +134,109 @@ static void add_diffusive_gas(System& system, double gamma) { add_compiled_model(system, "gas", model, "none", "rusanov", "conservative", "explicit", gamma); } +TEST(ProgramRuntime, BalanceDueWindowUsesTheOuterAcceptedStepAndCleansUpOnFailure) { + runtime::program::ProgramRuntimeState state; + const std::string contract = "pops.balance-due-contract.v1:sha256:" + std::string(64, '1'); + const std::string route = "pops.balance-ledger-route.v1:sha256:" + std::string(64, '2'); + + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 3, "test"), std::logic_error); + state.run_balance_due_window(2, "test", [&] { + EXPECT_TRUE(state.balance_consumer_is_due(contract, route, 3, "test")); + EXPECT_FALSE(state.balance_consumer_is_due(contract, route, 2, "test")); + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 0, "test"), + std::invalid_argument); + EXPECT_THROW((void)state.balance_consumer_is_due("forged", route, 3, "test"), + std::invalid_argument); + }); + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 3, "test"), std::logic_error); + + EXPECT_THROW( + state.run_balance_due_window(3, "test", [] { throw std::runtime_error("attempt rejected"); }), + std::runtime_error); + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 4, "test"), std::logic_error); +} + +TEST(ProgramRuntime, AutomaticBalanceDueMarkerIsAttemptLocalMonotoneAndReplaySafe) { + runtime::program::ProgramRuntimeState state; + + EXPECT_FALSE(state.automatic_balance_capture_due()); + EXPECT_THROW(state.note_automatic_balance_capture_due(true, "test"), std::logic_error); + state.run_balance_due_window(0, "test", [&] { + state.note_automatic_balance_capture_due(false, "test"); + EXPECT_FALSE(state.automatic_balance_capture_due()); + state.note_automatic_balance_capture_due(true, "test"); + EXPECT_TRUE(state.automatic_balance_capture_due()); + state.note_automatic_balance_capture_due(false, "test"); + EXPECT_TRUE(state.automatic_balance_capture_due()); + }); + EXPECT_TRUE(state.automatic_balance_capture_due()); + + state.begin_step_projection_report(); + EXPECT_FALSE(state.automatic_balance_capture_due()); + state.run_balance_replay("test", [&] { + state.note_automatic_balance_capture_due(false, "test"); + EXPECT_FALSE(state.automatic_balance_capture_due()); + EXPECT_THROW(state.note_automatic_balance_capture_due(true, "test"), std::logic_error); + }); + EXPECT_FALSE(state.automatic_balance_capture_due()); +} + +TEST(ProgramRuntime, SelectedAutomaticBalanceTermsRequireCompleteQualifiedEvidence) { + runtime::program::ProgramRuntimeState state; + const std::string route = "pops.balance-ledger-route.v1:sha256:" + std::string(64, '5'); + state.begin_step_projection_report(); + state.run_balance_due_window(0, "test", [&] { + state.note_automatic_balance_capture_due(true, "test"); + state.record_balance_term(route, "storage_change", 1.0, "test"); + state.record_balance_term(route, "outward_boundary_flux", 2.0, "test"); + state.record_balance_term(route, "sources", 3.0, "test"); + state.record_automatic_balance_term(2, 0, 1, "projection", 0.25, "test"); + state.record_automatic_balance_term(2, 1, 1, "projection", 0.75, "test"); + state.record_automatic_balance_term(2, 0, 1, "reflux", 0.5, "test"); + }); + state.complete_balance_step(true); + + const auto selected = + state.selected_accepted_balance_terms(route, 2, 1, {0, 1}, {"projection", "reflux"}, "test"); + EXPECT_EQ(selected.at("storage_change"), 1.0); + EXPECT_EQ(selected.at("outward_boundary_flux"), 2.0); + EXPECT_EQ(selected.at("sources"), 3.0); + EXPECT_EQ(selected.at("projection"), 1.0); + EXPECT_EQ(selected.at("reflux"), 0.5); + + EXPECT_THROW((void)state.selected_accepted_balance_terms(route, 2, 1, {0, 1, 2}, + {"projection", "reflux"}, "test"), + std::runtime_error); + EXPECT_THROW((void)state.selected_accepted_balance_terms(route, 2, 1, {0, 2}, + {"projection", "reflux"}, "test"), + std::invalid_argument); +} + +TEST(ProgramRuntime, SelectiveReplayCompilesBalanceOffAndRestoresTheGuard) { + runtime::program::ProgramRuntimeState state; + const std::string contract = "pops.balance-due-contract.v1:sha256:" + std::string(64, '3'); + const std::string route = "pops.balance-ledger-route.v1:sha256:" + std::string(64, '4'); + + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 2, "test"), std::logic_error); + state.run_balance_replay("test", [&] { + EXPECT_FALSE(state.balance_consumer_is_due(contract, route, 2, "test")); + EXPECT_THROW((void)state.balance_consumer_is_due("forged", route, 2, "test"), + std::invalid_argument); + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 0, "test"), + std::invalid_argument); + EXPECT_THROW(state.run_balance_replay("nested", [] {}), std::logic_error); + EXPECT_THROW(state.run_balance_due_window(1, "nested", [] {}), std::logic_error); + }); + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 2, "test"), std::logic_error); + state.run_balance_due_window(1, "test", [&] { + EXPECT_THROW(state.run_balance_replay("window", [] {}), std::logic_error); + }); + + EXPECT_THROW(state.run_balance_replay("test", [] { throw std::runtime_error("replay failed"); }), + std::runtime_error); + EXPECT_THROW((void)state.balance_consumer_is_due(contract, route, 2, "test"), std::logic_error); +} + TEST(ProgramRuntime, ReplayAuthorityRequiresAnArtifactAndAnExactRingDepthPair) { runtime::program::ProgramRuntimeState state; state.history_replay_authorities_ = {{"gas.previous", 3}}; @@ -312,6 +432,65 @@ TEST(ProgramRuntime, GlobalCadencePublishesExactSubstepAndStrideWindowTimes) { EXPECT_DOUBLE_EQ(catchup.program_cadence_window_start_time(), 0.0); } +TEST(ProgramRuntime, StrideHeldStepsPublishTheExactZeroBalance) { +#if defined(POPS_HAS_KOKKOS) + ensure_kokkos(); +#endif + SystemConfig config; + config.n = 4; + config.L = 1.0; + config.periodicity = {true, true}; + + System system(config); + runtime::program::ProgramContext context(&system); + const std::string route = "pops.balance-ledger-route.v1:sha256:" + std::string(64, '7'); + const std::array, 5> records{{ + {"storage_change", 1.0}, + {"outward_boundary_flux", 2.0}, + {"sources", 3.0}, + {"reflux", 4.0}, + {"projection", 5.0}, + }}; + context.install([&](double) { + for (const auto& [name, value] : records) + context.record_balance_term(route, name, value); + }); + system.set_program_cadence(/*substeps=*/1, /*stride=*/3); + + const auto step_and_read = [&]() { + system.begin_step_transaction(); + system.step(0.1); + const auto balance = system.accepted_balance_terms(route); + system.commit_step_transaction(); + system.finalize_step_transaction(); + return balance; + }; + + for (int held = 0; held < 2; ++held) { + const auto balance = step_and_read(); + ASSERT_EQ(balance.size(), records.size()); + for (const auto& [name, _value] : records) + EXPECT_DOUBLE_EQ(balance.at(name), 0.0); + } + + system.begin_step_transaction(); + system.step(0.1); + const auto rejected_due = system.accepted_balance_terms(route); + for (const auto& [name, value] : records) + EXPECT_DOUBLE_EQ(rejected_due.at(name), value); + system.rollback_step_transaction(); + system.begin_step_transaction(); + const auto restored_held = system.accepted_balance_terms(route); + for (const auto& [name, _value] : records) + EXPECT_DOUBLE_EQ(restored_held.at(name), 0.0); + system.rollback_step_transaction(); + + const auto due = step_and_read(); + ASSERT_EQ(due.size(), records.size()); + for (const auto& [name, value] : records) + EXPECT_DOUBLE_EQ(due.at(name), value); +} + TEST(ProgramRuntime, CadenceUsesThePreparedFacadeEndpointWhenFloatingPointAdditionIsNonAssociative) { #if defined(POPS_HAS_KOKKOS) @@ -807,6 +986,101 @@ TEST(ProgramRuntime, SourceOnlyProgramStagePreservesEmbeddedBoundaryInactiveCell EXPECT_GT(inactive_cells, 0); } +TEST(ProgramRuntime, TerminalSourcePublicationAcceptsPreparedRecoveryCandidate) { +#if defined(POPS_HAS_KOKKOS) + ensure_kokkos(); +#endif + constexpr int n = 8; + constexpr double gamma = 1.4; + const std::size_t cells = static_cast(n) * n; + SystemConfig cfg; + cfg.n = n; + cfg.L = 1.0; + cfg.periodicity = {true, true}; + + System system(cfg); + add_draining_gas(system, "gas", gamma); + std::vector initial(4 * cells); + fill_ic(initial, n, gamma); + system.set_state("gas", initial); + system.set_program_block_map({0}); + runtime::program::ProgramContext context(&system); + context.configure_primary_clock("test.clock.source-recovery"); + context.install([context](double step) { + context.begin_step(step); + MultiFab& live = context.state(0); + MultiFab& source = context.rhs_scratch(920001, 0, live); + MultiFab& candidate = context.scratch_state(920002, 0, live); + context.source_default_into(0, live, source); + context.lincomb(candidate, Real(1), live, Real(0), live); + context.axpy(candidate, Real(step), source); + context.commit_many({{&live, &candidate}}); + }); + system.set_program_block_map({0}); + + system.step(0.25); + const std::vector accepted = system.get_state("gas"); + for (std::size_t cell = 0; cell < cells; ++cell) + EXPECT_DOUBLE_EQ(accepted[cell], 0.75); + EXPECT_DOUBLE_EQ(system.time(), 0.25); + EXPECT_EQ(system.macro_step(), 1); +} + +TEST(ProgramRuntime, TerminalSourceRecoveryRefusalPreventsPartialMultiBlockCommit) { +#if defined(POPS_HAS_KOKKOS) + ensure_kokkos(); +#endif + constexpr int n = 8; + constexpr double gamma = 1.4; + const std::size_t cells = static_cast(n) * n; + SystemConfig cfg; + cfg.n = n; + cfg.L = 1.0; + cfg.periodicity = {true, true}; + + System system(cfg); + add_draining_gas(system, "first", gamma); + add_draining_gas(system, "second", gamma); + std::vector initial(4 * cells); + fill_ic(initial, n, gamma); + system.set_state("first", initial); + system.set_state("second", initial); + system.set_program_block_map({0, 1}); + runtime::program::ProgramContext context(&system); + context.configure_primary_clock("test.clock.source-recovery-multiblock"); + context.install([context](double step) { + context.begin_step(step); + MultiFab& first = context.state(0); + MultiFab& second = context.state(1); + MultiFab& first_source = context.rhs_scratch(920011, 0, first); + MultiFab& second_source = context.rhs_scratch(920012, 1, second); + MultiFab& first_candidate = context.scratch_state(920013, 0, first); + MultiFab& second_candidate = context.scratch_state(920014, 1, second); + context.source_default_into(0, first, first_source); + context.source_default_into(1, second, second_source); + context.lincomb(first_candidate, Real(1), first, Real(0), first); + context.lincomb(second_candidate, Real(1), second, Real(0), second); + context.axpy(first_candidate, Real(step), first_source); + context.axpy(second_candidate, Real(2) * Real(step), second_source); + context.commit_many({{&first, &first_candidate}, {&second, &second_candidate}}); + }); + system.set_program_block_map({0, 1}); + + // The first block reaches rho=0.5 and is valid, while the second reaches rho=0. If commit_many + // copied as it iterated, the first live state would leak before the second recovery refusal. + try { + system.step(0.5); + FAIL() << "an unrecoverable multi-block model-source endpoint must not publish"; + } catch (const std::runtime_error& error) { + EXPECT_NE(std::string(error.what()).find("prepared variable recovery rejected"), + std::string::npos); + } + EXPECT_EQ(system.get_state("first"), initial); + EXPECT_EQ(system.get_state("second"), initial); + EXPECT_DOUBLE_EQ(system.time(), 0.0); + EXPECT_EQ(system.macro_step(), 0); +} + TEST(ProgramRuntime, ExplicitSourceProgramPreservesEmbeddedBoundaryInactiveCells) { #if defined(POPS_HAS_KOKKOS) ensure_kokkos(); @@ -1340,6 +1614,83 @@ TEST(ProgramRuntime, EmbeddedBoundaryRejectsUnqualifiedBoundaryLinearizationEntr expect_metric_rejection([&] { context.boundary_jvp_into_at(point, 0, state, output, output); }); } +TEST(ProgramRuntime, AnalyticInitialStatePublishesOnlyAfterPreparedRecoveryAcceptsEveryCell) { +#if defined(POPS_HAS_KOKKOS) + ensure_kokkos(); +#endif + constexpr int n = 8; + System system(SystemConfig{n, 1.0, Periodicity{true, true}}); + ModelSpec scalar; + scalar.transport = "exb"; + scalar.source = "none"; + scalar.elliptic = "charge"; + system.add_block("tracer", scalar); + + const std::vector accepted(static_cast(n) * n, 0.25); + system.set_state("tracer", accepted); + system.set_block_conversion( + "tracer", [](const double* in, double* out) { out[0] = in[0]; }, + [](const double* in, double* out) { + RecoveryReport report; + if (!std::isfinite(in[0]) || in[0] > 0.75) { + report.status = RecoveryStatus::kRejected; + report.cause = RecoveryCause::kInadmissibleCandidate; + report.failing_component = 0; + return report; + } + out[0] = in[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; + }); + + EXPECT_THROW(system.set_analytic_expression_state( + "tracer", "cell", "cell", "conservative_cell_average", {{"constant"}}, {{1.0}}), + std::runtime_error); + EXPECT_EQ(system.get_state("tracer"), accepted); + + EXPECT_THROW(system.set_analytic_mapped_state("tracer", {{"input", "constant", "add"}}, + {{0.0, 1.0, 0.0}}, {"state:0"}), + std::runtime_error); + EXPECT_EQ(system.get_state("tracer"), accepted); + + EXPECT_THROW(system.set_analytic_gaussian_state("tracer", 0.5, 0.5, 1.0, 0.0, 16.0), + std::runtime_error); + EXPECT_EQ(system.get_state("tracer"), accepted); + + EXPECT_EQ(system.set_analytic_expression_state( + "tracer", "cell", "cell", "conservative_cell_average", {{"constant"}}, {{0.5}}), + static_cast(n) * n); + EXPECT_EQ(system.get_state("tracer"), std::vector(static_cast(n) * n, 0.5)); +} + +TEST(ProgramRuntime, AnalyticInitialStatePublishesWhenPreparedRecoveryAcceptsEveryCell) { +#if defined(POPS_HAS_KOKKOS) + ensure_kokkos(); +#endif + constexpr int n = 8; + System system(SystemConfig{n, 1.0, Periodicity{true, true}}); + ModelSpec scalar; + scalar.transport = "exb"; + scalar.source = "none"; + scalar.elliptic = "charge"; + system.add_block("tracer", scalar); + system.set_block_conversion( + "tracer", [](const double* in, double* out) { out[0] = in[0]; }, + [](const double* in, double* out) { + RecoveryReport report; + out[0] = in[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + return report; + }); + + EXPECT_EQ(system.set_analytic_expression_state( + "tracer", "cell", "cell", "conservative_cell_average", {{"constant"}}, {{0.5}}), + static_cast(n) * n); + EXPECT_EQ(system.get_state("tracer"), std::vector(static_cast(n) * n, 0.5)); +} + TEST(ProgramRuntime, RejectedAttemptRestoresStateHistoryCacheDiagnosticsAndClock) { #if defined(POPS_HAS_KOKKOS) ensure_kokkos(); diff --git a/tests/cpp/integration/runtime/test_route_ids.cpp b/tests/cpp/integration/runtime/test_route_ids.cpp index 46b4f1ef2..05767f0ed 100644 --- a/tests/cpp/integration/runtime/test_route_ids.cpp +++ b/tests/cpp/integration/runtime/test_route_ids.cpp @@ -128,11 +128,11 @@ TEST(RouteIds, UnknownTokenRefusedWithFamilyTokenValidSetAndNoDefaultPhrase) { << "field_solver 'amg' refuse (famille, token, set valide, no-default)"; } { - const std::string m = throw_message([] { parse_limiter_route("superbee"); }); - EXPECT_TRUE(contains(m, "limiter") && contains(m, "superbee") && - contains(m, "none|minmod|vanleer|weno5") && + const std::string m = throw_message([] { parse_limiter_route("koren"); }); + EXPECT_TRUE(contains(m, "limiter") && contains(m, "koren") && + contains(m, "none|minmod|vanleer|weno5|mc|superbee") && contains(m, "never fall back to a default")) - << "limiter 'superbee' refuse (famille, token, set valide, no-default)"; + << "limiter 'koren' refuse (famille, token, set valide, no-default)"; } { const std::string m = throw_message([] { parse_transport_route("upwind"); }); @@ -173,13 +173,13 @@ TEST(RouteIds, UnknownAndReservedNumericIdsAreRefused) { TEST(RouteIds, RegistrySignatureAuthenticatesFullContent) { const std::string signature = route_registry_signature(); - EXPECT_TRUE(signature.rfind("v2:", 0) == 0) << signature; - EXPECT_TRUE(signature.size() == 67) << "v2: plus complete sha256 catalog digest"; + EXPECT_TRUE(signature.rfind("v3:", 0) == 0) << signature; + EXPECT_TRUE(signature.size() == 67) << "v3: plus complete sha256 catalog digest"; EXPECT_TRUE(throw_message([&] { verify_route_manifest("", "test"); }).find("missing") != std::string::npos); EXPECT_TRUE(throw_message([&] { verify_route_manifest( - "v2:0000000000000000000000000000000000000000000000000000000000000000", "test"); + "v3:0000000000000000000000000000000000000000000000000000000000000000", "test"); }).find("mismatch") != std::string::npos); EXPECT_NO_THROW(verify_route_manifest(signature, "test")); } @@ -191,6 +191,16 @@ TEST(RouteIds, RouteInfoCarriesNativeEntryRequirementsAndLimitations) { EXPECT_TRUE(std::string(route_info(RiemannRouteId::kRoe).native_entry) == "pops::RoeFlux" && contains(route_info(RiemannRouteId::kRoe).requirements, "roe_dissipation")) << "route_info(kRoe) : one generic Roe provider route"; + const auto& recovery = route_info(RiemannRouteId::kRoeHllRusanovRecovery); + bool recovery_polar_ok = true; + for (const RiemannTag& tag : kRiemanns) + if (std::string(tag.name) == recovery.token) + recovery_polar_ok = tag.polar_ok; + EXPECT_TRUE(std::string(recovery.token) == "roe_hll_rusanov_recovery" && + contains(recovery.native_entry, "PreparedRiemannRecoveryPolicy") && + contains(recovery.requirements, "wave_speeds") && + contains(recovery.requirements, "roe_dissipation") && !recovery_polar_ok) + << "route_info(kRoeHllRusanovRecovery): exact fixed Cartesian/AMR policy"; EXPECT_TRUE(std::string(route_info(TimeRouteId::kSsprk3).native_entry) == "pops::SSPRK3" && std::string(route_info(TimeRouteId::kSsprk3).limitations).empty()) << "route_info(kSsprk3) : native production sans limitation obsolete"; diff --git a/tests/cpp/integration/runtime/test_system_abstraction.cpp b/tests/cpp/integration/runtime/test_system_abstraction.cpp index 1ac45204c..df09ee6bc 100644 --- a/tests/cpp/integration/runtime/test_system_abstraction.cpp +++ b/tests/cpp/integration/runtime/test_system_abstraction.cpp @@ -22,8 +22,8 @@ struct ElectronToy { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State& u) const { return -u[0]; } }; @@ -32,8 +32,8 @@ struct IonToy { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/integration/runtime/test_system_coupler.cpp b/tests/cpp/integration/runtime/test_system_coupler.cpp index e3efd5dc7..ba6a951d6 100644 --- a/tests/cpp/integration/runtime/test_system_coupler.cpp +++ b/tests/cpp/integration/runtime/test_system_coupler.cpp @@ -25,8 +25,8 @@ struct ElectronSource { Real rate = Real(2); - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{rate}; } POPS_HD Real elliptic_rhs(const State& u) const { return -u[0]; } }; @@ -38,8 +38,8 @@ struct IonSource { Real rate = Real(3); - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{rate}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/integration/runtime/test_system_hardening.cpp b/tests/cpp/integration/runtime/test_system_hardening.cpp index d25ed1258..8bed1fa6a 100644 --- a/tests/cpp/integration/runtime/test_system_hardening.cpp +++ b/tests/cpp/integration/runtime/test_system_hardening.cpp @@ -25,8 +25,8 @@ struct Scalar { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/integration/runtime/test_system_two_explicit.cpp b/tests/cpp/integration/runtime/test_system_two_explicit.cpp index 6b945b908..8282fa7a8 100644 --- a/tests/cpp/integration/runtime/test_system_two_explicit.cpp +++ b/tests/cpp/integration/runtime/test_system_two_explicit.cpp @@ -31,8 +31,8 @@ struct Production { using Aux = pops::Aux; static constexpr int n_vars = 1; Real rate = Real(1); - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{rate}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; @@ -44,10 +44,10 @@ struct AdvectX { using Aux = pops::Aux; static constexpr int n_vars = 1; Real a = Real(1); - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{dir == 0 ? a * u[0] : Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return a < 0 ? -a : a; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return a < 0 ? -a : a; } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/integration/runtime/test_wave_speed_cache_engagement.cpp b/tests/cpp/integration/runtime/test_wave_speed_cache_engagement.cpp index 392595642..44ac6878b 100644 --- a/tests/cpp/integration/runtime/test_wave_speed_cache_engagement.cpp +++ b/tests/cpp/integration/runtime/test_wave_speed_cache_engagement.cpp @@ -46,7 +46,7 @@ struct CountingIsothermal { int busy = 0; Counter calls; // handle capture par valeur dans le kernel (donnees partagees) - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { const Real rho = u[0]; const Real vx = u[1] / rho, vy = u[2] / rho; const Real p = c0 * c0 * rho; @@ -62,12 +62,12 @@ struct CountingIsothermal { } return F; } - POPS_HD Real max_wave_speed(const State& u, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const State& u, const auto&, int dir) const { const Real v = (dir == 0 ? u[1] : u[2]) / u[0]; const Real av = v < 0 ? -v : v; return av + c0; } - POPS_HD void wave_speeds(const State& u, const Aux&, int dir, Real& lo, Real& hi) const { + POPS_HD void wave_speeds(const State& u, const auto&, int dir, Real& lo, Real& hi) const { Kokkos::atomic_add(&calls(), 1LL); const Real v = (dir == 0 ? u[1] : u[2]) / u[0]; Real acc = Real(0); diff --git a/tests/cpp/support/explicit_amr_program.hpp b/tests/cpp/support/explicit_amr_program.hpp index 22f54b71b..ceb9e9b15 100644 --- a/tests/cpp/support/explicit_amr_program.hpp +++ b/tests/cpp/support/explicit_amr_program.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -14,7 +15,8 @@ namespace pops::test { /// /// AmrProgramContext owns level clocks and conservative catch-up. AmrRuntime remains the spatial /// engine inspected by tests and exposes no temporal step entry point. -inline void install_forward_euler_program(AmrSystem& system) { +inline std::shared_ptr install_forward_euler_program_context( + AmrSystem& system, const std::function& prepare_runtime = {}) { std::vector block_map(static_cast(system.n_blocks())); std::iota(block_map.begin(), block_map.end(), 0); // The facade selects the common AmrRuntime route during lazy construction only when a Program @@ -23,13 +25,16 @@ inline void install_forward_euler_program(AmrSystem& system) { system.install_program_step([](double) {}); if (!system.uses_runtime_engine() || system.engine() == nullptr) throw std::runtime_error("explicit AMR test Program requires the materialized runtime engine"); + if (prepare_runtime) + prepare_runtime(system); auto context = std::make_shared(system.engine(), &system); context->configure_primary_clock("test.clock.macro"); context->install([context](double macro_dt) { context->advance_hierarchy(macro_dt, [context](double level_dt) { context->set_stage_time(0, 1); - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); std::vector states; std::vector residuals; @@ -50,6 +55,11 @@ inline void install_forward_euler_program(AmrSystem& system) { // A direct Program replacement revokes every artifact-derived binding authority, including the // block map. Publish this fixture's explicit identity map only after the final body is installed. system.set_program_block_map(block_map); + return context; +} + +inline void install_forward_euler_program(AmrSystem& system) { + static_cast(install_forward_euler_program_context(system)); } } // namespace pops::test diff --git a/tests/cpp/support/polar_boundary_plan.hpp b/tests/cpp/support/polar_boundary_plan.hpp new file mode 100644 index 000000000..6d70a7e03 --- /dev/null +++ b/tests/cpp/support/polar_boundary_plan.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace pops::test_support { + +inline std::shared_ptr polar_boundary_plan(int ncomp, bool close_radial_flux, + int required_depth) { + BCRec descriptor; + descriptor.xlo = descriptor.xhi = BCType::Foextrap; + std::vector names; + names.reserve(static_cast(ncomp)); + for (int component = 0; component < ncomp; ++component) + names.push_back("u" + std::to_string(component)); + VariableSet variables{ + VariableKind::Conservative, std::move(names), ncomp, + std::vector(static_cast(ncomp), VariableRole::Scalar)}; + return detail::prepare_builtin_boundary_plan( + close_radial_flux ? "test-polar-closed" : "test-polar-outflow", {}, required_depth, variables, + descriptor, close_radial_flux); +} + +} // namespace pops::test_support diff --git a/tests/cpp/test_durations.json b/tests/cpp/test_durations.json index 855ccd973..1fa90a6a2 100644 --- a/tests/cpp/test_durations.json +++ b/tests/cpp/test_durations.json @@ -5,8 +5,23 @@ "estimated_targets": [ "test_amr_program_diffusion", "test_amr_program_positivity_floor", + "test_cell_temporal_partition_executor", + "test_cell_temporal_program_route", "test_flux_failure_loader_transaction", - "test_interface_flux_fragment_ledger" + "test_interface_flux_fragment_ledger", + "test_nd_cluster", + "test_nd_finite_volume", + "test_nd_flux_ledger", + "test_nd_hierarchy_plan", + "test_nd_metric_provider", + "test_nd_tag_mask", + "test_nd_transfer", + "test_prepared_cartesian_nd", + "test_prepared_numerics_gate", + "test_prepared_stream_executor", + "test_spatial_provider_matrix", + "test_temporal_partition_restart", + "test_variable_recovery_chain" ], "estimate_policy": "new unmeasured targets use a conservative analogous-target estimate, falling back to the catalog median until the next CTest timing refresh", "measured_refresh": { @@ -16,7 +31,7 @@ "refresh_source_run": "30190778708", "source_job": "87152034744", "source_run": "29352485297", - "target_count": 187, + "target_count": 208, "unit_seconds": "aggregate CTest wall time per build target" }, "test_adaptive_multirate": 0.02, @@ -72,6 +87,8 @@ "test_cache_manager": 0.04, "test_canonical_identity": 0.02, "test_capability_report": 0.01, + "test_cell_temporal_partition_executor": 0.05, + "test_cell_temporal_program_route": 0.05, "test_cf_interface": 0.01, "test_cfl_dt": 0.02, "test_checkpoint_cache": 0.03, @@ -139,6 +156,19 @@ "test_multiblock_interface_scheduler": 0.09, "test_multifab": 0.01, "test_multirate_stride": 0.01, + "test_nd_boundary_schedule": 0.2, + "test_nd_cluster": 0.2, + "test_nd_distribution": 0.2, + "test_nd_execution": 0.2, + "test_nd_finite_volume": 0.05, + "test_nd_flux_ledger": 0.2, + "test_nd_hierarchy_plan": 0.2, + "test_nd_layout": 0.2, + "test_nd_metric_provider": 0.02, + "test_nd_tag_mask": 0.2, + "test_nd_topology": 0.2, + "test_nd_transfer": 0.2, + "test_nd_translation_schedule": 0.2, "test_native_aux_named": 0.14, "test_native_loader_param_overflow": 0.06, "test_newton_robustness": 0.04, @@ -161,6 +191,9 @@ "test_polar_transport_mms": 2.56, "test_positivity_floor": 0.02, "test_prepared_boundary_plan": 0.02, + "test_prepared_cartesian_nd": 0.02, + "test_prepared_numerics_gate": 0.02, + "test_prepared_stream_executor": 0.02, "test_primitive_recon": 0.01, "test_pure_field_algebra_extreme_dot": 0.02, "test_profiler": 0.04, @@ -186,11 +219,13 @@ "test_solve_robust": 83.66, "test_solver_codegen_generated": 0.66, "test_spatial_discretisation": 0.01, + "test_spatial_provider_matrix": 0.01, "test_splitting": 0.01, "test_step_attempt_rejected_amr_link": 0.01, "test_step_attempt_rejected_header_only": 0.01, "test_structured_solver_diagnostics": 0.01, "test_sync_residence": 0.01, + "test_temporal_partition_restart": 0.05, "test_system_abstraction": 0.01, "test_system_coupler": 0.01, "test_system_hardening": 0.01, @@ -199,6 +234,7 @@ "test_two_species_minimal": 0.13, "test_user_time_integrator": 0.01, "test_variable_epsilon": 0.37, + "test_variable_recovery_chain": 0.02, "test_variable_role": 0.01, "test_variable_user_role": 0.01, "test_wave_speed_cache_engagement": 0.03, diff --git a/tests/cpp/test_sources.cmake b/tests/cpp/test_sources.cmake index 8dee480e5..68325ba9c 100644 --- a/tests/cpp/test_sources.cmake +++ b/tests/cpp/test_sources.cmake @@ -55,6 +55,9 @@ set(POPS_CPP_TEST_SOURCE_test_capability_report "tests/cpp/integration/runtime/t set(POPS_CPP_TEST_SOURCE_test_canonical_identity "tests/cpp/unit/core/test_canonical_identity.cpp") set(POPS_CPP_TEST_SOURCE_test_cf_interface "tests/cpp/integration/amr/test_cf_interface.cpp") set(POPS_CPP_TEST_SOURCE_test_program_reflux_ledger "tests/cpp/integration/amr/test_program_reflux_ledger.cpp") +set(POPS_CPP_TEST_SOURCE_test_temporal_partition_restart "tests/cpp/integration/amr/test_temporal_partition_restart.cpp") +set(POPS_CPP_TEST_SOURCE_test_cell_temporal_partition_executor "tests/cpp/integration/amr/test_cell_temporal_partition_executor.cpp") +set(POPS_CPP_TEST_SOURCE_test_cell_temporal_program_route "tests/cpp/integration/amr/test_cell_temporal_program_route.cpp") set(POPS_CPP_TEST_SOURCE_test_cfl_dt "tests/cpp/unit/numerics/test_cfl_dt.cpp") set(POPS_CPP_TEST_SOURCE_test_checkpoint_cache "tests/cpp/integration/runtime/test_checkpoint_cache.cpp") set(POPS_CPP_TEST_SOURCE_test_checkpoint_history "tests/cpp/integration/runtime/test_checkpoint_history.cpp") @@ -101,6 +104,7 @@ set(POPS_CPP_TEST_SOURCE_test_copy_schedule_cache "tests/cpp/unit/mesh/test_copy set(POPS_CPP_TEST_SOURCE_test_fill_boundary "tests/cpp/unit/mesh/test_fill_boundary.cpp") set(POPS_CPP_TEST_SOURCE_test_fill_boundary_cache "tests/cpp/unit/mesh/test_fill_boundary_cache.cpp") set(POPS_CPP_TEST_SOURCE_test_prepared_boundary_plan "tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp") +set(POPS_CPP_TEST_SOURCE_test_prepared_stream_executor "tests/cpp/unit/runtime/test_prepared_stream_executor.cpp") set(POPS_CPP_TEST_SOURCE_test_flux_register "tests/cpp/integration/amr/test_flux_register.cpp") set(POPS_CPP_TEST_SOURCE_test_flux_failure_loader_transaction "tests/cpp/integration/native_loader/test_flux_failure_loader_transaction.cpp") set(POPS_CPP_TEST_SOURCE_test_flux_interfaces "tests/cpp/unit/numerics/test_flux_interfaces.cpp") @@ -112,6 +116,8 @@ set(POPS_CPP_TEST_SOURCE_test_krylov_collective_contract "tests/cpp/unit/ellipti set(POPS_CPP_TEST_SOURCE_test_scaled_scalar "tests/cpp/unit/elliptic/test_scaled_scalar.cpp") set(POPS_CPP_TEST_SOURCE_test_geometric_mg "tests/cpp/unit/elliptic/test_geometric_mg.cpp") set(POPS_CPP_TEST_SOURCE_test_geometry "tests/cpp/unit/mesh/test_geometry.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_metric_provider "tests/cpp/unit/mesh/test_nd_metric_provider.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_finite_volume "tests/cpp/unit/numerics/test_nd_finite_volume.cpp") set(POPS_CPP_TEST_SOURCE_test_imex_ap "tests/cpp/unit/numerics/test_imex_ap.cpp") set(POPS_CPP_TEST_SOURCE_test_imex_partial "tests/cpp/unit/numerics/test_imex_partial.cpp") set(POPS_CPP_TEST_SOURCE_test_imex_transport "tests/cpp/unit/numerics/test_imex_transport.cpp") @@ -123,7 +129,10 @@ set(POPS_CPP_TEST_SOURCE_test_multiblock_interface_scheduler "tests/cpp/integrat set(POPS_CPP_TEST_SOURCE_test_mpi_amr_compiled_parity "tests/cpp/integration/mpi/test_mpi_amr_compiled_parity.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_distributed_coarse "tests/cpp/integration/mpi/test_mpi_amr_distributed_coarse.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_dynamic_active_depth "tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp") +set(POPS_CPP_TEST_SOURCE_test_mpi_cell_temporal_program_refusal "tests/cpp/integration/mpi/test_mpi_cell_temporal_program_refusal.cpp") +set(POPS_CPP_TEST_SOURCE_test_mpi_amr_prepared_boundary_cf "tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_program_reflux "tests/cpp/integration/mpi/test_mpi_amr_program_reflux.cpp") +set(POPS_CPP_TEST_SOURCE_test_mpi_amr_rebalance_migration "tests/cpp/integration/mpi/test_mpi_amr_rebalance_migration.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_amr_twoblock_parity "tests/cpp/integration/mpi/test_mpi_amr_twoblock_parity.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_array_reduce "tests/cpp/integration/mpi/test_mpi_array_reduce.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_composite_fac "tests/cpp/integration/mpi/test_mpi_composite_fac.cpp") @@ -141,6 +150,8 @@ set(POPS_CPP_TEST_SOURCE_test_mpi_hybrid_mbox_parity "tests/cpp/integration/mpi/ set(POPS_CPP_TEST_SOURCE_test_mpi_load_balance_authority "tests/cpp/integration/mpi/test_mpi_load_balance_authority.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_mbox_parity "tests/cpp/integration/mpi/test_mpi_mbox_parity.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_multiblock_interface_scheduler "tests/cpp/integration/mpi/test_mpi_multiblock_interface_scheduler.cpp") +set(POPS_CPP_TEST_SOURCE_test_mpi_nd_translation_completion_failstop "tests/cpp/integration/mpi/test_mpi_nd_translation_completion_failstop.cpp") +set(POPS_CPP_TEST_SOURCE_test_mpi_nd_translation_exchange "tests/cpp/integration/mpi/test_mpi_nd_translation_exchange.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_poisson "tests/cpp/integration/mpi/test_mpi_poisson.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_polar_schur "tests/cpp/integration/mpi/test_mpi_polar_schur.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_redistribute "tests/cpp/integration/mpi/test_mpi_redistribute.cpp") @@ -152,6 +163,17 @@ set(POPS_CPP_TEST_SOURCE_test_mpi_system_io_gather "tests/cpp/integration/mpi/te set(POPS_CPP_TEST_SOURCE_test_mpi_system_layout_transfer "tests/cpp/integration/mpi/test_mpi_system_layout_transfer.cpp") set(POPS_CPP_TEST_SOURCE_test_mpi_system_solve_fields "tests/cpp/integration/mpi/test_mpi_system_solve_fields.cpp") set(POPS_CPP_TEST_SOURCE_test_multifab "tests/cpp/unit/mesh/test_multifab.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_boundary_schedule "tests/cpp/unit/mesh/test_nd_boundary_schedule.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_cluster "tests/cpp/unit/mesh/test_nd_cluster.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_distribution "tests/cpp/unit/mesh/test_nd_distribution.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_execution "tests/cpp/unit/mesh/test_nd_execution.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_flux_ledger "tests/cpp/unit/amr/test_nd_flux_ledger.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_hierarchy_plan "tests/cpp/unit/mesh/test_nd_hierarchy_plan.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_layout "tests/cpp/unit/mesh/test_nd_layout.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_tag_mask "tests/cpp/unit/mesh/test_nd_tag_mask.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_topology "tests/cpp/unit/mesh/test_nd_topology.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_transfer "tests/cpp/unit/amr/test_nd_transfer.cpp") +set(POPS_CPP_TEST_SOURCE_test_nd_translation_schedule "tests/cpp/unit/mesh/test_nd_translation_schedule.cpp") set(POPS_CPP_TEST_SOURCE_test_multirate_stride "tests/cpp/unit/physics/test_multirate_stride.cpp") set(POPS_CPP_TEST_SOURCE_test_native_aux_named "tests/cpp/integration/native_loader/test_native_aux_named.cpp") set(POPS_CPP_TEST_SOURCE_test_native_loader_param_overflow "tests/cpp/integration/native_loader/test_native_loader_param_overflow.cpp") @@ -174,6 +196,8 @@ set(POPS_CPP_TEST_SOURCE_test_polar_system_step "tests/cpp/integration/runtime/t set(POPS_CPP_TEST_SOURCE_test_polar_tensor_elliptic_mms "tests/cpp/unit/elliptic/test_polar_tensor_elliptic_mms.cpp") set(POPS_CPP_TEST_SOURCE_test_polar_transport_mms "tests/cpp/unit/physics/test_polar_transport_mms.cpp") set(POPS_CPP_TEST_SOURCE_test_positivity_floor "tests/cpp/unit/numerics/test_positivity_floor.cpp") +set(POPS_CPP_TEST_SOURCE_test_prepared_cartesian_nd "tests/cpp/unit/numerics/test_prepared_cartesian_nd.cpp") +set(POPS_CPP_TEST_SOURCE_test_prepared_numerics_gate "tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp") set(POPS_CPP_TEST_SOURCE_test_primitive_recon "tests/cpp/unit/numerics/test_primitive_recon.cpp") set(POPS_CPP_TEST_SOURCE_test_pure_field_algebra_extreme_dot "tests/cpp/unit/elliptic/test_pure_field_algebra_extreme_dot.cpp") set(POPS_CPP_TEST_SOURCE_test_profiler "tests/cpp/integration/runtime/test_profiler.cpp") @@ -199,6 +223,7 @@ set(POPS_CPP_TEST_SOURCE_test_screened_poisson "tests/cpp/unit/elliptic/test_scr set(POPS_CPP_TEST_SOURCE_test_solve_robust "tests/cpp/unit/elliptic/test_solve_robust.cpp") set(POPS_CPP_TEST_SOURCE_test_solver_codegen_generated "tests/cpp/unit/elliptic/test_solver_codegen_generated.cpp") set(POPS_CPP_TEST_SOURCE_test_spatial_discretisation "tests/cpp/unit/runtime/test_spatial_discretisation.cpp") +set(POPS_CPP_TEST_SOURCE_test_spatial_provider_matrix "tests/cpp/unit/numerics/test_spatial_provider_matrix.cpp") set(POPS_CPP_TEST_SOURCE_test_splitting "tests/cpp/unit/numerics/test_splitting.cpp") set(POPS_CPP_TEST_SOURCE_test_step_attempt_rejected_amr_link "tests/cpp/unit/runtime/test_step_attempt_rejected_amr_link.cpp") set(POPS_CPP_TEST_SOURCE_test_step_attempt_rejected_header_only "tests/cpp/unit/runtime/test_step_attempt_rejected_header_only.cpp") @@ -212,6 +237,7 @@ set(POPS_CPP_TEST_SOURCE_test_system_two_explicit "tests/cpp/integration/runtime set(POPS_CPP_TEST_SOURCE_test_two_species_minimal "tests/cpp/unit/physics/test_two_species_minimal.cpp") set(POPS_CPP_TEST_SOURCE_test_user_time_integrator "tests/cpp/unit/physics/test_user_time_integrator.cpp") set(POPS_CPP_TEST_SOURCE_test_variable_epsilon "tests/cpp/unit/elliptic/test_variable_epsilon.cpp") +set(POPS_CPP_TEST_SOURCE_test_variable_recovery_chain "tests/cpp/unit/numerics/test_variable_recovery_chain.cpp") set(POPS_CPP_TEST_SOURCE_test_variable_role "tests/cpp/unit/runtime/test_variable_role.cpp") set(POPS_CPP_TEST_SOURCE_test_variable_user_role "tests/cpp/unit/runtime/test_variable_user_role.cpp") set(POPS_CPP_TEST_SOURCE_test_wave_speed_cache_engagement "tests/cpp/integration/runtime/test_wave_speed_cache_engagement.cpp") diff --git a/tests/cpp/unit/amr/test_nd_flux_ledger.cpp b/tests/cpp/unit/amr/test_nd_flux_ledger.cpp new file mode 100644 index 000000000..ebba1e831 --- /dev/null +++ b/tests/cpp/unit/amr/test_nd_flux_ledger.cpp @@ -0,0 +1,700 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using pops::Index; +using pops::amr::ClockStamp; +using pops::amr::Rational; +using pops::amr::reflux::nd::CoarseCellFaceSide; +using pops::amr::reflux::nd::CoarseFaceRefluxKey; +using pops::amr::reflux::nd::FaceFluxFragmentKey; +using pops::amr::reflux::nd::FaceFluxFragmentMeasure; +using pops::amr::reflux::nd::FaceFluxLedgerBudget; +using pops::amr::reflux::nd::FaceLedgerCentering; +using pops::amr::reflux::nd::FaceLedgerContribution; +using pops::amr::reflux::nd::FaceLedgerRole; +using pops::amr::reflux::nd::FaceRefinementMapping; +using pops::amr::reflux::nd::LevelTransition; +using pops::amr::reflux::nd::MetricRefluxBudget; +using pops::amr::reflux::nd::TransactionalFaceFluxLedger; +using pops::amr::reflux::nd::coarse_cell_reflux_correction; +using pops::amr::reflux::nd::fine_faces_for_coarse_face; +using pops::amr::reflux::nd::metric_reflux; +using pops::amr::transfer::nd::RefinementRatio; + +constexpr FaceFluxLedgerBudget ledger_budget() { + return {512, 1024, 4}; +} + +constexpr MetricRefluxBudget reflux_budget() { + return {256, 1024, 128}; +} + +void scalar_axpy(double& destination, double coefficient, const double& source) { + destination += coefficient * source; +} + +struct ThrowingPayload { + double value = 0.0; + inline static bool fail_copy = false; + + ThrowingPayload() = default; + explicit ThrowingPayload(double input) : value(input) {} + ThrowingPayload(const ThrowingPayload& other) : value(other.value) { + if (fail_copy) + throw std::runtime_error("injected payload copy failure"); + } + ThrowingPayload& operator=(const ThrowingPayload&) = default; + ThrowingPayload(ThrowingPayload&&) noexcept = default; + ThrowingPayload& operator=(ThrowingPayload&&) noexcept = default; +}; + +struct CopyOnlyPayload { + double value = 0.0; + + CopyOnlyPayload() = default; + explicit CopyOnlyPayload(double input) : value(input) {} + CopyOnlyPayload(const CopyOnlyPayload&) = default; + CopyOnlyPayload(CopyOnlyPayload&&) noexcept = default; + CopyOnlyPayload& operator=(const CopyOnlyPayload&) = delete; + CopyOnlyPayload& operator=(CopyOnlyPayload&&) = delete; +}; + +static_assert(std::is_copy_constructible_v); +static_assert(!std::is_copy_assignable_v); + +template +RefinementRatio sample_ratio() { + if constexpr (Dim == 1) + return RefinementRatio<1>{2}; + else if constexpr (Dim == 2) + return RefinementRatio<2>{2, 3}; + else + return RefinementRatio<3>{2, 3, 4}; +} + +template +FaceRefinementMapping sample_mapping() { + FaceRefinementMapping mapping; + for (int axis = 0; axis < Dim; ++axis) { + mapping.coarse_origin[axis] = -3 + axis; + mapping.fine_origin[axis] = 5 - 2 * axis; + } + return mapping; +} + +template +CoarseFaceRefluxKey sample_query(int axis, std::uint64_t attempt) { + CoarseFaceRefluxKey query; + query.owner = "transport"; + query.state = "U"; + query.levels = LevelTransition{2, 3}; + query.centering = FaceLedgerCentering::Face; + query.axis = axis; + query.attempt = attempt; + query.macro_step = 9; + query.window_begin = Rational{0, 1}; + query.window_end = Rational{1, 1}; + for (int direction = 0; direction < Dim; ++direction) + query.coarse_face[direction] = -1 + 2 * direction; + return query; +} + +ClockStamp clock_at(int level, std::int64_t macro_step, Rational phase, double physical_time) { + return ClockStamp{level, macro_step, phase, physical_time}; +} + +template +FaceFluxFragmentKey fragment_key( + const CoarseFaceRefluxKey& query, FaceLedgerRole role, Index face, std::string stage, + Rational phase, FaceLedgerContribution contribution = FaceLedgerContribution::NumericalFlux) { + FaceFluxFragmentKey key; + key.owner = query.owner; + key.state = query.state; + key.levels = query.levels; + key.centering = query.centering; + key.axis = query.axis; + key.face = face; + key.coarse_face = query.coarse_face; + key.clock = clock_at(role == FaceLedgerRole::Coarse ? query.levels.coarse : query.levels.fine, + query.macro_step, phase, 1.25 + phase.value()); + key.stage = std::move(stage); + key.attempt = query.attempt; + key.role = role; + key.contribution = contribution; + return key; +} + +template +void accumulate_stage(TransactionalFaceFluxLedger& ledger, + const CoarseFaceRefluxKey& query, const RefinementRatio& ratio, + const FaceRefinementMapping& mapping, const MetricRefluxBudget& budget, + const std::string& stage, Rational phase, Rational stage_weight, + Rational substep_begin, Rational substep_end, double duration, + double coarse_face_measure, double fine_face_measure, double coarse_flux, + double fine_flux) { + ledger.accumulate(fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, stage, phase), + FaceFluxFragmentMeasure{stage_weight, substep_begin, substep_end, duration, + coarse_face_measure}, + coarse_flux); + for (const auto& fine_face : fine_faces_for_coarse_face(query, ratio, mapping, budget)) + ledger.accumulate(fragment_key(query, FaceLedgerRole::Fine, fine_face, stage, phase), + FaceFluxFragmentMeasure{stage_weight, substep_begin, substep_end, duration, + fine_face_measure}, + fine_flux); +} + +template +void expect_composite_conservation() { + const auto ratio = sample_ratio(); + const auto mapping = sample_mapping(); + const auto query = sample_query(0, 12); + const auto budget = reflux_budget(); + const auto fine_faces = fine_faces_for_coarse_face(query, ratio, mapping, budget); + const double fine_measure = 0.75; + const double coarse_measure = fine_measure * static_cast(fine_faces.size()); + const double duration = 0.4; + TransactionalFaceFluxLedger ledger{ledger_budget()}; + + ledger.begin(query.attempt); + accumulate_stage(ledger, query, ratio, mapping, budget, "advance", Rational{1, 2}, Rational{1, 1}, + Rational{0, 1}, Rational{1, 1}, duration, coarse_measure, fine_measure, 2.0, + 3.0); + ledger.commit(); + + const auto result = metric_reflux(ledger, query, ratio, mapping, budget, scalar_axpy); + const double expected_mismatch = duration * coarse_measure; + EXPECT_EQ(result.fine_face_count, fine_faces.size()); + EXPECT_NEAR(result.coarse_weighted_measure, duration * coarse_measure, 1e-14); + EXPECT_NEAR(result.fine_weighted_measure, duration * coarse_measure, 1e-14); + EXPECT_NEAR(result.mismatch, expected_mismatch, 1e-14); + + constexpr double coarse_cell_measure = 2.5; + const double correction = coarse_cell_reflux_correction(result, coarse_cell_measure, + CoarseCellFaceSide::Upper, scalar_axpy); + EXPECT_NEAR(correction * coarse_cell_measure + result.mismatch, 0.0, 1e-14); +} + +template +std::size_t tangential_count(const RefinementRatio& ratio, int normal_axis) { + std::size_t result = 1; + for (int axis = 0; axis < Dim; ++axis) + if (axis != normal_axis) + result *= static_cast(ratio[axis]); + return result; +} + +template +std::vector> coordinates(const std::vector>& faces) { + std::vector> result; + result.reserve(faces.size()); + for (const auto& face : faces) { + std::array coordinate{}; + for (int axis = 0; axis < Dim; ++axis) + coordinate[static_cast(axis)] = face[axis]; + result.push_back(coordinate); + } + return result; +} + +} // namespace + +TEST(test_nd_flux_ledger, composite_reflux_conserves_accepted_transport_in_1d_2d_3d) { + expect_composite_conservation<1>(); + expect_composite_conservation<2>(); + expect_composite_conservation<3>(); +} + +TEST(test_nd_flux_ledger, anisotropic_3d_faces_close_the_exact_tangential_surface_product) { + const RefinementRatio<3> ratio{2, 3, 4}; + const auto mapping = sample_mapping<3>(); + const auto budget = reflux_budget(); + constexpr std::array expected_counts{12, 8, 6}; + TransactionalFaceFluxLedger<3, double> ledger{ledger_budget()}; + + for (int axis = 0; axis < 3; ++axis) { + const auto query = sample_query<3>(axis, static_cast(21 + axis)); + const auto fine_faces = fine_faces_for_coarse_face(query, ratio, mapping, budget); + ASSERT_EQ(fine_faces.size(), expected_counts[static_cast(axis)]); + ASSERT_EQ(fine_faces.size(), tangential_count(ratio, axis)); + const double fine_measure = 0.125 * static_cast(axis + 1); + const double coarse_measure = fine_measure * static_cast(fine_faces.size()); + + ledger.begin(query.attempt); + accumulate_stage(ledger, query, ratio, mapping, budget, "surface", Rational{1, 3}, + Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, coarse_measure, + fine_measure, 1.75, 1.75); + ledger.commit(); + + const auto result = metric_reflux(ledger, query, ratio, mapping, budget, scalar_axpy); + EXPECT_NEAR(result.coarse_weighted_measure, coarse_measure, 1e-14); + EXPECT_NEAR(result.fine_weighted_measure, coarse_measure, 1e-14); + EXPECT_NEAR(result.mismatch, 0.0, 1e-14); + } +} + +TEST(test_nd_flux_ledger, axis_permutation_uses_explicit_coordinate_oracles) { + const RefinementRatio<3> original_ratio{2, 3, 4}; + const RefinementRatio<3> permuted_ratio{4, 2, 3}; + const RefinementRatio<3> twice_permuted_ratio{3, 4, 2}; + FaceRefinementMapping<3> original_mapping; + original_mapping.coarse_origin[0] = -3; + original_mapping.coarse_origin[1] = -2; + original_mapping.coarse_origin[2] = -1; + original_mapping.fine_origin[0] = 5; + original_mapping.fine_origin[1] = 3; + original_mapping.fine_origin[2] = 1; + FaceRefinementMapping<3> permuted_mapping; + permuted_mapping.coarse_origin[0] = -1; + permuted_mapping.coarse_origin[1] = -3; + permuted_mapping.coarse_origin[2] = -2; + permuted_mapping.fine_origin[0] = 1; + permuted_mapping.fine_origin[1] = 5; + permuted_mapping.fine_origin[2] = 3; + FaceRefinementMapping<3> twice_permuted_mapping; + twice_permuted_mapping.coarse_origin[0] = -2; + twice_permuted_mapping.coarse_origin[1] = -1; + twice_permuted_mapping.coarse_origin[2] = -3; + twice_permuted_mapping.fine_origin[0] = 3; + twice_permuted_mapping.fine_origin[1] = 1; + twice_permuted_mapping.fine_origin[2] = 5; + + auto original_query = sample_query<3>(0, 31); + auto permuted_query = sample_query<3>(1, 31); + auto twice_permuted_query = sample_query<3>(2, 31); + permuted_query.coarse_face[0] = original_query.coarse_face[2]; + permuted_query.coarse_face[1] = original_query.coarse_face[0]; + permuted_query.coarse_face[2] = original_query.coarse_face[1]; + twice_permuted_query.coarse_face[0] = original_query.coarse_face[1]; + twice_permuted_query.coarse_face[1] = original_query.coarse_face[2]; + twice_permuted_query.coarse_face[2] = original_query.coarse_face[0]; + const auto budget = reflux_budget(); + + const std::vector> original_oracle{ + {9, 12, 17}, {9, 12, 18}, {9, 12, 19}, {9, 12, 20}, {9, 13, 17}, {9, 13, 18}, + {9, 13, 19}, {9, 13, 20}, {9, 14, 17}, {9, 14, 18}, {9, 14, 19}, {9, 14, 20}}; + const std::vector> permuted_oracle{ + {17, 9, 12}, {17, 9, 13}, {17, 9, 14}, {18, 9, 12}, {18, 9, 13}, {18, 9, 14}, + {19, 9, 12}, {19, 9, 13}, {19, 9, 14}, {20, 9, 12}, {20, 9, 13}, {20, 9, 14}}; + const std::vector> twice_permuted_oracle{ + {12, 17, 9}, {12, 18, 9}, {12, 19, 9}, {12, 20, 9}, {13, 17, 9}, {13, 18, 9}, + {13, 19, 9}, {13, 20, 9}, {14, 17, 9}, {14, 18, 9}, {14, 19, 9}, {14, 20, 9}}; + EXPECT_EQ(coordinates(fine_faces_for_coarse_face(original_query, original_ratio, original_mapping, + budget)), + original_oracle); + EXPECT_EQ(coordinates(fine_faces_for_coarse_face(permuted_query, permuted_ratio, permuted_mapping, + budget)), + permuted_oracle); + EXPECT_EQ(coordinates(fine_faces_for_coarse_face(twice_permuted_query, twice_permuted_ratio, + twice_permuted_mapping, budget)), + twice_permuted_oracle); + + TransactionalFaceFluxLedger<3, double> original{ledger_budget()}; + TransactionalFaceFluxLedger<3, double> permuted{ledger_budget()}; + TransactionalFaceFluxLedger<3, double> twice_permuted{ledger_budget()}; + original.begin(31); + permuted.begin(31); + twice_permuted.begin(31); + accumulate_stage(original, original_query, original_ratio, original_mapping, budget, "permuted", + Rational{1, 4}, Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.5, 6.0, 0.5, + 2.0, 2.5); + accumulate_stage(permuted, permuted_query, permuted_ratio, permuted_mapping, budget, "permuted", + Rational{1, 4}, Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.5, 6.0, 0.5, + 2.0, 2.5); + accumulate_stage(twice_permuted, twice_permuted_query, twice_permuted_ratio, + twice_permuted_mapping, budget, "permuted", Rational{1, 4}, Rational{1, 1}, + Rational{0, 1}, Rational{1, 1}, 0.5, 6.0, 0.5, 2.0, 2.5); + original.commit(); + permuted.commit(); + twice_permuted.commit(); + + const auto first = metric_reflux(original, original_query, original_ratio, original_mapping, + budget, scalar_axpy); + const auto second = metric_reflux(permuted, permuted_query, permuted_ratio, permuted_mapping, + budget, scalar_axpy); + const auto third = metric_reflux(twice_permuted, twice_permuted_query, twice_permuted_ratio, + twice_permuted_mapping, budget, scalar_axpy); + EXPECT_NEAR(first.coarse_integrated, second.coarse_integrated, 1e-14); + EXPECT_NEAR(first.fine_integrated, second.fine_integrated, 1e-14); + EXPECT_NEAR(first.mismatch, second.mismatch, 1e-14); + EXPECT_NEAR(first.coarse_integrated, third.coarse_integrated, 1e-14); + EXPECT_NEAR(first.fine_integrated, third.fine_integrated, 1e-14); + EXPECT_NEAR(first.mismatch, third.mismatch, 1e-14); +} + +TEST(test_nd_flux_ledger, exact_stage_weights_are_applied_before_metric_reflux) { + const RefinementRatio<2> ratio{2, 2}; + const auto mapping = sample_mapping<2>(); + const auto query = sample_query<2>(0, 42); + const auto budget = reflux_budget(); + TransactionalFaceFluxLedger<2, double> ledger{ledger_budget()}; + ledger.begin(query.attempt); + accumulate_stage(ledger, query, ratio, mapping, budget, "rk_a", Rational{1, 4}, Rational{1, 4}, + Rational{0, 1}, Rational{1, 1}, 2.0, 2.0, 1.0, 2.0, 2.0); + accumulate_stage(ledger, query, ratio, mapping, budget, "rk_b", Rational{3, 4}, Rational{3, 4}, + Rational{0, 1}, Rational{1, 1}, 2.0, 2.0, 1.0, 4.0, 4.0); + ledger.commit(); + + const auto result = metric_reflux(ledger, query, ratio, mapping, budget, scalar_axpy); + EXPECT_NEAR(result.coarse_integrated, 14.0, 1e-14); + EXPECT_NEAR(result.fine_integrated, 14.0, 1e-14); + EXPECT_NEAR(result.mismatch, 0.0, 1e-14); + EXPECT_EQ(ledger.published_entries(0).size(), 6u); + EXPECT_TRUE(ledger.published_entries(1).empty()); +} + +TEST(test_nd_flux_ledger, coarse_window_matches_two_exact_fine_substeps) { + const RefinementRatio<2> ratio{2, 2}; + const auto mapping = sample_mapping<2>(); + const auto query = sample_query<2>(0, 50); + const auto budget = reflux_budget(); + const auto fine_faces = fine_faces_for_coarse_face(query, ratio, mapping, budget); + ASSERT_EQ(fine_faces.size(), 2u); + TransactionalFaceFluxLedger<2, double> ledger{ledger_budget()}; + ledger.begin(query.attempt); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "advance", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 2.0}, 3.0); + for (const auto& fine_face : fine_faces) { + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Fine, fine_face, "advance", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 2}, 0.5, 1.0}, 3.0); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Fine, fine_face, "advance", Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{1, 2}, Rational{1, 1}, 0.5, 1.0}, 3.0); + } + ledger.commit(); + + const auto result = metric_reflux(ledger, query, ratio, mapping, budget, scalar_axpy); + EXPECT_NEAR(result.coarse_weighted_measure, 2.0, 1e-14); + EXPECT_NEAR(result.fine_weighted_measure, 2.0, 1e-14); + EXPECT_NEAR(result.mismatch, 0.0, 1e-14); +} + +TEST(test_nd_flux_ledger, gaps_overlaps_duration_and_stage_weight_fail_closed) { + const RefinementRatio<2> ratio{2, 2}; + const auto mapping = sample_mapping<2>(); + const auto budget = reflux_budget(); + const auto populate = [&](TransactionalFaceFluxLedger<2, double>& ledger, + const CoarseFaceRefluxKey<2>& query, Rational second_begin, + Rational second_end, double first_duration, double second_duration, + double second_face_measure, Rational second_stage_weight) { + const auto fine_faces = fine_faces_for_coarse_face(query, ratio, mapping, budget); + ledger.begin(query.attempt); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "advance", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 2.0}, 1.0); + for (const auto& fine_face : fine_faces) { + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Fine, fine_face, "advance", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 2}, first_duration, + 1.0}, + 1.0); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Fine, fine_face, "advance", second_begin), + FaceFluxFragmentMeasure{second_stage_weight, second_begin, second_end, second_duration, + second_face_measure}, + 1.0); + } + ledger.commit(); + }; + + const auto gap_query = sample_query<2>(0, 51); + TransactionalFaceFluxLedger<2, double> gap{ledger_budget()}; + populate(gap, gap_query, Rational{3, 4}, Rational{1, 1}, 0.5, 0.5, 1.0, Rational{1, 1}); + EXPECT_THROW((void)metric_reflux(gap, gap_query, ratio, mapping, budget, scalar_axpy), + std::runtime_error); + + const auto overlap_query = sample_query<2>(0, 52); + TransactionalFaceFluxLedger<2, double> overlap{ledger_budget()}; + populate(overlap, overlap_query, Rational{1, 4}, Rational{1, 1}, 0.5, 0.5, 1.0, Rational{1, 1}); + EXPECT_THROW((void)metric_reflux(overlap, overlap_query, ratio, mapping, budget, scalar_axpy), + std::runtime_error); + + const auto bad_duration_query = sample_query<2>(0, 53); + TransactionalFaceFluxLedger<2, double> bad_duration{ledger_budget()}; + populate(bad_duration, bad_duration_query, Rational{1, 2}, Rational{1, 1}, 0.5, 0.6, 5.0 / 6.0, + Rational{1, 1}); + EXPECT_THROW( + (void)metric_reflux(bad_duration, bad_duration_query, ratio, mapping, budget, scalar_axpy), + std::runtime_error); + + const auto bad_stage_weight_query = sample_query<2>(0, 54); + TransactionalFaceFluxLedger<2, double> bad_stage_weight{ledger_budget()}; + populate(bad_stage_weight, bad_stage_weight_query, Rational{1, 2}, Rational{1, 1}, 0.5, 0.5, 2.0, + Rational{1, 2}); + EXPECT_THROW((void)metric_reflux(bad_stage_weight, bad_stage_weight_query, ratio, mapping, budget, + scalar_axpy), + std::runtime_error); + + const auto distorted_clock_query = sample_query<2>(0, 55); + TransactionalFaceFluxLedger<2, double> distorted_clock{ledger_budget()}; + populate(distorted_clock, distorted_clock_query, Rational{1, 2}, Rational{1, 1}, 0.75, 0.25, 1.0, + Rational{1, 1}); + EXPECT_THROW((void)metric_reflux(distorted_clock, distorted_clock_query, ratio, mapping, budget, + scalar_axpy), + std::runtime_error); +} + +TEST(test_nd_flux_ledger, tiny_physical_clock_mismatch_is_not_unit_scaled_roundoff) { + const RefinementRatio<2> ratio{2, 2}; + const auto mapping = sample_mapping<2>(); + const auto query = sample_query<2>(0, 56); + const auto budget = reflux_budget(); + const auto fine_faces = fine_faces_for_coarse_face(query, ratio, mapping, budget); + TransactionalFaceFluxLedger<2, double> ledger{ledger_budget()}; + ledger.begin(query.attempt); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "advance", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0e-16, 2.0}, 1.0); + for (const auto& fine_face : fine_faces) { + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Fine, fine_face, "first", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 2}, 1.0e-16, 0.5}, 1.0); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Fine, fine_face, "second", Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{1, 2}, Rational{1, 1}, 1.0e-16, 0.5}, 1.0); + } + ledger.commit(); + EXPECT_THROW((void)metric_reflux(ledger, query, ratio, mapping, budget, scalar_axpy), + std::runtime_error); +} + +TEST(test_nd_flux_ledger, tangential_product_and_reflux_budgets_fail_closed) { + auto query = sample_query<3>(0, 60); + const auto mapping = sample_mapping<3>(); + const RefinementRatio<3> extreme_ratio{2, std::numeric_limits::max(), + std::numeric_limits::max()}; + EXPECT_THROW( + (void)fine_faces_for_coarse_face(query, extreme_ratio, mapping, MetricRefluxBudget{8, 8, 8}), + std::length_error); + EXPECT_THROW((void)fine_faces_for_coarse_face(query, RefinementRatio<3>{2, 2, 2}, mapping, + MetricRefluxBudget{0, 8, 8}), + std::invalid_argument); + + const RefinementRatio<3> ratio{2, 2, 2}; + const auto budget = reflux_budget(); + TransactionalFaceFluxLedger<3, double> ledger{ledger_budget()}; + ledger.begin(query.attempt); + accumulate_stage(ledger, query, ratio, mapping, budget, "advance", Rational{1, 2}, Rational{1, 1}, + Rational{0, 1}, Rational{1, 1}, 1.0, 4.0, 1.0, 1.0, 1.0); + ledger.commit(); + EXPECT_THROW( + (void)metric_reflux(ledger, query, ratio, mapping, + MetricRefluxBudget{8, ledger.published_size() - 1, 8}, scalar_axpy), + std::length_error); + EXPECT_THROW((void)metric_reflux(ledger, query, ratio, mapping, + MetricRefluxBudget{8, ledger.published_size(), 1}, scalar_axpy), + std::length_error); +} + +TEST(test_nd_flux_ledger, identity_ratios_and_incomplete_fine_surfaces_fail_closed) { + const RefinementRatio<2> ratio{2, 2}; + const auto mapping = sample_mapping<2>(); + const auto query = sample_query<2>(0, 41); + const auto budget = reflux_budget(); + EXPECT_THROW((void)fine_faces_for_coarse_face(query, RefinementRatio<2>{1, 1}, mapping, budget), + std::invalid_argument); + + TransactionalFaceFluxLedger<2, double> ledger{ledger_budget()}; + ledger.begin(query.attempt); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "coarse_stage", + Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.25, 2.0}, 3.0); + const auto fine_faces = fine_faces_for_coarse_face(query, ratio, mapping, budget); + ASSERT_EQ(fine_faces.size(), 2u); + ledger.accumulate( + fragment_key(query, FaceLedgerRole::Fine, fine_faces.front(), "fine_stage", Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.25, 1.0}, 3.0); + ledger.commit(); + + EXPECT_THROW((void)metric_reflux(ledger, query, ratio, mapping, budget, scalar_axpy), + std::runtime_error); +} + +TEST(test_nd_flux_ledger, rejected_attempt_never_publishes_pending_faces) { + const RefinementRatio<2> ratio{2, 3}; + const auto mapping = sample_mapping<2>(); + const auto budget = reflux_budget(); + auto rejected_query = sample_query<2>(0, 0); + TransactionalFaceFluxLedger<2, double> ledger{ledger_budget()}; + ledger.begin(rejected_query.attempt); + accumulate_stage(ledger, rejected_query, ratio, mapping, budget, "candidate", Rational{1, 2}, + Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.25, 3.0, 1.0, 2.0, 2.0); + EXPECT_EQ(ledger.pending_size(), 4u); + EXPECT_EQ(ledger.published_size(), 0u); + EXPECT_THROW((void)metric_reflux(ledger, rejected_query, ratio, mapping, budget, scalar_axpy), + std::runtime_error); + ledger.rollback(); + EXPECT_EQ(ledger.pending_size(), 0u); + EXPECT_EQ(ledger.published_size(), 0u); + + auto accepted_query = sample_query<2>(0, 1); + ledger.begin(accepted_query.attempt); + accumulate_stage(ledger, accepted_query, ratio, mapping, budget, "retry", Rational{1, 2}, + Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.25, 3.0, 1.0, 2.0, 2.0); + ledger.commit(); + EXPECT_EQ(ledger.published_size(), 4u); + EXPECT_THROW(ledger.begin(1), std::invalid_argument); +} + +TEST(test_nd_flux_ledger, ledger_budgets_and_discard_published_attempt_fail_closed) { + EXPECT_THROW(((void)TransactionalFaceFluxLedger<1, double>(FaceFluxLedgerBudget{0, 1, 1})), + std::invalid_argument); + + const auto query0 = sample_query<1>(0, 0); + TransactionalFaceFluxLedger<1, double> pending_limited{FaceFluxLedgerBudget{1, 4, 1}}; + pending_limited.begin(query0.attempt); + pending_limited.accumulate( + fragment_key(query0, FaceLedgerRole::Coarse, query0.coarse_face, "first", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 1.0}, 1.0); + EXPECT_THROW( + pending_limited.accumulate( + fragment_key(query0, FaceLedgerRole::Coarse, query0.coarse_face, "second", + Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 1.0}, 1.0), + std::length_error); + EXPECT_THROW(pending_limited.begin(query0.attempt), std::length_error); + pending_limited.rollback(); + + TransactionalFaceFluxLedger<1, double> publication_limited{FaceFluxLedgerBudget{2, 1, 1}}; + publication_limited.begin(query0.attempt); + publication_limited.accumulate( + fragment_key(query0, FaceLedgerRole::Coarse, query0.coarse_face, "accepted", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 1.0}, 1.0); + publication_limited.commit(); + const auto query1 = sample_query<1>(0, 1); + publication_limited.begin(query1.attempt); + publication_limited.accumulate( + fragment_key(query1, FaceLedgerRole::Coarse, query1.coarse_face, "candidate", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 1.0}, 1.0); + EXPECT_THROW(publication_limited.commit(), std::length_error); + EXPECT_TRUE(publication_limited.in_transaction()); + EXPECT_EQ(publication_limited.published_size(), 1u); + EXPECT_EQ(publication_limited.pending_size(), 1u); + publication_limited.rollback(); + + TransactionalFaceFluxLedger<1, double> discardable{FaceFluxLedgerBudget{4, 4, 1}}; + for (std::uint64_t attempt = 0; attempt < 2; ++attempt) { + const auto query = sample_query<1>(0, attempt); + discardable.begin(attempt); + discardable.accumulate( + fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "published", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 1.0}, 1.0); + discardable.commit(); + } + EXPECT_EQ(discardable.published_size(), 2u); + EXPECT_EQ(discardable.discard_published_attempt(0), 1u); + EXPECT_EQ(discardable.discard_published_attempt(0), 0u); + EXPECT_EQ(discardable.published_size(), 1u); + discardable.begin(2); + EXPECT_THROW((void)discardable.discard_published_attempt(1), std::runtime_error); + discardable.rollback(); + + TransactionalFaceFluxLedger<1, CopyOnlyPayload> copy_only{FaceFluxLedgerBudget{2, 2, 1}}; + copy_only.begin(0); + copy_only.accumulate( + fragment_key(query0, FaceLedgerRole::Coarse, query0.coarse_face, "copy-only", Rational{0, 1}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 1.0, 1.0}, + CopyOnlyPayload{1.0}); + copy_only.commit(); + EXPECT_EQ(copy_only.discard_published_attempt(0), 1u); + EXPECT_EQ(copy_only.published_size(), 0u); +} + +TEST(test_nd_flux_ledger, failed_commit_preserves_accepted_and_pending_transactions) { + TransactionalFaceFluxLedger<1, ThrowingPayload> ledger{FaceFluxLedgerBudget{4, 4, 1}}; + auto accepted = sample_query<1>(0, 0); + ledger.begin(accepted.attempt); + ledger.accumulate( + fragment_key(accepted, FaceLedgerRole::Coarse, accepted.coarse_face, "accepted", + Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.1, 1.0}, + ThrowingPayload{2.0}); + ledger.commit(); + ASSERT_EQ(ledger.published_size(), 1u); + + auto candidate = sample_query<1>(0, 1); + ledger.begin(candidate.attempt); + ledger.accumulate( + fragment_key(candidate, FaceLedgerRole::Coarse, candidate.coarse_face, "candidate", + Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.1, 1.0}, + ThrowingPayload{3.0}); + ThrowingPayload::fail_copy = true; + EXPECT_THROW(ledger.commit(), std::runtime_error); + ThrowingPayload::fail_copy = false; + EXPECT_TRUE(ledger.in_transaction()); + EXPECT_EQ(ledger.published_size(), 1u); + EXPECT_EQ(ledger.pending_size(), 1u); + ledger.rollback(); + EXPECT_EQ(ledger.published_size(), 1u); + EXPECT_EQ(ledger.pending_size(), 0u); + + auto survivor = sample_query<1>(0, 2); + ledger.begin(survivor.attempt); + ledger.accumulate( + fragment_key(survivor, FaceLedgerRole::Coarse, survivor.coarse_face, "survivor", + Rational{1, 2}), + FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.1, 1.0}, + ThrowingPayload{4.0}); + ledger.commit(); + ThrowingPayload::fail_copy = true; + EXPECT_THROW((void)ledger.discard_published_attempt(0), std::runtime_error); + ThrowingPayload::fail_copy = false; + EXPECT_EQ(ledger.published_size(), 2u); +} + +TEST(test_nd_flux_ledger, subnormal_cell_measure_fails_before_non_finite_axpy) { + pops::amr::reflux::nd::MetricFaceReflux reflux; + reflux.mismatch = 1.0; + EXPECT_THROW( + (void)coarse_cell_reflux_correction(reflux, std::numeric_limits::denorm_min(), + CoarseCellFaceSide::Lower, scalar_axpy), + std::overflow_error); +} + +TEST(test_nd_flux_ledger, sources_cell_centering_and_stale_attempts_fail_closed) { + const auto query = sample_query<2>(1, 7); + TransactionalFaceFluxLedger<2, double> ledger{ledger_budget()}; + ledger.begin(query.attempt); + auto source = fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "source", + Rational{1, 2}, FaceLedgerContribution::Source); + EXPECT_THROW( + ledger.accumulate( + source, FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.1, 1.0}, + 3.0), + std::invalid_argument); + auto cell = + fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "cell", Rational{1, 2}); + cell.centering = FaceLedgerCentering::Cell; + EXPECT_THROW( + ledger.accumulate( + cell, FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.1, 1.0}, + 3.0), + std::invalid_argument); + auto stale = + fragment_key(query, FaceLedgerRole::Coarse, query.coarse_face, "stale", Rational{1, 2}); + stale.attempt = 6; + EXPECT_THROW( + ledger.accumulate( + stale, FaceFluxFragmentMeasure{Rational{1, 1}, Rational{0, 1}, Rational{1, 1}, 0.1, 1.0}, + 3.0), + std::invalid_argument); + EXPECT_EQ(ledger.pending_size(), 0u); + ledger.rollback(); + EXPECT_EQ(ledger.published_size(), 0u); +} diff --git a/tests/cpp/unit/amr/test_nd_transfer.cpp b/tests/cpp/unit/amr/test_nd_transfer.cpp new file mode 100644 index 000000000..32c6a14fe --- /dev/null +++ b/tests/cpp/unit/amr/test_nd_transfer.cpp @@ -0,0 +1,351 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using pops::Box; +using pops::FieldView; +using pops::Index; +using pops::Real; +using pops::amr::transfer::nd::Centering; +using pops::amr::transfer::nd::ComponentRange; +using pops::amr::transfer::nd::IndexMapping; +using pops::amr::transfer::nd::PreparedTransfer; +using pops::amr::transfer::nd::RefinementRatio; +using pops::amr::transfer::nd::TransferKind; +using pops::amr::transfer::nd::TransferProvider; + +template +void visit(const Box& box, F&& function) { + if (box.empty()) + return; + Index index = box.lo; + while (true) { + function(index); + int axis = 0; + for (; axis < Dim; ++axis) { + if (index[axis] < box.hi[axis]) { + ++index[axis]; + break; + } + index[axis] = box.lo[axis]; + } + if (axis == Dim) + return; + } +} + +template +class HostField { + public: + HostField(Box box, int components) + : box_(box), + components_(components), + values_(static_cast(box.numPts()) * static_cast(components)) {} + + FieldView view() { + FieldView result{}; + populate(result); + return result; + } + + FieldView const_view() const { + FieldView result{}; + populate(result); + return result; + } + + Real& operator()(const Index& index, int component = 0) { return view()(index, component); } + + Real operator()(const Index& index, int component = 0) const { + return const_view()(index, component); + } + + const Box& box() const { return box_; } + + private: + template + void populate(FieldView& result) const { + result.data = values_.data(); + result.origin = box_.lo; + result.extents = box_.extent(); + result.strides[0] = 1; + for (int axis = 1; axis < Dim; ++axis) + result.strides[axis] = result.strides[axis - 1] * result.extents[axis - 1]; + result.ncomp = components_; + result.component_stride = box_.numPts(); + } + + Box box_; + int components_; + mutable std::vector values_; +}; + +template +RefinementRatio sample_ratio() { + if constexpr (Dim == 1) + return RefinementRatio<1>{3}; + else if constexpr (Dim == 2) + return RefinementRatio<2>{2, 3}; + else + return RefinementRatio<3>{2, 1, 3}; +} + +template +IndexMapping sample_mapping() { + if constexpr (Dim == 1) + return {Index<1>{-3}, Index<1>{5}}; + else if constexpr (Dim == 2) + return {Index<2>{-3, 4}, Index<2>{5, -7}}; + else + return {Index<3>{-3, 4, -2}, Index<3>{5, -7, 11}}; +} + +template +Box sample_coarse_region(const IndexMapping& mapping) { + Index upper = mapping.coarse_origin; + for (int axis = 0; axis < Dim; ++axis) + ++upper[axis]; + return {mapping.coarse_origin, upper}; +} + +template +Box sample_coarse_source(const IndexMapping& mapping) { + Index lower = mapping.coarse_origin; + Index upper = mapping.coarse_origin; + for (int axis = 0; axis < Dim; ++axis) { + lower[axis] -= 3; + upper[axis] += 3; + } + return {lower, upper}; +} + +template +Box refine_for_test(const Box& coarse, const RefinementRatio& ratio, + const IndexMapping& mapping) { + Box fine{}; + for (int axis = 0; axis < Dim; ++axis) { + fine.lo[axis] = + mapping.fine_origin[axis] + (coarse.lo[axis] - mapping.coarse_origin[axis]) * ratio[axis]; + fine.hi[axis] = mapping.fine_origin[axis] + + (coarse.hi[axis] - mapping.coarse_origin[axis]) * ratio[axis] + ratio[axis] - 1; + } + return fine; +} + +template +Real affine_coarse(const Index& index, const IndexMapping& mapping, int component) { + Real value = Real(2.75) + Real(4.5) * component; + for (int axis = 0; axis < Dim; ++axis) + value += Real(axis + 1) * Real(index[axis] - mapping.coarse_origin[axis]); + return value; +} + +template +Real affine_fine(const Index& index, const RefinementRatio& ratio, + const IndexMapping& mapping, int component) { + Real value = Real(2.75) + Real(4.5) * component; + for (int axis = 0; axis < Dim; ++axis) { + const Real relative = static_cast(index[axis] - mapping.fine_origin[axis]); + value += Real(axis + 1) * ((relative + Real(0.5)) / static_cast(ratio[axis]) - Real(0.5)); + } + return value; +} + +template +void fill_affine(HostField& field, const IndexMapping& mapping) { + visit(field.box(), [&](const Index& index) { + for (int component = 0; component < 2; ++component) + field(index, component) = affine_coarse(index, mapping, component); + }); +} + +template +void execute(const PreparedTransfer& prepared) { + visit(prepared.destination_region(), [&](const Index& index) { prepared(index); }); +} + +template +void expect_constant_restriction() { + const auto ratio = sample_ratio(); + const auto mapping = sample_mapping(); + const Box coarse_region = sample_coarse_region(mapping); + const Box fine_region = refine_for_test(coarse_region, ratio, mapping); + HostField fine(fine_region, 2); + HostField coarse(coarse_region, 2); + visit(fine_region, [&](const Index& index) { + fine(index, 0) = Real(0.1); + fine(index, 1) = Real(-3.25); + }); + + const auto prepared = TransferProvider::conservative_restriction().prepare( + fine.const_view(), coarse.view(), coarse_region, ratio, mapping, ComponentRange{0, 0, 2}); + execute(prepared); + + visit(coarse_region, [&](const Index& index) { + EXPECT_DOUBLE_EQ(coarse(index, 0), Real(0.1)); + EXPECT_DOUBLE_EQ(coarse(index, 1), Real(-3.25)); + }); +} + +template +void expect_affine_prolongation_and_conservative_round_trip() { + const auto ratio = sample_ratio(); + const auto mapping = sample_mapping(); + const Box coarse_region = sample_coarse_region(mapping); + const Box fine_region = refine_for_test(coarse_region, ratio, mapping); + HostField coarse_source(sample_coarse_source(mapping), 2); + HostField fine(fine_region, 2); + HostField restricted(coarse_region, 2); + fill_affine(coarse_source, mapping); + + const auto prolongation = TransferProvider::linear_prolongation().prepare( + coarse_source.const_view(), fine.view(), fine_region, ratio, mapping, + ComponentRange{0, 0, 2}); + execute(prolongation); + visit(fine_region, [&](const Index& index) { + for (int component = 0; component < 2; ++component) + EXPECT_NEAR(fine(index, component), affine_fine(index, ratio, mapping, component), 1e-13); + }); + + const auto restriction = + TransferProvider::conservative_restriction().prepare( + fine.const_view(), restricted.view(), coarse_region, ratio, mapping, + ComponentRange{0, 0, 2}); + execute(restriction); + visit(coarse_region, [&](const Index& index) { + for (int component = 0; component < 2; ++component) + EXPECT_NEAR(restricted(index, component), affine_coarse(index, mapping, component), 1e-13); + }); +} + +template +void expect_negative_offset_ghost_interpolation() { + const auto ratio = sample_ratio(); + const auto mapping = sample_mapping(); + Index lower = mapping.fine_origin; + Index upper = mapping.fine_origin; + for (int axis = 0; axis < Dim; ++axis) { + lower[axis] -= ratio[axis]; + upper[axis] = mapping.fine_origin[axis] - 1; + } + const Box ghost_region{lower, upper}; + HostField coarse(sample_coarse_source(mapping), 2); + HostField fine_ghosts(ghost_region, 2); + fill_affine(coarse, mapping); + + const auto interpolation = + TransferProvider::coarse_fine_ghost_interpolation().prepare( + coarse.const_view(), fine_ghosts.view(), ghost_region, ratio, mapping, + ComponentRange{0, 0, 2}); + execute(interpolation); + visit(ghost_region, [&](const Index& index) { + for (int component = 0; component < 2; ++component) + EXPECT_NEAR(fine_ghosts(index, component), affine_fine(index, ratio, mapping, component), + 1e-13); + }); +} + +} // namespace + +TEST(test_nd_transfer, anisotropic_ratios_validate_once_and_fail_closed) { + const RefinementRatio<3> identity{}; + EXPECT_TRUE(identity.is_identity()); + EXPECT_FALSE(identity.refines_any_axis()); + EXPECT_EQ(identity.child_count(), 1); + EXPECT_EQ((RefinementRatio<3>{1, 1, 1}), identity); + EXPECT_EQ((RefinementRatio<1>{3}.child_count()), 3); + EXPECT_EQ((RefinementRatio<2>{2, 3}.child_count()), 6); + EXPECT_EQ((RefinementRatio<3>{2, 1, 3}.child_count()), 6); + EXPECT_TRUE((RefinementRatio<3>{2, 1, 3}.refines_any_axis())); + EXPECT_THROW((void)(RefinementRatio<1>{0}), std::invalid_argument); + EXPECT_THROW((void)(RefinementRatio<2>{2, -1}), std::invalid_argument); + EXPECT_THROW( + (void)(RefinementRatio<3>{std::numeric_limits::max(), std::numeric_limits::max(), + std::numeric_limits::max()}), + std::overflow_error); +} + +TEST(test_nd_transfer, prepared_contract_is_fixed_size_and_reports_exact_capabilities) { + static_assert(std::is_same_v, + pops::amr::transfer::nd::RefinementRatio<3>>); + static_assert(std::is_trivially_copyable_v>); + static_assert(std::is_trivially_copyable_v>); + static_assert(std::is_trivially_copyable_v>); + static_assert(std::is_trivially_copyable_v>); + + EXPECT_EQ((TransferProvider<2, Centering::Cell>::conservative_restriction().capabilities()), + (pops::amr::transfer::nd::TransferCapabilities{1, 0, true, true})); + EXPECT_EQ((TransferProvider<2, Centering::Cell>::linear_prolongation().capabilities()), + (pops::amr::transfer::nd::TransferCapabilities{2, 1, false, true})); + EXPECT_THROW((void)(TransferProvider<2, Centering::Node>::linear_prolongation().capabilities()), + std::invalid_argument); + EXPECT_THROW( + (void)(TransferProvider<2, Centering::Cell>{static_cast(255)}.capabilities()), + std::invalid_argument); +} + +TEST(test_nd_transfer, conservative_restriction_preserves_constants_bit_exact_in_1d_2d_3d) { + expect_constant_restriction<1>(); + expect_constant_restriction<2>(); + expect_constant_restriction<3>(); +} + +TEST(test_nd_transfer, linear_prolongation_and_restriction_reproduce_affine_fields_in_1d_2d_3d) { + expect_affine_prolongation_and_conservative_round_trip<1>(); + expect_affine_prolongation_and_conservative_round_trip<2>(); + expect_affine_prolongation_and_conservative_round_trip<3>(); +} + +TEST(test_nd_transfer, coarse_fine_ghost_interpolation_handles_negative_offsets_in_1d_2d_3d) { + expect_negative_offset_ghost_interpolation<1>(); + expect_negative_offset_ghost_interpolation<2>(); + expect_negative_offset_ghost_interpolation<3>(); +} + +TEST(test_nd_transfer, preparation_rejects_missing_stencils_components_aliases_and_regions) { + const RefinementRatio<2> ratio{2, 3}; + const IndexMapping<2> mapping{}; + const Box<2> fine_region{Index<2>{0, 0}, Index<2>{3, 5}}; + const Box<2> coarse_without_halo{Index<2>{0, 0}, Index<2>{1, 1}}; + HostField<2> coarse(coarse_without_halo, 1); + HostField<2> fine(fine_region, 1); + const auto linear = TransferProvider<2, Centering::Cell>::linear_prolongation(); + + EXPECT_THROW((void)linear.prepare(coarse.const_view(), fine.view(), fine_region, ratio, mapping), + std::invalid_argument); + + const Box<2> source_with_halo{Index<2>{-1, -1}, Index<2>{2, 2}}; + HostField<2> valid_source(source_with_halo, 1); + EXPECT_THROW((void)linear.prepare(valid_source.const_view(), fine.view(), fine_region, + RefinementRatio<2>{1, 1}, mapping), + std::invalid_argument); + EXPECT_THROW((void)linear.prepare(valid_source.const_view(), fine.view(), fine_region, ratio, + mapping, ComponentRange{0, 0, 2}), + std::invalid_argument); + EXPECT_THROW((void)linear.prepare(valid_source.const_view(), fine.view(), + Box<2>{Index<2>{0, 0}, Index<2>{4, 5}}, ratio, mapping), + std::invalid_argument); + + HostField<2> overlapping(Box<2>{Index<2>{-1, -1}, Index<2>{5, 5}}, 1); + EXPECT_THROW((void)linear.prepare(overlapping.const_view(), overlapping.view(), fine_region, + ratio, mapping), + std::invalid_argument); + + const auto unsupported = TransferProvider<2, Centering::Face0>::linear_prolongation(); + EXPECT_THROW((void)unsupported.prepare(valid_source.const_view(), fine.view(), fine_region, ratio, + mapping), + std::invalid_argument); + const TransferProvider<2, Centering::Cell> unknown{static_cast(255)}; + EXPECT_THROW( + (void)unknown.prepare(valid_source.const_view(), fine.view(), fine_region, ratio, mapping), + std::invalid_argument); +} diff --git a/tests/cpp/unit/codegen/test_block_builder.cpp b/tests/cpp/unit/codegen/test_block_builder.cpp index ec684a924..e9474e78d 100644 --- a/tests/cpp/unit/codegen/test_block_builder.cpp +++ b/tests/cpp/unit/codegen/test_block_builder.cpp @@ -23,8 +23,12 @@ #include #include +#include #include +#include +#include #include +#include using namespace pops; @@ -148,3 +152,107 @@ TEST(test_block_builder, isothermal_model_without_hllc_capability_is_rejected) { EXPECT_TRUE(refused_with("hllc", "capability")) << "isotherme + hllc refuse (nomme la capability)"; } + +TEST(test_block_builder, prepared_no_flux_zeroes_only_its_evaluated_face_flux) { + const Box2D dom = Box2D::from_extents(4, 3); + const Geometry geom{dom, 0.0, 1.0, 0.0, 1.0}; + const BoxArray cells = BoxArray::from_domain(dom, 4); + const DistributionMapping dm(cells.size(), n_ranks()); + BCRec bc; + MultiFab aux(cells, dm, 3, 1); + aux.set_val(0.0); + + GridContext ctx{dom, bc, geom, &aux}; + ctx.boundary_plan = std::make_shared( + "case::closed::plan", 1, + prepare_hyperbolic_boundary<2>( + {"no_flux", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::closed::xlo", "case::closed::xhi", "case::closed::ylo", "case::closed::yhi"}, + std::vector{"Scalar"})); + + MultiFab fx(BoxArray(std::vector{xface_box(dom)}), dm, 1, 0); + MultiFab fy(BoxArray(std::vector{yface_box(dom)}), dm, 1, 0); + fx.set_val(3.0); + fy.set_val(5.0); + detail::zero_prepared_boundary_fluxes(fx, fy, ctx); + fx.sync_host(); + fy.sync_host(); + + for (int local = 0; local < fx.local_size(); ++local) { + const Fab2D& values = fx.fab(local); + const Box2D box = fx.box(local); + for (int j = box.lo[1]; j <= box.hi[1]; ++j) + for (int i = box.lo[0]; i <= box.hi[0]; ++i) + EXPECT_DOUBLE_EQ(values(i, j, 0), i == dom.lo[0] ? 0.0 : 3.0); + } + for (int local = 0; local < fy.local_size(); ++local) { + const Fab2D& values = fy.fab(local); + const Box2D box = fy.box(local); + for (int j = box.lo[1]; j <= box.hi[1]; ++j) + for (int i = box.lo[0]; i <= box.hi[0]; ++i) + EXPECT_DOUBLE_EQ(values(i, j, 0), 5.0); + } +} + +TEST(test_block_builder, cell_primitive_conversion_consumes_prepared_recovery_outcome) { + const Model model{Euler{1.4}, GravityForce{}, GravityCoupling{-1.0, 1.0, 1.0}}; + const auto conversion = make_cell_convert(model); + + // rho=1, (u,v)=(0.2,-0.1), p=1 -> E=p/(gamma-1)+rho*(u^2+v^2)/2=2.525. + const std::array conservative{1.0, 0.2, -0.1, 2.525}; + const std::array authored_primitive{1.0, 0.2, -0.1, 1.0}; + std::array forward_candidate{-9.0, -9.0, -9.0, -9.0}; + EXPECT_NO_THROW(conversion.first(authored_primitive.data(), forward_candidate.data())); + for (std::size_t component = 0; component < conservative.size(); ++component) + EXPECT_NEAR(forward_candidate[component], conservative[component], 1e-14); + + // Forward conversion is also a candidate transaction: the conservative result must survive the + // same prepared inverse authority before any output component is published. + const std::array invalid_primitive{0.0, 0.0, 0.0, 1.0}; + const std::array forward_sentinel{1.25, -2.5, 3.75, -5.0}; + forward_candidate = forward_sentinel; + EXPECT_THROW(conversion.first(invalid_primitive.data(), forward_candidate.data()), + std::runtime_error); + EXPECT_EQ(forward_candidate, forward_sentinel); + + std::array primitive{-9.0, -9.0, -9.0, -9.0}; + const RecoveryReport success = conversion.second(conservative.data(), primitive.data()); + EXPECT_TRUE(success.recovered()); + EXPECT_EQ(success.status, RecoveryStatus::kRecovered); + EXPECT_EQ(success.cause, RecoveryCause::kNone); + EXPECT_EQ(success.attempted_methods, 1); + EXPECT_EQ(success.selected_method, 0); + EXPECT_EQ(success.selected_method_kind, RecoveryMethodKind::kClosedForm); + EXPECT_EQ(success.last_method_kind, RecoveryMethodKind::kClosedForm); + EXPECT_DOUBLE_EQ(primitive[0], 1.0); + EXPECT_DOUBLE_EQ(primitive[1], 0.2); + EXPECT_DOUBLE_EQ(primitive[2], -0.1); + EXPECT_NEAR(primitive[3], 1.0, 1e-14); + + // The Euler closed form produces non-finite velocity/pressure for rho=0. The common prepared + // authority rejects that candidate and the type-erased closure must leave output byte-exact. + const std::array invalid_conservative{0.0, 0.0, 0.0, 0.0}; + const std::array sentinel{1.25, -2.5, 3.75, -5.0}; + primitive = sentinel; + const RecoveryReport failure = conversion.second(invalid_conservative.data(), primitive.data()); + EXPECT_FALSE(failure.publication_permitted()); + EXPECT_EQ(failure.status, RecoveryStatus::kInvalidContract); + EXPECT_EQ(failure.cause, RecoveryCause::kNonFiniteCandidate); + EXPECT_EQ(failure.attempted_methods, 1); + EXPECT_EQ(failure.selected_method_kind, RecoveryMethodKind::kUnknown); + EXPECT_EQ(failure.last_method_kind, RecoveryMethodKind::kClosedForm); + EXPECT_GE(failure.failing_component, 1); + EXPECT_EQ(primitive, sentinel); +} + +TEST(test_block_builder, primitive_to_conservative_publication_roundtrips_before_commit) { + const Model model{Euler{1.4}, GravityForce{}, GravityCoupling{-1.0, 1.0, 1.0}}; + const auto conversion = make_cell_convert(model); + + const std::array authored_primitive{1.0, 0.2, -0.1, 1.0}; + const std::array expected_conservative{1.0, 0.2, -0.1, 2.525}; + std::array published{-9.0, -9.0, -9.0, -9.0}; + EXPECT_NO_THROW(conversion.first(authored_primitive.data(), published.data())); + for (std::size_t component = 0; component < published.size(); ++component) + EXPECT_NEAR(published[component], expected_conservative[component], 1e-14); +} diff --git a/tests/cpp/unit/elliptic/test_composite_fac_poisson.cpp b/tests/cpp/unit/elliptic/test_composite_fac_poisson.cpp index 748f87894..8637f941b 100644 --- a/tests/cpp/unit/elliptic/test_composite_fac_poisson.cpp +++ b/tests/cpp/unit/elliptic/test_composite_fac_poisson.cpp @@ -30,7 +30,9 @@ #include #include #include +#include #include +#include using namespace pops; @@ -72,6 +74,25 @@ static void apply_noop_fully_refined_boundary_jvp(int, const MultiFab&, const Mu ++fully_refined_jvp_visits[context.point.level]; } +static void boundary_prepare_noop(int, const MultiFab&, MultiFab&, const Geometry&, + const FieldBoundaryExecutionContext&) {} + +static void boundary_residual_noop(int, const MultiFab&, MultiFab&, const Geometry&, + const FieldBoundaryExecutionContext&) {} + +static const MultiFab* expected_boundary_state = nullptr; +static bool observed_expected_boundary_state = false; +static bool observed_unexpected_boundary_state = false; + +static void boundary_residual_observe_state(int, const MultiFab&, MultiFab&, const Geometry&, + const FieldBoundaryExecutionContext& context) { + if (context.state_count == 1 && context.states != nullptr && + context.states[0] == expected_boundary_state) + observed_expected_boundary_state = true; + else + observed_unexpected_boundary_state = true; +} + TEST(CompositeFacPoissonTest, fine_patch_improves_accuracy_over_coarse_only) { comm_init(); const int me = my_rank(); @@ -232,8 +253,8 @@ TEST(CompositeFacPoissonTest, fully_refined_boundary_uses_finest_level_context) coarse_context.point.level = -1; FieldBoundaryExecutionContext fine_context; fine_context.point.level = -1; - fac.set_boundary_context_for_level(0, coarse_context); - fac.set_boundary_context_for_level(1, fine_context); + fac.set_boundary_context_at_level(0, coarse_context); + fac.set_boundary_context_at_level(1, fine_context); fac.rhs_level(0).set_val(Real(0)); fac.rhs_level(1).set_val(Real(0)); @@ -299,8 +320,8 @@ TEST(CompositeFacPoissonTest, fully_refined_nonlinear_boundary_uses_finest_jvp_c coarse_context.point.level = -1; FieldBoundaryExecutionContext fine_context; fine_context.point.level = -1; - fac.set_boundary_context_for_level(0, coarse_context); - fac.set_boundary_context_for_level(1, fine_context); + fac.set_boundary_context_at_level(0, coarse_context); + fac.set_boundary_context_at_level(1, fine_context); const FieldNewtonOptions options; fac.set_field_nonlinear_options(options); fac.rhs_level(0).set_val(Real(-1)); @@ -486,3 +507,206 @@ TEST(CompositeFacPoissonTest, nonfinite_composite_residual_fails_closed) { comm_finalize(); } + +TEST(CompositeFacPoissonTest, level_qualified_boundary_carrier_requires_every_level) { + comm_init(); + const int n = 16, r = 2; + const Box2D domain = Box2D::from_extents(n, n); + const Geometry geometry{domain, 0.0, 1.0, 0.0, 1.0}; + const BoxArray coarse = BoxArray::from_domain(domain, n); + BCRec boundary; + boundary.xlo = boundary.xhi = boundary.ylo = boundary.yhi = BCType::Dirichlet; + const Box2D fine_box{{n / 2, n / 2}, {n - 1, n - 1}}; + CompositeFacPoisson fac(geometry, coarse, boundary, fine_box, r); + fac.set_boundary_kernel(CompiledFieldBoundaryKernel{ + "tests.composite-fac.level-carrier@1", + "tests.composite-fac.level-carrier.residual@1", + "", + boundary_prepare_noop, + nullptr, + boundary_residual_noop, + nullptr, + false, + }); + + const MultiFab* states[] = {&fac.rhs_level(0)}; + const FieldDistribution distributions[] = {FieldDistribution::Replicated}; + const std::string identities[] = {"tests.composite-fac.state"}; + FieldBoundaryExecutionContext coarse_context; + coarse_context.states = states; + coarse_context.state_distributions = distributions; + coarse_context.state_identities = identities; + coarse_context.state_count = 1; + fac.set_boundary_context_at_level(0, coarse_context); + + try { + (void)fac.solve(/*max_iters=*/0, /*fine_sweeps=*/0, + /*rel_tol=*/Real(0), /*abs_tol=*/Real(0)); + FAIL() << "a composite dynamic boundary accepted a missing fine-level carrier"; + } catch (const std::runtime_error& error) { + EXPECT_NE( + std::string(error.what()).find("missing a level-qualified boundary carrier for level 1"), + std::string::npos) + << error.what(); + } + comm_finalize(); +} + +TEST(CompositeFacPoissonTest, late_invalid_carrier_does_not_replace_committed_batch) { + comm_init(); + const int n = 16, r = 2; + const Box2D domain = Box2D::from_extents(n, n); + const Geometry geometry{domain, 0.0, 1.0, 0.0, 1.0}; + const BoxArray coarse = BoxArray::from_domain(domain, n); + BCRec boundary; + boundary.xlo = boundary.xhi = boundary.ylo = boundary.yhi = BCType::Dirichlet; + const Box2D full_fine_domain = geometry.refine(r).domain; + CompositeFacPoisson fac(geometry, coarse, boundary, full_fine_domain, r); + fac.set_boundary_kernel(CompiledFieldBoundaryKernel{ + "tests.composite-fac.transactional-level-carrier@1", + "tests.composite-fac.transactional-level-carrier.residual@1", + "", + boundary_prepare_noop, + nullptr, + boundary_residual_observe_state, + nullptr, + false, + }); + + const MultiFab* accepted_coarse_states[] = {&fac.rhs_level(0)}; + const MultiFab* accepted_fine_states[] = {&fac.rhs_level(1)}; + const FieldDistribution coarse_distribution[] = {FieldDistribution::Replicated}; + const FieldDistribution fine_distribution[] = {FieldDistribution::Distributed}; + const std::string state_identities[] = {"tests.composite-fac.transactional-state"}; + FieldBoundaryExecutionContext accepted_coarse; + accepted_coarse.states = accepted_coarse_states; + accepted_coarse.state_distributions = coarse_distribution; + accepted_coarse.state_identities = state_identities; + accepted_coarse.state_count = 1; + FieldBoundaryExecutionContext accepted_fine; + accepted_fine.states = accepted_fine_states; + accepted_fine.state_distributions = fine_distribution; + accepted_fine.state_identities = state_identities; + accepted_fine.state_count = 1; + fac.set_boundary_context_at_level(0, accepted_coarse); + fac.set_boundary_context_at_level(1, accepted_fine); + + MultiFab replacement_fine(fac.rhs_level(1).box_array(), fac.rhs_level(1).dmap(), 1, 0); + const MultiFab* replacement_fine_states[] = {&replacement_fine}; + FieldBoundaryExecutionContext candidate_fine = accepted_fine; + candidate_fine.states = replacement_fine_states; + fac.set_boundary_context_at_level(1, candidate_fine); + + const std::vector one_parameter{Real(1)}; + FieldBoundaryExecutionContext invalid_coarse = accepted_coarse; + invalid_coarse.parameters = &one_parameter; + invalid_coarse.parameter_count = 2; + EXPECT_THROW(fac.set_boundary_context_at_level(0, invalid_coarse), std::invalid_argument); + + expected_boundary_state = &fac.rhs_level(1); + observed_expected_boundary_state = false; + observed_unexpected_boundary_state = false; + EXPECT_NO_THROW((void)fac.solve(/*max_iters=*/1, /*fine_sweeps=*/0, + /*rel_tol=*/Real(1e-10), /*abs_tol=*/Real(0))); + EXPECT_TRUE(fac.last_solve_report().solved()) << fac.last_solve_report().reason; + EXPECT_TRUE(observed_expected_boundary_state); + EXPECT_FALSE(observed_unexpected_boundary_state) + << "a late carrier failure changed the previously committed fine boundary context"; + + expected_boundary_state = nullptr; + comm_finalize(); +} + +TEST(CompositeFacPoissonTest, fully_refined_hierarchy_consumes_finest_level_carrier) { + comm_init(); + const int n = 16, r = 2; + const Box2D domain = Box2D::from_extents(n, n); + const Geometry geometry{domain, 0.0, 1.0, 0.0, 1.0}; + const BoxArray coarse = BoxArray::from_domain(domain, n); + BCRec boundary; + boundary.xlo = boundary.xhi = boundary.ylo = boundary.yhi = BCType::Dirichlet; + const Box2D full_fine_domain = geometry.refine(r).domain; + CompositeFacPoisson fac(geometry, coarse, boundary, full_fine_domain, r); + fac.set_boundary_kernel(CompiledFieldBoundaryKernel{ + "tests.composite-fac.finest-level-carrier@1", + "tests.composite-fac.finest-level-carrier.residual@1", + "", + boundary_prepare_noop, + nullptr, + boundary_residual_observe_state, + nullptr, + false, + }); + + const MultiFab* coarse_states[] = {&fac.rhs_level(0)}; + const MultiFab* fine_states[] = {&fac.rhs_level(1)}; + const FieldDistribution coarse_distribution[] = {FieldDistribution::Replicated}; + const FieldDistribution fine_distribution[] = {FieldDistribution::Distributed}; + const std::string state_identities[] = {"tests.composite-fac.finest-state"}; + FieldBoundaryExecutionContext coarse_context; + coarse_context.states = coarse_states; + coarse_context.state_distributions = coarse_distribution; + coarse_context.state_identities = state_identities; + coarse_context.state_count = 1; + FieldBoundaryExecutionContext fine_context; + fine_context.states = fine_states; + fine_context.state_distributions = fine_distribution; + fine_context.state_identities = state_identities; + fine_context.state_count = 1; + fac.set_boundary_context_at_level(0, coarse_context); + fac.set_boundary_context_at_level(1, fine_context); + + expected_boundary_state = &fac.rhs_level(1); + observed_expected_boundary_state = false; + observed_unexpected_boundary_state = false; + EXPECT_NO_THROW((void)fac.solve(/*max_iters=*/1, /*fine_sweeps=*/0, + /*rel_tol=*/Real(1e-8), /*abs_tol=*/Real(0))); + EXPECT_TRUE(observed_expected_boundary_state); + EXPECT_FALSE(observed_unexpected_boundary_state); + expected_boundary_state = nullptr; + comm_finalize(); +} + +TEST(CompositeFacPoissonTest, partial_dynamic_boundary_touching_physical_face_fails_closed) { + comm_init(); + const int n = 16, r = 2; + const Box2D domain = Box2D::from_extents(n, n); + const Geometry geometry{domain, 0.0, 1.0, 0.0, 1.0}; + const BoxArray coarse = BoxArray::from_domain(domain, n); + BCRec boundary; + boundary.xlo = boundary.xhi = boundary.ylo = boundary.yhi = BCType::Dirichlet; + const Box2D fine_box{{0, n / 2}, {n - 1, n - 1}}; + CompositeFacPoisson fac(geometry, coarse, boundary, fine_box, r); + fac.set_boundary_kernel(CompiledFieldBoundaryKernel{ + "tests.composite-fac.partial-physical-boundary@1", + "tests.composite-fac.partial-physical-boundary.residual@1", + "", + boundary_prepare_noop, + nullptr, + boundary_residual_noop, + nullptr, + false, + }); + + const MultiFab* coarse_states[] = {&fac.rhs_level(0)}; + const MultiFab* fine_states[] = {&fac.rhs_level(1)}; + const FieldDistribution coarse_distribution[] = {FieldDistribution::Replicated}; + const FieldDistribution fine_distribution[] = {FieldDistribution::Distributed}; + const std::string state_identities[] = {"tests.composite-fac.partial-boundary-state"}; + FieldBoundaryExecutionContext coarse_context; + coarse_context.states = coarse_states; + coarse_context.state_distributions = coarse_distribution; + coarse_context.state_identities = state_identities; + coarse_context.state_count = 1; + FieldBoundaryExecutionContext fine_context; + fine_context.states = fine_states; + fine_context.state_distributions = fine_distribution; + fine_context.state_identities = state_identities; + fine_context.state_count = 1; + fac.set_boundary_context_at_level(0, coarse_context); + EXPECT_THROW(fac.set_boundary_context_at_level(1, fine_context), std::invalid_argument); + EXPECT_THROW((void)fac.solve(/*max_iters=*/0, /*fine_sweeps=*/0, + /*rel_tol=*/Real(0), /*abs_tol=*/Real(0)), + std::runtime_error); + comm_finalize(); +} diff --git a/tests/cpp/unit/elliptic/test_elliptic_composite_rhs.cpp b/tests/cpp/unit/elliptic/test_elliptic_composite_rhs.cpp index ee3cec6db..9524b2bc3 100644 --- a/tests/cpp/unit/elliptic/test_elliptic_composite_rhs.cpp +++ b/tests/cpp/unit/elliptic/test_elliptic_composite_rhs.cpp @@ -37,8 +37,8 @@ struct ScalarElliptic { using Aux = pops::Aux; static constexpr int n_vars = 1; Elliptic ell{}; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State& u) const { return ell.rhs(u); } }; diff --git a/tests/cpp/unit/elliptic/test_newton_robustness.cpp b/tests/cpp/unit/elliptic/test_newton_robustness.cpp index 4376a8753..0b08833be 100644 --- a/tests/cpp/unit/elliptic/test_newton_robustness.cpp +++ b/tests/cpp/unit/elliptic/test_newton_robustness.cpp @@ -33,8 +33,8 @@ struct StiffModel { using Aux = pops::Aux; static constexpr int n_vars = 3; Real k = 200.0; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return 0; } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return 0; } POPS_HD State source(const State& u, const Aux&) const { State s{}; s[0] = -k * (u[0] - u[1] * u[2]); @@ -67,8 +67,8 @@ struct NanModel { using State = pops::StateVec<3>; using Aux = pops::Aux; static constexpr int n_vars = 3; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return 0; } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return 0; } POPS_HD State source(const State& u, const Aux&) const { State s{}; s[0] = -u[0]; @@ -85,8 +85,8 @@ struct SingularModel { using State = pops::StateVec<3>; using Aux = pops::Aux; static constexpr int n_vars = 3; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return 0; } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return 0; } POPS_HD State source(const State& u, const Aux&) const { State s{}; s[0] = Real(8) * u[0] + Real(1); @@ -132,8 +132,8 @@ struct FallibleSourceModel { pops::ImplicitEvaluationStatus evaluation = pops::ImplicitEvaluationStatus::kOk; std::uint32_t reason = 0; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return 0; } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return 0; } POPS_HD State source(const State&, const Aux&) const { return State{Real(1e6), Real(1e6), Real(1e6)}; } @@ -972,42 +972,54 @@ TEST(PreparedLocalNonlinear, EveryFailureClassIsExplicitAndLeavesTheGuessUntouch EXPECT_TRUE(maximum_attempts_result.solved()); EXPECT_NEAR(maximum_attempts_result.value[0], Real(1), 1e-12); - int decoded_i = -1; - int decoded_j = -1; - int decoded_component = -1; - pops::detail::decode_local_nonlinear_failure( - pops::detail::encode_local_nonlinear_failure(17, 23, 4), decoded_i, decoded_j, - decoded_component); - EXPECT_EQ(decoded_i, 17); - EXPECT_EQ(decoded_j, 23); - EXPECT_EQ(decoded_component, 4); - - const pops::Real recoverable = pops::detail::encode_ranked_local_nonlinear_failure( - pops::local_nonlinear_status_priority(pops::LocalNonlinearStatus::kEvaluationReject), 1, 1, - 2); - const pops::Real fatal = pops::detail::encode_ranked_local_nonlinear_failure( - pops::local_nonlinear_status_priority(pops::LocalNonlinearStatus::kInvalidEvaluation), 7, 9, - 3); - int decoded_priority = 0; - pops::detail::decode_ranked_local_nonlinear_failure( - std::max(recoverable, fatal), decoded_priority, decoded_i, decoded_j, decoded_component); - EXPECT_EQ(decoded_priority, - pops::local_nonlinear_status_priority(pops::LocalNonlinearStatus::kInvalidEvaluation)); - EXPECT_EQ(decoded_i, 7); - EXPECT_EQ(decoded_j, 9); - EXPECT_EQ(decoded_component, 3); - - const pops::Real first_fatal = - pops::detail::encode_ranked_local_nonlinear_failure(decoded_priority, 0, 0, -1); - const pops::Real last_fatal = pops::detail::encode_ranked_local_nonlinear_failure( - decoded_priority, (1 << 20) - 1, (1 << 20) - 1, 1022); - pops::detail::decode_ranked_local_nonlinear_failure( - std::max(first_fatal, last_fatal), decoded_priority, decoded_i, decoded_j, decoded_component); - EXPECT_EQ(decoded_i, 0); - EXPECT_EQ(decoded_j, 0); - EXPECT_EQ(decoded_component, -1); - EXPECT_EQ(initial[0], Real(10)); EXPECT_EQ(inadmissible_initial[0], Real(-1)); EXPECT_EQ(safeguard_initial[0], Real(0)); } + +TEST(LocalNonlinearCollective, SignedLargeIndicesPreservePriorityAndLexicographicOrder) { + const pops::BoxArray boxes( + std::vector{pops::Box2D{{-1000000000, -700000000}, {-1000000000, -700000000}}, + pops::Box2D{{1000000000, 700000000}, {1000000000, 700000000}}}); + const pops::DistributionMapping mapping(boxes.size(), pops::n_ranks()); + pops::MultiFab statistics(boxes, mapping, 11, 0); + statistics.set_val(Real(0)); + const int recoverable = + pops::local_nonlinear_status_priority(pops::LocalNonlinearStatus::kEvaluationReject); + const int fatal = + pops::local_nonlinear_status_priority(pops::LocalNonlinearStatus::kInvalidEvaluation); + + for (int local = 0; local < statistics.local_size(); ++local) { + const pops::Box2D box = statistics.box(local); + const pops::Array4 values = statistics.fab(local).array(); + pops::for_each_cell(box, [=] POPS_HD(int i, int j) { + const bool negative = i < 0; + values(i, j, 8) = negative ? Real(7) : Real(3); + values(i, j, 9) = Real(1); + values(i, j, 10) = static_cast(negative ? recoverable : fatal); + }); + } + + auto location = pops::collective_first_local_nonlinear_failure(statistics, fatal, 10, 8); + ASSERT_TRUE(location.found); + EXPECT_EQ(location.priority, fatal); + EXPECT_EQ(location.i, 1000000000); + EXPECT_EQ(location.j, 700000000); + EXPECT_EQ(location.component, 3); + + for (int local = 0; local < statistics.local_size(); ++local) { + const pops::Box2D box = statistics.box(local); + const pops::Array4 values = statistics.fab(local).array(); + pops::for_each_cell(box, [=] POPS_HD(int i, int j) { + if (i < 0) + values(i, j, 10) = static_cast(fatal); + }); + } + location = pops::collective_first_local_nonlinear_failure(statistics, fatal, 10, 8); + ASSERT_TRUE(location.found); + EXPECT_EQ(location.i, -1000000000); + EXPECT_EQ(location.j, -700000000); + EXPECT_EQ(location.component, 7); + EXPECT_THROW((void)pops::collective_first_local_nonlinear_failure(statistics, fatal + 1, 10, 8), + std::runtime_error); +} diff --git a/tests/cpp/unit/mesh/test_box2d.cpp b/tests/cpp/unit/mesh/test_box2d.cpp index 49a280130..0d70bdc1a 100644 --- a/tests/cpp/unit/mesh/test_box2d.cpp +++ b/tests/cpp/unit/mesh/test_box2d.cpp @@ -5,6 +5,8 @@ #include #include +#include +#include #include #include @@ -13,6 +15,33 @@ using namespace pops; static_assert(std::is_aggregate_v); static_assert(std::is_trivially_copyable_v); +static_assert(Index<1>::rank == 1 && Index<2>::rank == 2 && Index<3>::rank == 3); +static_assert(Extent<1>::rank == 1 && Extent<2>::rank == 2 && Extent<3>::rank == 3); +static_assert(RealVector<1>::rank == 1 && RealVector<2>::rank == 2 && RealVector<3>::rank == 3); +static_assert(std::is_trivially_copyable_v> && std::is_trivially_copyable_v> && + std::is_trivially_copyable_v>); +static_assert(std::is_standard_layout_v> && std::is_standard_layout_v> && + std::is_standard_layout_v>); +static_assert(std::is_trivially_copyable_v> && std::is_trivially_copyable_v> && + std::is_trivially_copyable_v>); +static_assert(std::is_standard_layout_v> && std::is_standard_layout_v> && + std::is_standard_layout_v>); +static_assert(std::is_trivially_copyable_v> && + std::is_trivially_copyable_v> && + std::is_trivially_copyable_v>); +static_assert(std::is_standard_layout_v> && + std::is_standard_layout_v> && std::is_standard_layout_v>); +static_assert(std::is_constructible_v, int, int>); +static_assert(!std::is_constructible_v, long long>); +static_assert(std::is_constructible_v, int, unsigned int>); +static_assert(!std::is_constructible_v, unsigned long long>); +static_assert(std::is_constructible_v, float, int>); +static_assert(!std::is_constructible_v, long long>); +static_assert( + std::is_constructible_v, long double> == + (std::numeric_limits::digits <= std::numeric_limits::digits && + std::numeric_limits::max_exponent <= std::numeric_limits::max_exponent && + std::numeric_limits::min_exponent >= std::numeric_limits::min_exponent)); TEST(test_box2d, extents_and_contains) { Box2D b = Box2D::from_extents(4, 3); // [0..3] x [0..2] @@ -93,3 +122,59 @@ TEST(test_box2d, floor_div_rejects_undefined_integer_cases) { EXPECT_THROW((void)floor_div(lo, -1), std::overflow_error); EXPECT_EQ(floor_div(lo, 2), lo / 2); } + +TEST(test_box2d, compile_time_ranked_boxes_cover_1d_2d_and_3d) { + const Box<1> line = Box<1>::from_extents(Extent<1>{7}); + EXPECT_FALSE(line.empty()); + EXPECT_EQ(line.extent()[0], 7); + EXPECT_EQ(line.numPts(), 7); + EXPECT_TRUE(line.contains(Index<1>{6})); + + const Box<2> plane{Index<2>{-3, 4}, Index<2>{2, 6}}; + EXPECT_EQ(plane.extent()[0], 6); + EXPECT_EQ(plane.extent()[1], 3); + EXPECT_EQ(plane.numPts(), 18); + EXPECT_TRUE(plane.contains(Index<2>{-3, 4})); + EXPECT_FALSE(plane.contains(Index<2>{3, 4})); + EXPECT_EQ(plane.grow(1).lo[0], -4); + EXPECT_EQ(plane.refine(2).coarsen(2), plane); + + const Box<3> volume{Index<3>{-2, 5, 9}, Index<3>{1, 6, 11}}; + EXPECT_EQ(volume.extent()[0], 4); + EXPECT_EQ(volume.extent()[1], 2); + EXPECT_EQ(volume.extent()[2], 3); + EXPECT_EQ(volume.numPts(), 24); + EXPECT_EQ(volume.intersect(Box<3>{Index<3>{0, 4, 10}, Index<3>{4, 8, 10}}).numPts(), 4); + + const RealVector<3> point{1.25, -2.5, 0.75}; + EXPECT_DOUBLE_EQ(point[0], 1.25); + EXPECT_DOUBLE_EQ(point[1], -2.5); + EXPECT_DOUBLE_EQ(point[2], 0.75); +} + +TEST(test_box2d, compile_time_ranked_box_empty_and_overflow_contracts) { + const Box<3> empty{}; + EXPECT_TRUE(empty.empty()); + EXPECT_EQ(empty.numPts(), 0); + EXPECT_FALSE(empty.contains(Index<3>{0, 0, 0})); + + const Box<2> full_width{Index<2>{std::numeric_limits::min(), 0}, + Index<2>{std::numeric_limits::max(), 0}}; + EXPECT_EQ(full_width.extent()[0], std::int64_t{1} << 32); + EXPECT_EQ(full_width.numPts(), std::int64_t{1} << 32); + EXPECT_THROW((void)Box<1>::from_extents(Extent<1>{-1}), std::invalid_argument); + EXPECT_THROW((void)full_width.grow(1), std::overflow_error); +} + +TEST(test_box2d, ranked_box_shift_is_checked_and_preserves_empty_boxes) { + const Box<2> box{Index<2>{-3, 4}, Index<2>{1, 7}}; + EXPECT_EQ(box.shift(Index<2>{5, -2}), (Box<2>{Index<2>{2, 2}, Index<2>{6, 5}})); + EXPECT_EQ(Box<3>{}.shift(Index<3>{1, 2, 3}), Box<3>{}); + + const Box<1> at_max{Index<1>{std::numeric_limits::max()}, + Index<1>{std::numeric_limits::max()}}; + const Box<1> at_min{Index<1>{std::numeric_limits::min()}, + Index<1>{std::numeric_limits::min()}}; + EXPECT_THROW((void)at_max.shift(Index<1>{1}), std::overflow_error); + EXPECT_THROW((void)at_min.shift(Index<1>{-1}), std::overflow_error); +} diff --git a/tests/cpp/unit/mesh/test_dispatch_tags.cpp b/tests/cpp/unit/mesh/test_dispatch_tags.cpp index b8397fdac..dc686926c 100644 --- a/tests/cpp/unit/mesh/test_dispatch_tags.cpp +++ b/tests/cpp/unit/mesh/test_dispatch_tags.cpp @@ -77,18 +77,16 @@ TEST(test_dispatch_tags, validate_riemann_cartesian_matrix) { TEST(test_dispatch_tags, validate_riemann_polar_matrix) { std::string msg; - // seul rusanov est cable en polaire pour les flux CONNUS mais NON explicitement geres. + // Every public provider is admitted by the geometry registry. Model capabilities are checked + // by the exact dispatch leaf; the registry never infers a model or changes the solver. EXPECT_FALSE(throws([] { validate_riemann("rusanov", /*polar=*/true, "System (polaire)"); }, msg)) << "riemann rusanov accepte (polaire)"; - // hll/hllc/roe sont des tags CONNUS mais NON cables en polaire -> rejet avec le message polaire. - EXPECT_TRUE(throws([] { validate_riemann("hllc", /*polar=*/true, "System (polaire)"); }, msg)) - << "riemann hllc rejete en polaire"; - EXPECT_TRUE(contains(msg, "unsupported") && contains(msg, "polar") && contains(msg, "rusanov")) - << "message polaire : unsupported / polar / rusanov"; EXPECT_FALSE(throws([] { validate_riemann("hll", /*polar=*/true, "System (polaire)"); }, msg)) - << "riemann hll ACCEPTE en polaire (solde audit : gate wave_speeds au call-site)"; - EXPECT_TRUE(throws([] { validate_riemann("roe", /*polar=*/true, "System (polaire)"); }, msg)) - << "riemann roe rejete en polaire"; + << "riemann hll accepte en polaire (gate wave_speeds au call-site)"; + EXPECT_FALSE(throws([] { validate_riemann("hllc", /*polar=*/true, "System (polaire)"); }, msg)) + << "riemann hllc accepte en polaire (gate HasHLLCStructure au call-site)"; + EXPECT_FALSE(throws([] { validate_riemann("roe", /*polar=*/true, "System (polaire)"); }, msg)) + << "riemann roe accepte en polaire (gate HasRoeDissipation au call-site)"; EXPECT_TRUE(throws([] { validate_riemann("bogus", /*polar=*/true, "System (polaire)"); }, msg)) << "riemann inconnu rejete en polaire (meme message)"; EXPECT_TRUE(contains(msg, "unsupported")) @@ -100,11 +98,15 @@ TEST(test_dispatch_tags, limiter_n_ghost_widths) { EXPECT_EQ(limiter_n_ghost("minmod"), 2) << "n_ghost(minmod) == 2"; EXPECT_EQ(limiter_n_ghost("vanleer"), 2) << "n_ghost(vanleer) == 2"; EXPECT_EQ(limiter_n_ghost("weno5"), 3) << "n_ghost(weno5) == 3"; + EXPECT_EQ(limiter_n_ghost("mc"), 2) << "n_ghost(mc) == 2"; + EXPECT_EQ(limiter_n_ghost("superbee"), 2) << "n_ghost(superbee) == 2"; EXPECT_THROW((void)limiter_n_ghost("bogus"), std::runtime_error) << "an unknown limiter must never select a fallback halo"; // variante compile-time (utilisee par les static_assert de non-derive de block_builder.hpp). static_assert(limiter_n_ghost_ct("none") == 1, "ct none"); static_assert(limiter_n_ghost_ct("weno5") == 3, "ct weno5"); + static_assert(limiter_n_ghost_ct("mc") == 2, "ct mc"); + static_assert(limiter_n_ghost_ct("superbee") == 2, "ct superbee"); static_assert(limiter_n_ghost_ct("bogus") == -1, "ct inconnu == -1"); } @@ -113,19 +115,25 @@ TEST(test_dispatch_tags, klimiters_kriemanns_tables) { << "kLimiters[0]"; EXPECT_TRUE(std::string(kLimiters[3].name) == "weno5" && kLimiters[3].n_ghost == 3) << "kLimiters[3]"; + EXPECT_TRUE(std::string(kLimiters[4].name) == "mc" && kLimiters[4].n_ghost == 2) + << "kLimiters[4]"; + EXPECT_TRUE(std::string(kLimiters[5].name) == "superbee" && kLimiters[5].n_ghost == 2) + << "kLimiters[5]"; EXPECT_TRUE(std::string(kRiemanns[0].name) == "rusanov" && kRiemanns[0].polar_ok) << "kRiemanns[0] rusanov polar_ok"; EXPECT_TRUE(std::string(kRiemanns[1].name) == "hll" && kRiemanns[1].needs_wave_speeds && kRiemanns[1].polar_ok) << "kRiemanns[1] hll needs_wave_speeds, pas polaire"; - EXPECT_TRUE(std::string(kRiemanns[2].name) == "hllc" && kRiemanns[2].needs_hllc_struct) + EXPECT_TRUE(std::string(kRiemanns[2].name) == "hllc" && kRiemanns[2].needs_hllc_struct && + kRiemanns[2].polar_ok) << "kRiemanns[2] hllc"; - EXPECT_TRUE(std::string(kRiemanns[3].name) == "roe" && kRiemanns[3].needs_roe_diss) + EXPECT_TRUE(std::string(kRiemanns[3].name) == "roe" && kRiemanns[3].needs_roe_diss && + kRiemanns[3].polar_ok) << "kRiemanns[3] roe"; - // DEUX flux cables en polaire (rusanov + hll, solde de l'audit) : verrouille polar_ok. + // Every public provider has a polar leaf; exact model capabilities decide availability. int n_polar = 0; for (const RiemannTag& t : kRiemanns) if (t.polar_ok) ++n_polar; - EXPECT_EQ(n_polar, 2) << "deux flux polar_ok (rusanov + hll)"; + EXPECT_EQ(n_polar, 4) << "quatre flux polar_ok, chacun capability-gated au dispatch"; } diff --git a/tests/cpp/unit/mesh/test_fab2d.cpp b/tests/cpp/unit/mesh/test_fab2d.cpp index e73ba11bf..97a0433f1 100644 --- a/tests/cpp/unit/mesh/test_fab2d.cpp +++ b/tests/cpp/unit/mesh/test_fab2d.cpp @@ -5,19 +5,68 @@ #include #include +#include +#include #include +#include #include #include +#include using namespace pops; +static_assert(std::is_trivially_copyable_v> && + std::is_trivially_copyable_v> && + std::is_trivially_copyable_v>); +static_assert(std::is_standard_layout_v> && + std::is_standard_layout_v> && + std::is_standard_layout_v>); + namespace { struct NoOpCellKernel { POPS_HD void operator()(int, int) const {} }; +template +struct FillRankedFab { + FieldView values; + + POPS_HD void operator()(const Index& index) const { + Real value = 0; + for (int axis = 0; axis < Dim; ++axis) + value += (axis + 1) * index[axis]; + values(index, 0) = value; + values(index, 1) = -value; + } +}; + +template +struct SumRankedIndex { + POPS_HD Real operator()(const Index& index) const { + Real value = 0; + for (int axis = 0; axis < Dim; ++axis) + value += index[axis]; + return value; + } +}; + +template +struct NegativeRankedIndex { + POPS_HD Real operator()(const Index& index) const { + Real value = -1; + for (int axis = 0; axis < Dim; ++axis) + value -= Real(index[axis] * index[axis]); + return value; + } +}; + +template +struct NoOpRankedIndex { + POPS_HD void operator()(const Index&) const {} +}; + } // namespace TEST(test_fab2d, fill_interior_leaves_ghosts_untouched) { @@ -80,3 +129,215 @@ TEST(test_fab2d, rejects_noniterable_bounds_and_oversized_allocation_before_laun // The generic iteration seam must make the same decision before Kokkos sees hi + 1. EXPECT_THROW(for_each_cell(Box2D{{hi, 0}, {hi, 0}}, NoOpCellKernel{}), std::overflow_error); } + +TEST(test_fab2d, ranked_fab_layout_and_host_mirrors_cover_1d_2d_and_3d) { + const Box<1> line{Index<1>{-2}, Index<1>{1}}; + Fab<1> fab1(line, /*ncomp=*/2, Extent<1>{2}); + for_each_cell(line, FillRankedFab<1>{fab1.view()}); + auto host1 = fab1.create_host_mirror(); + fab1.copy_to_host(host1); + EXPECT_EQ(fab1.ghosts(), Extent<1>{2}); + EXPECT_EQ(fab1.size(), 16u); + EXPECT_EQ(fab1.view().strides[0], 1); + EXPECT_EQ(fab1.view().component_stride, 8); + EXPECT_DOUBLE_EQ(host1(2), -2.0); + EXPECT_DOUBLE_EQ(host1(2 + 8), 2.0); + EXPECT_DOUBLE_EQ(host1(5), 1.0); + EXPECT_DOUBLE_EQ(host1(5 + 8), -1.0); + + const Box<2> plane{Index<2>{-1, 3}, Index<2>{1, 4}}; + Fab<2> fab2(plane, /*ncomp=*/2, Extent<2>{1, 2}); + for_each_cell(plane, FillRankedFab<2>{fab2.view()}); + auto host2 = fab2.create_host_mirror(); + fab2.copy_to_host(host2); + EXPECT_EQ(fab2.ghosts(), (Extent<2>{1, 2})); + EXPECT_EQ(fab2.size(), 60u); + EXPECT_EQ(fab2.view().strides[0], 1); + EXPECT_EQ(fab2.view().strides[1], 5); + EXPECT_EQ(fab2.view().component_stride, 30); + EXPECT_DOUBLE_EQ(host2(11), 5.0); // (-1, 3), offset 1 + 2 * 5 + EXPECT_DOUBLE_EQ(host2(11 + 30), -5.0); + + const Box<3> volume{Index<3>{0, -1, 2}, Index<3>{1, 0, 3}}; + Fab<3> fab3(volume, /*ncomp=*/2, Extent<3>{1, 0, 2}); + for_each_cell(volume, FillRankedFab<3>{fab3.view()}); + auto host3 = fab3.create_host_mirror(); + fab3.copy_to_host(host3); + EXPECT_EQ(fab3.ghosts(), (Extent<3>{1, 0, 2})); + EXPECT_EQ(fab3.size(), 96u); + EXPECT_EQ(fab3.view().strides[0], 1); + EXPECT_EQ(fab3.view().strides[1], 4); + EXPECT_EQ(fab3.view().strides[2], 8); + EXPECT_EQ(fab3.view().component_stride, 48); + EXPECT_DOUBLE_EQ(host3(22), 7.0); // (1, 0, 2), offset 2 + 1 * 4 + 2 * 8 + EXPECT_DOUBLE_EQ(host3(22 + 48), -7.0); + + host3(0) = Real(17.5); + fab3.copy_from_host(host3); + auto copied_back = fab3.create_host_mirror(); + fab3.copy_to_host(copied_back); + EXPECT_DOUBLE_EQ(copied_back(0), 17.5); +} + +TEST(test_fab2d, ranked_fab_rejects_invalid_axis_ghosts_and_overflow_before_allocation) { + const Box<1> line{Index<1>{0}, Index<1>{1}}; + EXPECT_THROW((void)Fab<1>(line, /*ncomp=*/1, Extent<1>{-1}), std::invalid_argument); + EXPECT_THROW((void)Fab<2>(Box<2>{Index<2>{0, 0}, Index<2>{1, 1}}, /*ncomp=*/1, Extent<2>{0, -1}), + std::invalid_argument); + + constexpr int maximum = std::numeric_limits::max(); + EXPECT_THROW( + (void)Fab<1>(Box<1>{Index<1>{maximum}, Index<1>{maximum}}, /*ncomp=*/1, Extent<1>{1}), + std::overflow_error); + EXPECT_THROW((void)Fab<1>(line, /*ncomp=*/1, Extent<1>{std::numeric_limits::max()}), + std::overflow_error); + EXPECT_THROW((void)Fab<2>(Box<2>{Index<2>{0, 0}, Index<2>{maximum - 1, maximum - 1}}, + /*ncomp=*/3, Extent<2>{}), + std::overflow_error); +} + +TEST(test_fab2d, ranked_traversal_and_reductions_pass_ranked_indices) { + const Box<1> line{Index<1>{-1}, Index<1>{2}}; + const Box<2> plane{Index<2>{0, 0}, Index<2>{1, 2}}; + const Box<3> volume{Index<3>{0, 0, 0}, Index<3>{1, 1, 1}}; + + EXPECT_DOUBLE_EQ(for_each_cell_reduce_sum(line, SumRankedIndex<1>{}), 2.0); + EXPECT_DOUBLE_EQ(for_each_cell_reduce_sum(plane, SumRankedIndex<2>{}), 9.0); + EXPECT_DOUBLE_EQ(for_each_cell_reduce_sum(volume, SumRankedIndex<3>{}), 12.0); + EXPECT_DOUBLE_EQ(for_each_cell_reduce_max(line, SumRankedIndex<1>{}), 2.0); + EXPECT_DOUBLE_EQ(for_each_cell_reduce_max(plane, SumRankedIndex<2>{}), 3.0); + EXPECT_DOUBLE_EQ(for_each_cell_reduce_max(volume, SumRankedIndex<3>{}), 3.0); +} + +TEST(test_fab2d, ranked_max_reduction_preserves_least_negative_result_in_1d_2d_and_3d) { + const Box<1> line{Index<1>{-4}, Index<1>{-2}}; + const Box<2> plane{Index<2>{-3, -3}, Index<2>{-2, -2}}; + const Box<3> volume{Index<3>{-2, -2, -2}, Index<3>{-1, -1, -1}}; + + EXPECT_DOUBLE_EQ(for_each_cell_reduce_max(line, NegativeRankedIndex<1>{}), -5.0); + EXPECT_DOUBLE_EQ(for_each_cell_reduce_max(plane, NegativeRankedIndex<2>{}), -9.0); + EXPECT_DOUBLE_EQ(for_each_cell_reduce_max(volume, NegativeRankedIndex<3>{}), -4.0); +} + +TEST(test_fab2d, ranked_small_host_boxes_use_existing_fallback_counter) { + if constexpr (std::is_same_v) { + reset_fallback_diagnostics_counters(); + if (detail::foreach_serial_threshold() > 1) { + const Box<1> line{Index<1>{0}, Index<1>{0}}; + for_each_cell(line, NoOpRankedIndex<1>{}); + EXPECT_EQ(fallback_count(FallbackCounter::kForeachSerialSmallBox), 1u); + } + } +} + +TEST(test_fab2d, ranked_fallback_threshold_does_not_multiply_large_extents) { + EXPECT_TRUE(detail::foreach_small_box(63, 65, 4096)); + EXPECT_FALSE(detail::foreach_small_box(64, 64, 4096)); + EXPECT_FALSE(detail::foreach_small_box(std::numeric_limits::max(), + std::numeric_limits::max(), 4096)); + + constexpr int minimum = std::numeric_limits::min(); + const Box<3> all_negative{Index<3>{minimum, minimum, minimum}, Index<3>{-1, -1, -1}}; + EXPECT_FALSE(detail::foreach_small_box(all_negative, 4096)); +} + +TEST(test_fab2d, ranked_value_constructors_compile_in_a_kokkos_device_lambda) { + detail::ensure_kokkos_initialized(); + Kokkos::View equal_on_device("pops_ranked_box_equality_device"); + Kokkos::parallel_for( + "pops_ranked_value_device_construction", 1, KOKKOS_LAMBDA(const int) { + const Index<1> index1{1}; + const Index<2> index2{1, 2}; + const Index<3> index3{1, 2, 3}; + const Extent<1> extent1{1}; + const Extent<2> extent2{1, 2}; + const Extent<3> extent3{1, 2, 3}; + const RealVector<1> vector1{1.0}; + const RealVector<2> vector2{1.0, 2.0}; + const RealVector<3> vector3{1.0, 2.0, 3.0}; + const Box<1> box1{index1, index1}; + const Box<2> box2{index2, index2}; + const Box<3> box3{index3, index3}; + equal_on_device(0) = box1 == Box<1>{index1, index1}; + equal_on_device(1) = box2 == Box<2>{index2, index2}; + equal_on_device(2) = box3 == Box<3>{index3, index3}; + (void)extent1; + (void)extent2; + (void)extent3; + (void)vector1; + (void)vector2; + (void)vector3; + (void)box1; + (void)box2; + (void)box3; + }); + Kokkos::fence(); + const auto equal_on_host = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, equal_on_device); + EXPECT_EQ(equal_on_host(0), 1); + EXPECT_EQ(equal_on_host(1), 1); + EXPECT_EQ(equal_on_host(2), 1); +} + +TEST(test_fab2d, ranked_fab_copy_owns_distinct_storage) { + const Box<2> box{Index<2>{-1, 2}, Index<2>{1, 3}}; + Fab<2> original(box, /*ncomp=*/1, Extent<2>{}); + original.set_val(Real(3.5)); + + Fab<2> copy = original; + EXPECT_NE(copy.storage().data(), original.storage().data()); + + auto original_host = original.create_host_mirror(); + auto copy_host = copy.create_host_mirror(); + original.copy_to_host(original_host); + copy.copy_to_host(copy_host); + EXPECT_DOUBLE_EQ(copy_host(0), original_host(0)); + + copy.set_val(Real(-8.0)); + auto mutated_copy_host = copy.create_host_mirror(); + copy.copy_to_host(mutated_copy_host); + original.copy_to_host(original_host); + EXPECT_DOUBLE_EQ(mutated_copy_host(0), -8.0); + EXPECT_DOUBLE_EQ(original_host(0), 3.5); + + Fab<2> assigned; + assigned = original; + EXPECT_NE(assigned.storage().data(), original.storage().data()); +} + +TEST(test_fab2d, ranked_host_mirrors_reject_cross_fab_and_stale_associations) { + static_assert( + std::is_same_v&>().storage()), const Fab<1>::storage_type&>); + + const Box<1> box{Index<1>{0}, Index<1>{1}}; + Fab<1> source(box, /*ncomp=*/1, Extent<1>{}); + Fab<1> other(box, /*ncomp=*/1, Extent<1>{}); + auto source_mirror = source.create_host_mirror(); + EXPECT_THROW(other.copy_to_host(source_mirror), std::invalid_argument); + EXPECT_THROW(other.copy_from_host(source_mirror), std::invalid_argument); + + Fab<1> moved(std::move(source)); + EXPECT_EQ(source.size(), 0U); + EXPECT_THROW(moved.copy_to_host(source_mirror), std::invalid_argument); + EXPECT_THROW(source.copy_to_host(source_mirror), std::invalid_argument); + auto moved_mirror = moved.create_host_mirror(); + EXPECT_NO_THROW(moved.copy_to_host(moved_mirror)); + + auto other_mirror = other.create_host_mirror(); + other = std::move(moved); + EXPECT_THROW(other.copy_to_host(other_mirror), std::invalid_argument); + EXPECT_THROW(other.copy_to_host(moved_mirror), std::invalid_argument); + auto rebound_mirror = other.create_host_mirror(); + EXPECT_NO_THROW(other.copy_to_host(rebound_mirror)); + + Fab<1> resized(box, /*ncomp=*/1, Extent<1>{}); + auto stale_extent = resized.create_host_mirror(); + resized = Fab<1>(Box<1>{Index<1>{0}, Index<1>{2}}, /*ncomp=*/1, Extent<1>{}); + EXPECT_THROW(resized.copy_to_host(stale_extent), std::invalid_argument); + + Fab<2> empty; + auto empty_mirror = empty.create_host_mirror(); + EXPECT_EQ(empty_mirror.size(), 0U); + EXPECT_NO_THROW(empty.copy_to_host(empty_mirror)); + EXPECT_NO_THROW(empty.copy_from_host(empty_mirror)); +} diff --git a/tests/cpp/unit/mesh/test_load_balance.cpp b/tests/cpp/unit/mesh/test_load_balance.cpp index b5a8e7417..29a9094d9 100644 --- a/tests/cpp/unit/mesh/test_load_balance.cpp +++ b/tests/cpp/unit/mesh/test_load_balance.cpp @@ -74,6 +74,20 @@ struct ExternalIndexLoadBalance { } }; +ResourceEstimate measured_patch_cost(std::int64_t nanoseconds, std::int64_t resident_bytes = 1024) { + return ResourceEstimate{ + .topology_epoch = 7, + .materialization_generation = 3, + .samples = 1, + .cell_updates = 1, + .compute_nanoseconds = nanoseconds, + .memory_bytes = 64, + .communication_bytes = 0, + .communication_nanoseconds = 0, + .resident_bytes = resident_bytes, + }; +} + } // namespace TEST(test_load_balance, morton_key_reference_values) { @@ -204,3 +218,113 @@ TEST(test_load_balance, third_party_provider_registers_without_core_changes) { PreparedProviderOptions{"pops.test.load-balance.wrong-schema@1", {}}), std::invalid_argument); } + +TEST(test_load_balance, measured_rebalance_accepts_only_net_benefit_after_migration) { + const BoxArray boxes = BoxArray::from_domain(Box2D::from_extents(4, 1), 1); + const DistributionMapping current(std::vector{0, 0, 1, 1}); + const DistributionMapping proposed(std::vector{0, 1, 0, 1}); + const std::vector estimates{measured_patch_cost(100), measured_patch_cost(100), + measured_patch_cost(1), measured_patch_cost(1)}; + const RebalancePolicy profitable{ + .minimum_improvement_ppm = 50'000, + .amortization_steps = 100, + .migration_bandwidth_bytes_per_second = 1'000'000'000'000, + .per_patch_migration_latency_nanoseconds = 0, + }; + const std::string source_contract = detail::exact_rebalance_source( + "test.load-balance", "test.load-balance@1", 1, 2, 7, 3, boxes, current); + + const RebalanceDecision accepted = make_rebalance_decision( + boxes, current, proposed, 2, 7, 3, estimates, profitable, source_contract); + EXPECT_TRUE(accepted.accepted); + EXPECT_EQ(accepted.reason, RebalanceReason::NetBenefit); + EXPECT_EQ(accepted.moved_patches, 2); + EXPECT_EQ(accepted.migration_bytes, 2048); + EXPECT_LT(accepted.proposed_imbalance, accepted.current_imbalance); + EXPECT_GT(accepted.predicted_net_speedup, 1.05); + EXPECT_FALSE(accepted.exact_contract.empty()); + + RebalancePolicy expensive = profitable; + expensive.amortization_steps = 1; + expensive.migration_bandwidth_bytes_per_second = 1; + const RebalanceDecision refused = make_rebalance_decision(boxes, current, proposed, 2, 7, 3, + estimates, expensive, source_contract); + EXPECT_FALSE(refused.accepted); + EXPECT_EQ(refused.reason, RebalanceReason::InsufficientNetBenefit); + EXPECT_LT(refused.predicted_net_speedup, 1.0); +} + +TEST(test_load_balance, measured_rebalance_refuses_stale_or_incomplete_evidence) { + const BoxArray boxes = BoxArray::from_domain(Box2D::from_extents(2, 1), 1); + const DistributionMapping current(std::vector{0, 0}); + const DistributionMapping proposed(std::vector{0, 1}); + std::vector estimates{measured_patch_cost(100), measured_patch_cost(1)}; + const RebalancePolicy policy{}; + const std::string source_contract = detail::exact_rebalance_source( + "test.load-balance", "test.load-balance@1", 1, 2, 7, 3, boxes, current); + + estimates[1].topology_epoch = 6; + EXPECT_THROW(make_rebalance_decision(boxes, current, proposed, 2, 7, 3, estimates, policy, + source_contract), + std::invalid_argument); + estimates[1] = measured_patch_cost(1); + estimates[1].samples = 0; + EXPECT_THROW(make_rebalance_decision(boxes, current, proposed, 2, 7, 3, estimates, policy, + source_contract), + std::invalid_argument); +} + +TEST(test_load_balance, measured_rebalance_keeps_an_unchanged_mapping) { + const BoxArray boxes = BoxArray::from_domain(Box2D::from_extents(2, 1), 1); + const DistributionMapping current(std::vector{0, 1}); + const std::vector estimates{measured_patch_cost(1), measured_patch_cost(1)}; + + const RebalanceDecision decision = make_rebalance_decision( + boxes, current, current, 2, 7, 3, estimates, RebalancePolicy{}, + detail::exact_rebalance_source("test.load-balance", "test.load-balance@1", 1, 2, 7, 3, boxes, + current)); + EXPECT_FALSE(decision.accepted); + EXPECT_EQ(decision.reason, RebalanceReason::MappingUnchanged); + EXPECT_EQ(decision.moved_patches, 0); + EXPECT_EQ(decision.migration_bytes, 0); + EXPECT_DOUBLE_EQ(decision.predicted_net_speedup, 1.0); +} + +TEST(test_load_balance, measured_knapsack_provider_owns_exact_default_decision_policy) { + const PreparedProviderOptions options{ + "pops.amr.load-balance.measured-knapsack@1", + { + {"minimum_improvement_ppm", std::int64_t{125'000}}, + {"amortization_steps", std::int64_t{40}}, + {"migration_bandwidth_bytes_per_second", std::int64_t{25'000'000'000}}, + {"per_patch_migration_latency_nanoseconds", std::int64_t{2'500}}, + }, + }; + const PreparedLoadBalanceAuthority authority = prepare_load_balance_authority( + "measured_knapsack", "test.measured-knapsack.identity", options); + ASSERT_TRUE(authority.has_default_rebalance_policy()); + EXPECT_EQ(authority.implementation(), "pops.load_balance.measured_knapsack"); + const RebalancePolicy& policy = authority.default_rebalance_policy(); + EXPECT_EQ(policy.minimum_improvement_ppm, 125'000); + EXPECT_EQ(policy.amortization_steps, 40); + EXPECT_EQ(policy.migration_bandwidth_bytes_per_second, 25'000'000'000); + EXPECT_EQ(policy.per_patch_migration_latency_nanoseconds, 2'500); + + const BoxArray boxes = BoxArray::from_domain(Box2D::from_extents(2, 1), 1); + const DistributionMapping current(std::vector{0, 0}); + const RebalanceDecision defaulted = authority.decide_rebalance( + 1, boxes, current, 1, 7, 3, + std::vector{measured_patch_cost(100), measured_patch_cost(1)}); + EXPECT_FALSE(defaulted.accepted); + EXPECT_EQ(defaulted.reason, RebalanceReason::MappingUnchanged); + EXPECT_FALSE(defaulted.source_contract.empty()); + + PreparedProviderOptions incomplete = options; + incomplete.values.erase("amortization_steps"); + EXPECT_THROW(prepare_load_balance_authority("measured_knapsack", "test.invalid", incomplete), + std::invalid_argument); + PreparedProviderOptions wrong_type = options; + wrong_type.values["amortization_steps"] = std::uint64_t{40}; + EXPECT_THROW(prepare_load_balance_authority("measured_knapsack", "test.invalid", wrong_type), + std::invalid_argument); +} diff --git a/tests/cpp/unit/mesh/test_nd_boundary_schedule.cpp b/tests/cpp/unit/mesh/test_nd_boundary_schedule.cpp new file mode 100644 index 000000000..12cf50e47 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_boundary_schedule.cpp @@ -0,0 +1,173 @@ +#include + +#include + +#include +#include +#include +#include +#include + +using namespace pops; + +TEST(test_nd_boundary_schedule, faces_and_complete_topology_are_ranked_and_canonical) { + EXPECT_EQ((Face<1>{0, BoundarySide::lower}.ordinal()), 0); + EXPECT_EQ((Face<3>{2, BoundarySide::upper}.ordinal()), 5); + EXPECT_EQ((Face<2>{1, BoundarySide::lower}.outward_sign()), -1); + EXPECT_EQ((Face<2>{1, BoundarySide::lower}.opposite()), (Face<2>{1, BoundarySide::upper})); + EXPECT_THROW((Face<2>{2, BoundarySide::lower}), std::invalid_argument); + + const BoundaryTopology<3> physical; + static_assert(BoundaryTopology<3>::face_count == 6); + ASSERT_EQ(physical.faces().size(), 6U); + for (std::size_t ordinal = 0; ordinal < physical.faces().size(); ++ordinal) { + EXPECT_EQ(physical.faces()[ordinal].face.ordinal(), static_cast(ordinal)); + EXPECT_EQ(physical.faces()[ordinal].kind, BoundaryFaceKind::physical); + } + + const auto periodic = BoundaryTopology<3>::axis_periodic({true, false, true}); + EXPECT_EQ(periodic.periodic_pair_count(), 2U); + EXPECT_EQ(periodic.partner(Face<3>{0, BoundarySide::lower}), (Face<3>{0, BoundarySide::upper})); + EXPECT_TRUE(periodic.is_physical(Face<3>{1, BoundarySide::upper})); + EXPECT_THROW((void)periodic.partner(Face<3>{1, BoundarySide::lower}), std::invalid_argument); +} + +TEST(test_nd_boundary_schedule, topology_refuses_ambiguous_or_non_translation_pairs) { + EXPECT_THROW( + (PeriodicFacePair<2>{Face<2>{0, BoundarySide::lower}, Face<2>{1, BoundarySide::upper}}), + std::invalid_argument); + EXPECT_THROW( + (PeriodicFacePair<2>{Face<2>{0, BoundarySide::lower}, Face<2>{0, BoundarySide::lower}}), + std::invalid_argument); + + const PeriodicFacePair<2> x_pair{Face<2>{0, BoundarySide::upper}, + Face<2>{0, BoundarySide::lower}}; + EXPECT_EQ(x_pair.first, (Face<2>{0, BoundarySide::lower})); + EXPECT_EQ(x_pair.second, (Face<2>{0, BoundarySide::upper})); + const std::array, 2> conflicts{x_pair, x_pair}; + EXPECT_THROW((void)BoundaryTopology<2>{conflicts}, std::invalid_argument); +} + +TEST(test_nd_boundary_schedule, physical_regions_are_explicit_faces_edges_and_corners) { + const auto line = prepare_boundary_schedule(Box<1>{Index<1>{0}, Index<1>{3}}, Extent<1>{1}, + BoundaryTopology<1>{}, BoundaryScheduleBudget{2}); + ASSERT_EQ(line.size(), 2U); + EXPECT_EQ(line.entries()[0].region.kind(), BoundaryRegionKind::face); + EXPECT_EQ(line.entries()[1].region.kind(), BoundaryRegionKind::face); + + const auto plane = + prepare_boundary_schedule(Box<2>{Index<2>{0, 0}, Index<2>{3, 4}}, Extent<2>{1, 1}, + BoundaryTopology<2>{}, BoundaryScheduleBudget{8}); + ASSERT_EQ(plane.size(), 8U); + std::size_t plane_faces = 0; + std::size_t plane_corners = 0; + for (const BoundaryRegionPlan<2>& entry : plane.entries()) { + plane_faces += entry.region.kind() == BoundaryRegionKind::face ? 1U : 0U; + plane_corners += entry.region.kind() == BoundaryRegionKind::corner ? 1U : 0U; + EXPECT_TRUE(entry.has_physical()); + EXPECT_FALSE(entry.has_periodic()); + } + EXPECT_EQ(plane_faces, 4U); + EXPECT_EQ(plane_corners, 4U); + EXPECT_EQ(plane.entries()[0].region.ordinal(), 1U); + EXPECT_EQ(plane.entries()[1].region.ordinal(), 2U); + EXPECT_EQ(plane.entries()[2].region.ordinal(), 3U); + EXPECT_EQ(plane.entries()[3].region.ordinal(), 4U); + + const auto volume = + prepare_boundary_schedule(Box<3>{Index<3>{0, 0, 0}, Index<3>{1, 1, 1}}, Extent<3>{1, 1, 1}, + BoundaryTopology<3>{}, BoundaryScheduleBudget{26}); + ASSERT_EQ(volume.size(), 26U); + std::array kind_counts{}; + for (const BoundaryRegionPlan<3>& entry : volume.entries()) { + if (entry.region.kind() == BoundaryRegionKind::face) + ++kind_counts[0]; + else if (entry.region.kind() == BoundaryRegionKind::edge) + ++kind_counts[1]; + else + ++kind_counts[2]; + } + EXPECT_EQ(kind_counts, (std::array{6, 12, 8})); +} + +TEST(test_nd_boundary_schedule, periodic_corner_composition_is_deterministic_and_additive) { + const Box<2> domain{Index<2>{0, 10}, Index<2>{3, 12}}; + const auto topology = BoundaryTopology<2>::axis_periodic({true, true}); + const auto schedule = + prepare_boundary_schedule(domain, Extent<2>{1, 1}, topology, BoundaryScheduleBudget{8}); + ASSERT_EQ(schedule.size(), 8U); + const BoundaryRegionPlan<2>& lower_x_upper_y = schedule.entries()[6]; + EXPECT_EQ(lower_x_upper_y.region.ordinal(), 7U); + EXPECT_EQ(lower_x_upper_y.region.kind(), BoundaryRegionKind::corner); + EXPECT_EQ(lower_x_upper_y.destination, (Box<2>{Index<2>{-1, 13}, Index<2>{-1, 13}})); + EXPECT_EQ(lower_x_upper_y.operation_count, 2); + EXPECT_EQ(lower_x_upper_y.operations[0].face, (Face<2>{0, BoundarySide::lower})); + EXPECT_EQ(lower_x_upper_y.operations[1].face, (Face<2>{1, BoundarySide::upper})); + EXPECT_EQ(lower_x_upper_y.source_from_destination_shift, (Index<2>{4, -3})); + EXPECT_TRUE(lower_x_upper_y.has_periodic()); + EXPECT_FALSE(lower_x_upper_y.has_physical()); + + const auto mixed = prepare_boundary_schedule(domain, Extent<2>{1, 1}, + BoundaryTopology<2>::axis_periodic({true, false}), + BoundaryScheduleBudget{8}); + const BoundaryRegionPlan<2>& mixed_corner = mixed.entries()[3]; + EXPECT_TRUE(mixed_corner.has_periodic()); + EXPECT_TRUE(mixed_corner.has_physical()); + EXPECT_EQ(mixed_corner.source_from_destination_shift, (Index<2>{4, 0})); +} + +TEST(test_nd_boundary_schedule, deep_periodic_ghosts_are_partitioned_into_exact_wraps) { + const auto schedule = prepare_boundary_schedule(Box<1>{Index<1>{0}, Index<1>{1}}, Extent<1>{5}, + BoundaryTopology<1>::axis_periodic({true}), + BoundaryScheduleBudget{6}); + ASSERT_EQ(schedule.size(), 6U); + EXPECT_EQ(schedule.entries()[0].destination, (Box<1>{Index<1>{-2}, Index<1>{-1}})); + EXPECT_EQ(schedule.entries()[0].source_from_destination_shift, (Index<1>{2})); + EXPECT_EQ(schedule.entries()[2].destination, (Box<1>{Index<1>{-4}, Index<1>{-3}})); + EXPECT_EQ(schedule.entries()[2].source_from_destination_shift, (Index<1>{4})); + EXPECT_EQ(schedule.entries()[4].destination, (Box<1>{Index<1>{-5}, Index<1>{-5}})); + EXPECT_EQ(schedule.entries()[4].source_from_destination_shift, (Index<1>{6})); + EXPECT_EQ(schedule.entries()[5].destination, (Box<1>{Index<1>{6}, Index<1>{6}})); + EXPECT_EQ(schedule.entries()[5].source_from_destination_shift, (Index<1>{-6})); +} + +TEST(test_nd_boundary_schedule, composition_and_planning_fail_closed_on_conflicts_and_limits) { + std::array, 2> unordered{ + BoundaryOperation<2>{Face<2>{1, BoundarySide::lower}, BoundaryFaceKind::physical, {}}, + BoundaryOperation<2>{Face<2>{0, BoundarySide::upper}, BoundaryFaceKind::periodic, + Index<2>{-4, 0}}}; + const auto canonical = + compose_boundary_region_plan(Box<2>{Index<2>{4, -1}, Index<2>{4, -1}}, unordered, 2); + EXPECT_EQ(canonical.operations[0].face, (Face<2>{0, BoundarySide::upper})); + EXPECT_EQ(canonical.operations[1].face, (Face<2>{1, BoundarySide::lower})); + + std::array, 2> duplicate_axis{ + BoundaryOperation<2>{Face<2>{0, BoundarySide::lower}, BoundaryFaceKind::physical, {}}, + BoundaryOperation<2>{Face<2>{0, BoundarySide::upper}, BoundaryFaceKind::physical, {}}}; + EXPECT_THROW( + (void)compose_boundary_region_plan(Box<2>{Index<2>{0, 0}, Index<2>{0, 0}}, duplicate_axis, 2), + std::invalid_argument); + + std::array, 2> tangential{ + BoundaryOperation<2>{Face<2>{0, BoundarySide::lower}, BoundaryFaceKind::periodic, + Index<2>{4, 1}}, + {}}; + EXPECT_THROW( + (void)compose_boundary_region_plan(Box<2>{Index<2>{0, 0}, Index<2>{0, 0}}, tangential, 1), + std::invalid_argument); + + EXPECT_THROW((void)prepare_boundary_schedule(Box<3>{Index<3>{0, 0, 0}, Index<3>{1, 1, 1}}, + Extent<3>{1, 1, 1}, BoundaryTopology<3>{}, + BoundaryScheduleBudget{25}), + std::length_error); + EXPECT_THROW( + (void)prepare_boundary_schedule(Box<1>{Index<1>{std::numeric_limits::min()}, + Index<1>{std::numeric_limits::max()}}, + Extent<1>{1}, BoundaryTopology<1>::axis_periodic({true}), + BoundaryScheduleBudget{2}), + std::overflow_error); + + static_assert(std::is_trivially_copyable_v>); + static_assert(std::is_trivially_copyable_v>); + static_assert(std::is_trivially_copyable_v>); +} diff --git a/tests/cpp/unit/mesh/test_nd_cluster.cpp b/tests/cpp/unit/mesh/test_nd_cluster.cpp new file mode 100644 index 000000000..67cb354b0 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_cluster.cpp @@ -0,0 +1,305 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace nd = pops::amr::hierarchy::nd; +namespace mesh = pops::mesh; + +using pops::Box; +using pops::Extent; +using pops::Index; + +namespace { + +constexpr mesh::BoxArrayValidationBudget kLayoutBudget{64, 2016}; +constexpr std::size_t kIdentityBudget = 1U << 20; + +constexpr nd::TagMaskBudget tag_budget(std::size_t global_patches, std::size_t owned_patches, + std::size_t cells_per_patch, std::size_t owned_cells) { + return nd::TagMaskBudget{global_patches, owned_patches, cells_per_patch, + owned_cells, owned_cells, kIdentityBudget}; +} + +template +nd::ClusterOptions options(std::array minimum, std::array maximum, + double efficiency = 0.7) { + return nd::ClusterOptions{efficiency, minimum, maximum, + nd::ClusterWorkBudget{16, 1024, 100000, 1024, kIdentityBudget}}; +} + +template +nd::LevelLayout replicated_level(const Box& domain, const mesh::BoxArray& patches, + const mesh::RankSpace& ranks) { + nd::RefinementRatio ratio{}; + return nd::LevelLayout(0, domain, patches, + mesh::Distribution::replicated(patches, ranks), ratio, + kLayoutBudget); +} + +template +bool box_less(const Box& left, const Box& right) { + for (int axis = 0; axis < Dim; ++axis) { + if (left.lo[axis] != right.lo[axis]) + return left.lo[axis] < right.lo[axis]; + if (left.hi[axis] != right.hi[axis]) + return left.hi[axis] < right.hi[axis]; + } + return false; +} + +template + requires(AxisCount == static_cast(Dim)) +Index permute(const Index& index, const std::array& axes) { + Index result{}; + for (int axis = 0; axis < Dim; ++axis) + result[axis] = index[axes[axis]]; + return result; +} + +template + requires(AxisCount == static_cast(Dim)) +Box permute(const Box& box, const std::array& axes) { + return Box{permute(box.lo, axes), permute(box.hi, axes)}; +} + +} // namespace + +TEST(test_nd_cluster, one_dimensional_holes_split_into_deterministic_boxes) { + const Box<1> domain{Index<1>{-8}, Index<1>{7}}; + const mesh::BoxArray<1> patches(std::vector>{domain}); + const mesh::RankSpace<1> ranks{Index<1>{3}, Extent<1>{1}}; + const auto level = replicated_level(domain, patches, ranks); + nd::TagMask<1> mask(level, Index<1>{3}, tag_budget(1, 1, 16, 16)); + for (const int coordinate : {-6, -5, 4, 5}) + mask.set(Index<1>{coordinate}); + + const nd::BergerRigoutsosProvider<1> provider; + const std::array, 1> shards{mask}; + const auto first = provider.cluster(shards, options<1>({1}, {16})); + const auto second = provider.cluster(shards, options<1>({1}, {16})); + EXPECT_EQ(first.boxes.boxes(), (std::vector>{Box<1>{Index<1>{-6}, Index<1>{-5}}, + Box<1>{Index<1>{4}, Index<1>{5}}})); + EXPECT_EQ(first.identity, second.identity); + EXPECT_EQ(first.identity.provider, nd::BergerRigoutsosProvider<1>::kIdentity); +} + +TEST(test_nd_cluster, anisotropic_final_chop_is_axis_indexed) { + const Box<2> domain{Index<2>{-2, 5}, Index<2>{1, 10}}; + const mesh::BoxArray<2> patches(std::vector>{domain}); + const mesh::RankSpace<2> ranks{Index<2>{2, -1}, Extent<2>{1, 1}}; + const auto level = replicated_level(domain, patches, ranks); + nd::TagMask<2> mask(level, Index<2>{2, -1}, tag_budget(1, 1, 24, 24)); + for (int j = domain.lo[1]; j <= domain.hi[1]; ++j) + for (int i = domain.lo[0]; i <= domain.hi[0]; ++i) + mask.set(Index<2>{i, j}); + + const nd::BergerRigoutsosProvider<2> provider; + const std::array, 1> shards{mask}; + const auto clustered = provider.cluster(shards, options<2>({1, 1}, {2, 3})); + ASSERT_EQ(clustered.boxes.size(), 4U); + for (const Box<2>& box : clustered.boxes.boxes()) { + EXPECT_LE(box.length(0), 2); + EXPECT_LE(box.length(1), 3); + } +} + +TEST(test_nd_cluster, three_dimensional_axis_permutation_maps_to_the_same_clusters) { + const Box<3> domain{Index<3>{0, 0, 0}, Index<3>{7, 4, 2}}; + const mesh::BoxArray<3> patches(std::vector>{domain}); + const mesh::RankSpace<3> ranks{Index<3>{0, 0, 0}, Extent<3>{1, 1, 1}}; + const auto level = replicated_level(domain, patches, ranks); + nd::TagMask<3> mask(level, Index<3>{0, 0, 0}, tag_budget(1, 1, 120, 120)); + for (int z = 0; z <= 0; ++z) + for (int y = 0; y <= 1; ++y) + for (int x = 0; x <= 1; ++x) + mask.set(Index<3>{x, y, z}); + for (int z = 2; z <= 2; ++z) + for (int y = 3; y <= 4; ++y) + for (int x = 6; x <= 7; ++x) + mask.set(Index<3>{x, y, z}); + + const nd::BergerRigoutsosProvider<3> provider; + const std::array, 1> shards{mask}; + const auto original = provider.cluster(shards, options<3>({1, 1, 1}, {8, 5, 3})); + + const std::array axes{2, 1, 0}; + const Box<3> transposed_domain = permute(domain, axes); + const mesh::BoxArray<3> transposed_patches(std::vector>{transposed_domain}); + const auto transposed_level = replicated_level(transposed_domain, transposed_patches, ranks); + nd::TagMask<3> transposed(transposed_level, Index<3>{0, 0, 0}, tag_budget(1, 1, 120, 120)); + mask.for_each_tagged_in(domain, + [&](const Index<3>& index) { transposed.set(permute(index, axes)); }); + const std::array, 1> transposed_shards{transposed}; + const auto mapped = provider.cluster(transposed_shards, options<3>({1, 1, 1}, {3, 5, 8})); + + std::vector> expected; + for (const Box<3>& box : original.boxes.boxes()) + expected.push_back(permute(box, axes)); + std::sort(expected.begin(), expected.end(), box_less<3>); + EXPECT_EQ(mapped.boxes.boxes(), expected); +} + +TEST(test_nd_cluster, equal_length_axis_ties_are_permutation_equivariant) { + const Box<2> domain{Index<2>{0, 0}, Index<2>{3, 3}}; + const mesh::BoxArray<2> patches(std::vector>{domain}); + const mesh::RankSpace<2> ranks{Index<2>{0, 0}, Extent<2>{1, 1}}; + const auto level = replicated_level(domain, patches, ranks); + nd::TagMask<2> mask(level, Index<2>{0, 0}, tag_budget(1, 1, 16, 16)); + for (const int y : {0, 1, 3}) + for (const int x : {0, 2, 3}) + mask.set(Index<2>{x, y}); + + const nd::BergerRigoutsosProvider<2> provider; + const std::array, 1> shards{mask}; + const auto original = provider.cluster(shards, options<2>({1, 1}, {4, 4}, 0.6)); + + const std::array axes{1, 0}; + const auto transposed_level = replicated_level(permute(domain, axes), patches, ranks); + nd::TagMask<2> transposed(transposed_level, Index<2>{0, 0}, tag_budget(1, 1, 16, 16)); + mask.for_each_tagged_in(domain, + [&](const Index<2>& index) { transposed.set(permute(index, axes)); }); + const std::array, 1> transposed_shards{transposed}; + const auto mapped = provider.cluster(transposed_shards, options<2>({1, 1}, {4, 4}, 0.6)); + + std::vector> expected; + for (const Box<2>& box : original.boxes.boxes()) + expected.push_back(permute(box, axes)); + std::sort(expected.begin(), expected.end(), box_less<2>); + EXPECT_GT(expected.size(), 1U); + EXPECT_EQ(mapped.boxes.boxes(), expected); +} + +TEST(test_nd_cluster, partitioned_shards_are_canonicalized_and_exactly_authenticated) { + const Box<2> domain{Index<2>{0, 0}, Index<2>{7, 3}}; + const mesh::BoxArray<2> patches(std::vector>{Box<2>{Index<2>{0, 0}, Index<2>{3, 3}}, + Box<2>{Index<2>{4, 0}, Index<2>{7, 3}}}); + const mesh::RankSpace<2> ranks{Index<2>{10, -2}, Extent<2>{3, 1}}; + const auto distribution = + mesh::Distribution<2>::partitioned(patches, ranks, {Index<2>{10, -2}, Index<2>{11, -2}}); + const nd::LevelLayout<2> level(0, domain, patches, distribution, nd::RefinementRatio<2>{1, 1}, + kLayoutBudget); + nd::TagMask<2> left(level, Index<2>{10, -2}, tag_budget(2, 1, 16, 16)); + nd::TagMask<2> right(level, Index<2>{11, -2}, tag_budget(2, 1, 16, 16)); + nd::TagMask<2> empty_rank(level, Index<2>{12, -2}, tag_budget(2, 0, 16, 0)); + left.set(Index<2>{1, 1}); + right.set(Index<2>{6, 2}); + + const nd::BergerRigoutsosProvider<2> provider; + const std::vector> ordered{left, right, empty_rank}; + const std::vector> reversed{empty_rank, right, left}; + const auto first = provider.cluster(ordered, options<2>({1, 1}, {4, 4})); + const auto second = provider.cluster(reversed, options<2>({1, 1}, {4, 4})); + EXPECT_EQ(first.boxes, second.boxes); + EXPECT_EQ(first.identity, second.identity); + EXPECT_EQ(first.boxes.boxes(), (std::vector>{Box<2>{Index<2>{1, 1}, Index<2>{1, 1}}, + Box<2>{Index<2>{6, 2}, Index<2>{6, 2}}})); + + const std::vector> missing{left}; + const std::vector> duplicate{left, left, empty_rank}; + EXPECT_THROW((void)provider.cluster(missing, options<2>({1, 1}, {4, 4})), std::invalid_argument); + EXPECT_THROW((void)provider.cluster(duplicate, options<2>({1, 1}, {4, 4})), + std::invalid_argument); + + const auto reversed_distribution = + mesh::Distribution<2>::partitioned(patches, ranks, {Index<2>{11, -2}, Index<2>{10, -2}}); + const nd::LevelLayout<2> other_level(0, domain, patches, reversed_distribution, + nd::RefinementRatio<2>{1, 1}, kLayoutBudget); + nd::TagMask<2> other(other_level, Index<2>{10, -2}, tag_budget(2, 1, 16, 16)); + const std::vector> mismatched{left, other, empty_rank}; + EXPECT_THROW((void)provider.cluster(mismatched, options<2>({1, 1}, {4, 4})), + std::invalid_argument); +} + +TEST(test_nd_cluster, replicated_shards_are_authenticated_and_canonicalized) { + const Box<2> domain{Index<2>{0, 0}, Index<2>{3, 3}}; + const mesh::BoxArray<2> patches(std::vector>{domain}); + const mesh::RankSpace<2> ranks{Index<2>{0, 0}, Extent<2>{2, 1}}; + const auto level = replicated_level(domain, patches, ranks); + nd::TagMask<2> first(level, Index<2>{0, 0}, tag_budget(1, 1, 16, 16)); + nd::TagMask<2> second(level, Index<2>{1, 0}, tag_budget(1, 1, 16, 16)); + for (int j = 0; j < 4; ++j) + for (int i = 0; i < 4; ++i) { + first.set(Index<2>{i, j}); + second.set(Index<2>{i, j}); + } + const nd::BergerRigoutsosProvider<2> provider; + const std::vector> ordered{first, second}; + const std::vector> reversed{second, first}; + const auto canonical = provider.cluster(ordered, options<2>({1, 1}, {4, 4})); + const auto reordered = provider.cluster(reversed, options<2>({1, 1}, {4, 4})); + EXPECT_EQ(canonical.identity, reordered.identity); + ASSERT_EQ(canonical.identity.canonical_shards.size(), 2U); + EXPECT_FALSE(canonical.identity.canonical_shards[0].replicated_alias); + EXPECT_EQ(canonical.identity.canonical_shards[0].patches.size(), 1U); + EXPECT_TRUE(canonical.identity.canonical_shards[1].replicated_alias); + EXPECT_TRUE(canonical.identity.canonical_shards[1].patches.empty()); + + const std::array, 1> missing{first}; + EXPECT_THROW((void)provider.cluster(missing, options<2>({1, 1}, {4, 4})), std::invalid_argument); + + nd::TagMask<2> divergent = second; + divergent.set(Index<2>{0, 0}, false); + const std::vector> disagreement{first, divergent}; + EXPECT_THROW((void)provider.cluster(disagreement, options<2>({1, 1}, {4, 4})), + std::invalid_argument); +} + +TEST(test_nd_cluster, invalid_or_exhausted_work_budgets_fail_closed) { + const Box<2> domain{Index<2>{0, 0}, Index<2>{3, 3}}; + const mesh::BoxArray<2> patches(std::vector>{domain}); + const mesh::RankSpace<2> ranks{Index<2>{0, 0}, Extent<2>{2, 1}}; + const auto level = replicated_level(domain, patches, ranks); + nd::TagMask<2> first(level, Index<2>{0, 0}, tag_budget(1, 1, 16, 16)); + nd::TagMask<2> second(level, Index<2>{1, 0}, tag_budget(1, 1, 16, 16)); + for (int j = 0; j < 4; ++j) + for (int i = 0; i < 4; ++i) { + first.set(Index<2>{i, j}); + second.set(Index<2>{i, j}); + } + const nd::BergerRigoutsosProvider<2> provider; + const std::vector> shards{first, second}; + + auto invalid_efficiency = options<2>({1, 1}, {4, 4}); + invalid_efficiency.min_efficiency = 0.0; + EXPECT_THROW((void)provider.cluster(shards, invalid_efficiency), std::invalid_argument); + auto invalid_size = options<2>({2, 1}, {1, 4}); + EXPECT_THROW((void)provider.cluster(shards, invalid_size), std::invalid_argument); + auto invalid_budget = options<2>({1, 1}, {4, 4}); + invalid_budget.budget.recursion_nodes = 0; + EXPECT_THROW((void)provider.cluster(shards, invalid_budget), std::invalid_argument); + auto invalid_identity_budget = options<2>({1, 1}, {4, 4}); + invalid_identity_budget.budget.identity_bytes = 0; + EXPECT_THROW((void)provider.cluster(shards, invalid_identity_budget), std::invalid_argument); + + auto cells_exhausted = options<2>({1, 1}, {4, 4}); + cells_exhausted.budget.cell_visits = 15; + EXPECT_THROW((void)provider.cluster(shards, cells_exhausted), std::length_error); + auto output_exhausted = options<2>({1, 1}, {1, 1}); + output_exhausted.budget.output_boxes = 2; + EXPECT_THROW((void)provider.cluster(shards, output_exhausted), std::length_error); + auto shard_exhausted = options<2>({1, 1}, {4, 4}); + shard_exhausted.budget.shards = 1; + EXPECT_THROW((void)provider.cluster(shards, shard_exhausted), std::length_error); + + nd::TagMask<2> sparse_first(level, Index<2>{0, 0}, tag_budget(1, 1, 16, 16)); + nd::TagMask<2> sparse_second(level, Index<2>{1, 0}, tag_budget(1, 1, 16, 16)); + for (const Index<2> index : {Index<2>{0, 0}, Index<2>{3, 3}}) { + sparse_first.set(index); + sparse_second.set(index); + } + const std::vector> sparse_shards{sparse_first, sparse_second}; + auto recursion_exhausted = options<2>({1, 1}, {4, 4}); + recursion_exhausted.budget.recursion_nodes = 1; + EXPECT_THROW((void)provider.cluster(sparse_shards, recursion_exhausted), std::length_error); + + auto identity_exhausted = options<2>({1, 1}, {4, 4}); + identity_exhausted.budget.identity_bytes = 1; + EXPECT_THROW((void)provider.cluster(shards, identity_exhausted), std::length_error); +} diff --git a/tests/cpp/unit/mesh/test_nd_distribution.cpp b/tests/cpp/unit/mesh/test_nd_distribution.cpp new file mode 100644 index 000000000..64d554133 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_distribution.cpp @@ -0,0 +1,200 @@ +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using pops::Box; +using pops::Extent; +using pops::Index; +using pops::mesh::nd_proof::BoxArray; +using pops::mesh::nd_proof::Distribution; +using pops::mesh::nd_proof::DistributionMode; +using pops::mesh::nd_proof::MultiFab; +using pops::mesh::nd_proof::RankSpace; + +TEST(test_nd_distribution, partitioned_ownership_is_ordered_and_rank_coordinates_round_trip) { + const BoxArray<1> line(std::vector>{Box<1>{Index<1>{-3}, Index<1>{-2}}, + Box<1>{Index<1>{-1}, Index<1>{0}}, + Box<1>{Index<1>{1}, Index<1>{3}}}); + const RankSpace<1> line_ranks{Index<1>{-4}, Extent<1>{3}}; + const auto line_distribution = + Distribution<1>::partitioned(line, line_ranks, {Index<1>{-4}, Index<1>{-2}, Index<1>{-4}}); + EXPECT_EQ(line_distribution.owner(0), Index<1>{-4}); + EXPECT_EQ(line_distribution.owner(1), Index<1>{-2}); + EXPECT_EQ(line_distribution.local_box_indices(Index<1>{-4}), (std::vector{0, 2})); + EXPECT_TRUE(line_distribution.is_local(2, Index<1>{-4})); + EXPECT_FALSE(line_distribution.is_local(1, Index<1>{-4})); + + const BoxArray<2> plane(std::vector>{Box<2>{Index<2>{0, 0}, Index<2>{0, 0}}, + Box<2>{Index<2>{1, 0}, Index<2>{1, 0}}}); + const RankSpace<2> plane_ranks{Index<2>{-1, 7}, Extent<2>{2, 3}}; + const auto plane_distribution = + Distribution<2>::partitioned(plane, plane_ranks, {Index<2>{-1, 7}, Index<2>{0, 9}}); + EXPECT_EQ(plane_distribution.owner(1), (Index<2>{0, 9})); + + const BoxArray<3> volume(std::vector>{Box<3>{Index<3>{0, 0, 0}, Index<3>{0, 0, 0}}, + Box<3>{Index<3>{1, 0, 0}, Index<3>{1, 0, 0}}}); + const RankSpace<3> volume_ranks{Index<3>{3, -2, 5}, Extent<3>{2, 1, 3}}; + const auto volume_distribution = + Distribution<3>::partitioned(volume, volume_ranks, {Index<3>{4, -2, 7}, Index<3>{3, -2, 5}}); + EXPECT_EQ(volume_distribution.local_box_indices(Index<3>{3, -2, 5}), + (std::vector{1})); + + EXPECT_TRUE( + line_distribution == + Distribution<1>::partitioned(line, line_ranks, {Index<1>{-4}, Index<1>{-2}, Index<1>{-4}})); + EXPECT_FALSE( + line_distribution == + Distribution<1>::partitioned(line, line_ranks, {Index<1>{-2}, Index<1>{-2}, Index<1>{-4}})); +} + +TEST(test_nd_distribution, replicated_layouts_store_no_fake_owner_and_are_local_everywhere) { + const BoxArray<2> boxes = + BoxArray<2>::from_domain(Box<2>{Index<2>{-3, 4}, Index<2>{0, 7}}, std::array{2, 2}); + const RankSpace<2> ranks{Index<2>{4, -3}, Extent<2>{2, 3}}; + const auto distribution = Distribution<2>::replicated(boxes, ranks); + + EXPECT_EQ(distribution.mode(), DistributionMode::replicated); + EXPECT_TRUE(distribution.replicated()); + EXPECT_THROW((void)distribution.owner(0), std::logic_error); + for (std::size_t global = 0; global < boxes.size(); ++global) { + EXPECT_TRUE(distribution.is_local(global, Index<2>{4, -3})); + EXPECT_TRUE(distribution.is_local(global, Index<2>{5, -1})); + } + EXPECT_EQ(distribution.local_box_indices(Index<2>{5, -2}), + (std::vector{0, 1, 2, 3})); +} + +TEST(test_nd_distribution, distribution_rejects_invalid_counts_owners_rank_spaces_and_modes) { + const BoxArray<1> boxes( + std::vector>{Box<1>{Index<1>{0}, Index<1>{0}}, Box<1>{Index<1>{1}, Index<1>{1}}}); + const RankSpace<1> ranks{Index<1>{3}, Extent<1>{2}}; + EXPECT_THROW((void)Distribution<1>::partitioned(boxes, ranks, {Index<1>{3}}), + std::invalid_argument); + EXPECT_THROW((void)Distribution<1>::partitioned(boxes, ranks, {Index<1>{3}, Index<1>{5}}), + std::out_of_range); + EXPECT_THROW((void)Distribution<1>(boxes, ranks, DistributionMode::replicated, {Index<1>{3}}), + std::invalid_argument); + EXPECT_THROW((void)Distribution<1>(boxes, ranks, static_cast(77), + {Index<1>{3}, Index<1>{4}}), + std::invalid_argument); + EXPECT_THROW((void)Distribution<1>::replicated(boxes, RankSpace<1>{Index<1>{0}, Extent<1>{0}}), + std::invalid_argument); + const auto distribution = Distribution<1>::partitioned(boxes, ranks, {Index<1>{3}, Index<1>{4}}); + EXPECT_THROW((void)distribution.is_local(2, Index<1>{3}), std::out_of_range); + EXPECT_THROW((void)distribution.is_local(0, Index<1>{2}), std::out_of_range); + EXPECT_THROW((void)distribution.local_box_indices(Index<1>{2}), std::out_of_range); +} + +TEST(test_nd_distribution, + multifab_allocates_only_ordered_partitioned_boxes_and_refuses_remote_access) { + const BoxArray<2> boxes = + BoxArray<2>::from_domain(Box<2>{Index<2>{-2, 3}, Index<2>{1, 6}}, std::array{2, 2}); + const RankSpace<2> ranks{Index<2>{10, -2}, Extent<2>{2, 2}}; + const Index<2> first_rank{10, -2}; + const auto distribution = Distribution<2>::partitioned( + boxes, ranks, {first_rank, Index<2>{11, -2}, first_rank, Index<2>{11, -1}}); + MultiFab<2> fields(boxes, distribution, first_rank, /*ncomp=*/2, Extent<2>{1, 2}); + + EXPECT_EQ(fields.local_global_indices(), (std::vector{0, 2})); + EXPECT_EQ(fields.local_size(), 2U); + EXPECT_TRUE(fields.contains_local(0)); + EXPECT_FALSE(fields.contains_local(1)); + EXPECT_EQ(fields.fab(0).ghosts(), (Extent<2>{1, 2})); + EXPECT_EQ(fields.fab(0).size(), 48U); + EXPECT_THROW((void)fields.fab(1), std::out_of_range); + EXPECT_THROW((void)MultiFab<2>(boxes, distribution, Index<2>{12, -2}, 1, Extent<2>{}), + std::out_of_range); + + fields.fab(0).set_val(3.5); + MultiFab<2> copy = fields; + EXPECT_NE(copy.fab(0).storage().data(), fields.fab(0).storage().data()); + copy.fab(0).set_val(-2.0); + auto source = fields.fab(0).create_host_mirror(); + auto copied = copy.fab(0).create_host_mirror(); + fields.fab(0).copy_to_host(source); + copy.fab(0).copy_to_host(copied); + EXPECT_DOUBLE_EQ(source(0), 3.5); + EXPECT_DOUBLE_EQ(copied(0), -2.0); + + MultiFab<2> moved(std::move(fields)); + EXPECT_EQ(fields.local_size(), 0U); + EXPECT_TRUE(fields.layout().empty()); + EXPECT_EQ(moved.local_global_indices(), (std::vector{0, 2})); +} + +TEST(test_nd_distribution, + multifab_replicates_all_boxes_and_supports_empty_and_memory_space_instantiation) { + const BoxArray<1> boxes( + std::vector>{Box<1>{Index<1>{0}, Index<1>{1}}, Box<1>{Index<1>{2}, Index<1>{4}}}); + const RankSpace<1> ranks{Index<1>{-1}, Extent<1>{3}}; + const auto replicated = Distribution<1>::replicated(boxes, ranks); + MultiFab<1> defaults(boxes, replicated, Index<1>{0}, /*ncomp=*/1, Extent<1>{1}); + MultiFab<1, Kokkos::HostSpace> hosts(boxes, replicated, Index<1>{1}, /*ncomp=*/1, Extent<1>{}); + EXPECT_EQ(defaults.local_global_indices(), (std::vector{0, 1})); + EXPECT_EQ(hosts.local_global_indices(), (std::vector{0, 1})); + static_assert(std::is_same_v::fab_type::memory_space, + typename Kokkos::DefaultExecutionSpace::memory_space>); + + const BoxArray<3> empty{}; + const RankSpace<3> empty_layout_ranks{Index<3>{1, 2, 3}, Extent<3>{1, 1, 1}}; + const auto empty_distribution = Distribution<3>::partitioned(empty, empty_layout_ranks, {}); + MultiFab<3, Kokkos::HostSpace> empty_fields(empty, empty_distribution, Index<3>{1, 2, 3}, 1, + Extent<3>{}); + EXPECT_EQ(empty_fields.local_size(), 0U); + EXPECT_THROW((void)empty_fields.fab(0), std::out_of_range); +} + +TEST(test_nd_distribution, + multifab_authenticates_ordered_layout_identity_for_all_distribution_modes) { + const BoxArray<1> layout( + std::vector>{Box<1>{Index<1>{0}, Index<1>{1}}, Box<1>{Index<1>{2}, Index<1>{3}}}); + const BoxArray<1> reordered(std::vector>{layout[1], layout[0]}); + const BoxArray<1> different( + std::vector>{Box<1>{Index<1>{0}, Index<1>{0}}, Box<1>{Index<1>{1}, Index<1>{3}}}); + const RankSpace<1> ranks{Index<1>{0}, Extent<1>{2}}; + const auto partitioned = Distribution<1>::partitioned(layout, ranks, {Index<1>{0}, Index<1>{1}}); + const auto replicated = Distribution<1>::replicated(layout, ranks); + EXPECT_THROW((void)MultiFab<1>(reordered, partitioned, Index<1>{0}, 1, Extent<1>{1}), + std::invalid_argument); + EXPECT_THROW((void)MultiFab<1>(different, replicated, Index<1>{0}, 1, Extent<1>{1}), + std::invalid_argument); +} + +TEST(test_nd_distribution, + multifab_assignment_and_nonempty_1d_3d_partitioned_layouts_remain_local) { + const RankSpace<1> ranks1{Index<1>{3}, Extent<1>{2}}; + const BoxArray<1> line = BoxArray<1>::from_domain(Box<1>{Index<1>{-2}, Index<1>{3}}, {2}); + const auto dist1 = + Distribution<1>::partitioned(line, ranks1, {Index<1>{3}, Index<1>{4}, Index<1>{3}}); + MultiFab<1> first(line, dist1, Index<1>{3}, 2, Extent<1>{2}); + MultiFab<1> assigned; + assigned = first; + EXPECT_EQ(assigned.local_global_indices(), (std::vector{0, 2})); + EXPECT_NE(assigned.fab(0).storage().data(), first.fab(0).storage().data()); + MultiFab<1> move_assigned; + move_assigned = std::move(assigned); + EXPECT_EQ(assigned.local_size(), 0U); + EXPECT_EQ(move_assigned.fab(0).ghosts(), Extent<1>{2}); + + const BoxArray<3> volume = BoxArray<3>::from_domain(Box<3>{Index<3>{-1, 2, 4}, Index<3>{2, 3, 5}}, + std::array{2, 1, 2}); + const RankSpace<3> ranks3{Index<3>{1, -1, 7}, Extent<3>{2, 1, 1}}; + std::vector> owners(volume.size(), Index<3>{2, -1, 7}); + owners[0] = Index<3>{1, -1, 7}; + const auto dist3 = Distribution<3>::partitioned(volume, ranks3, owners); + MultiFab<3> three_dimensional(volume, dist3, Index<3>{1, -1, 7}, 1, Extent<3>{1, 2, 1}); + ASSERT_EQ(three_dimensional.local_global_indices(), (std::vector{0})); + EXPECT_EQ(three_dimensional.fab(0).ghosts(), (Extent<3>{1, 2, 1})); + EXPECT_EQ(three_dimensional.fab(0).size(), 80U); +} diff --git a/tests/cpp/unit/mesh/test_nd_execution.cpp b/tests/cpp/unit/mesh/test_nd_execution.cpp new file mode 100644 index 000000000..30e12aa75 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_execution.cpp @@ -0,0 +1,111 @@ +#include + +#include +#include + +#include + +#include +#include + +namespace { + +template +pops::Box sample_box() { + if constexpr (Dim == 1) { + return pops::Box<1>{pops::Index<1>{-2}, pops::Index<1>{2}}; + } else if constexpr (Dim == 2) { + return pops::Box<2>{pops::Index<2>{-2, 3}, pops::Index<2>{1, 5}}; + } else { + return pops::Box<3>{pops::Index<3>{-1, 2, 7}, pops::Index<3>{1, 4, 8}}; + } +} + +template +struct SetCellValue { + pops::FieldView values; + pops::Real value; + + POPS_HD void operator()(const pops::CellIndex& index) const { values(index) = value; } +}; + +template +struct ReadCellValue { + pops::FieldView values; + + POPS_HD pops::Real operator()(const pops::CellIndex& index) const { + return values(index); + } +}; + +template +struct SetFaceValue { + pops::FieldView values; + + POPS_HD void operator()(const pops::FaceIndex& face) const { + static_assert(pops::FaceIndex::normal_axis == Axis); + values(face.coordinate) = static_cast(Axis + 1); + } +}; + +template +void expect_cell_and_product_execution() { + const pops::Box cells = sample_box(); + pops::Fab field(cells, 1); + Kokkos::DefaultExecutionSpace execution; + + pops::for_each_cell(execution, cells, SetCellValue{field.view(), pops::Real(2)}); + EXPECT_EQ(pops::for_each_cell_reduce_sum( + execution, cells, + ReadCellValue{static_cast&>(field).view()}), + static_cast(2 * cells.numPts())); + + pops::for_each_product(cells, SetCellValue{field.view(), pops::Real(3)}); + EXPECT_EQ(pops::for_each_product_reduce_sum( + cells, ReadCellValue{static_cast&>(field).view()}), + static_cast(3 * cells.numPts())); +} + +template +void expect_face_execution() { + const pops::Box cells = sample_box(); + const pops::Box faces = pops::face_box(cells); + pops::Fab field(faces, 1); + Kokkos::DefaultExecutionSpace execution; + + pops::for_each_face(execution, cells, SetFaceValue{field.view()}); + const pops::Real total = pops::for_each_cell_reduce_sum( + execution, faces, ReadCellValue{static_cast&>(field).view()}); + EXPECT_EQ(total, static_cast((Axis + 1) * faces.numPts())); + EXPECT_EQ(faces.length(Axis), cells.length(Axis) + 1); + for (int tangent = 0; tangent < Dim; ++tangent) + if (tangent != Axis) + EXPECT_EQ(faces.length(tangent), cells.length(tangent)); +} + +} // namespace + +TEST(test_nd_execution, cell_and_product_facades_share_static_1d_2d_3d_policies) { + static_assert(std::is_same_v, pops::Index<2>>); + static_assert(std::is_trivially_copyable_v>); + + expect_cell_and_product_execution<1>(); + expect_cell_and_product_execution<2>(); + expect_cell_and_product_execution<3>(); +} + +TEST(test_nd_execution, face_axis_is_compile_time_and_each_dimension_has_exact_face_extent) { + expect_face_execution<1, 0>(); + expect_face_execution<2, 0>(); + expect_face_execution<2, 1>(); + expect_face_execution<3, 0>(); + expect_face_execution<3, 1>(); + expect_face_execution<3, 2>(); +} + +TEST(test_nd_execution, empty_and_non_addressable_face_domains_fail_deterministically) { + EXPECT_TRUE(pops::face_box<0>(pops::Box<1>{}).empty()); + const pops::Box<1> overflow{pops::Index<1>{0}, + pops::Index<1>{std::numeric_limits::max()}}; + EXPECT_THROW((void)pops::face_box<0>(overflow), std::overflow_error); +} diff --git a/tests/cpp/unit/mesh/test_nd_hierarchy_plan.cpp b/tests/cpp/unit/mesh/test_nd_hierarchy_plan.cpp new file mode 100644 index 000000000..a8467620d --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_hierarchy_plan.cpp @@ -0,0 +1,231 @@ +#include + +#include + +#include +#include +#include +#include +#include + +namespace nd = pops::amr::hierarchy::nd; +namespace mesh = pops::mesh; + +using pops::Box; +using pops::Extent; +using pops::Index; + +namespace { + +constexpr mesh::BoxArrayValidationBudget kLayoutBudget{64, 2016}; +constexpr nd::HierarchyValidationBudget kHierarchyBudget{8, 4096}; + +template +nd::LevelLayout make_level(int level, const Box& domain, + const mesh::BoxArray& patches, + const mesh::RankSpace& ranks, + const std::vector>& owners, + const nd::RefinementRatio& ratio) { + return nd::LevelLayout(level, domain, patches, + mesh::Distribution::partitioned(patches, ranks, owners), ratio, + kLayoutBudget); +} + +} // namespace + +TEST(test_nd_hierarchy_plan, one_dimensional_nonzero_origin_and_ratio_are_exact) { + const Box<1> coarse_domain{Index<1>{-3}, Index<1>{4}}; + const mesh::BoxArray<1> coarse_patches = + mesh::BoxArray<1>::from_domain(coarse_domain, std::array{4}); + const mesh::RankSpace<1> ranks{Index<1>{-2}, Extent<1>{2}}; + const auto coarse = make_level<1>(0, coarse_domain, coarse_patches, ranks, + {Index<1>{-2}, Index<1>{-1}}, nd::RefinementRatio<1>{1}); + + const nd::RefinementRatio<1> ratio{3}; + const Box<1> fine_domain = nd::refine_box(coarse_domain, ratio); + const Box<1> fine_patch = nd::refine_box(Box<1>{Index<1>{-3}, Index<1>{-1}}, ratio); + const mesh::BoxArray<1> fine_patches(std::vector>{fine_patch}); + const auto fine = make_level<1>(1, fine_domain, fine_patches, ranks, {Index<1>{-1}}, ratio); + + const nd::HierarchyPlan<1> plan({coarse, fine}, kHierarchyBudget); + ASSERT_EQ(plan.num_levels(), 2U); + EXPECT_EQ(plan.level(1).domain(), (Box<1>{Index<1>{-9}, Index<1>{14}})); + EXPECT_EQ(nd::coarsen_box(fine_patch, ratio), (Box<1>{Index<1>{-3}, Index<1>{-1}})); + EXPECT_EQ(plan.exact_identity(), + nd::HierarchyPlan<1>({coarse, fine}, kHierarchyBudget).exact_identity()); +} + +TEST(test_nd_hierarchy_plan, anisotropic_two_and_three_dimensional_levels_are_validated) { + const Box<2> plane_domain{Index<2>{-2, 4}, Index<2>{1, 7}}; + const mesh::BoxArray<2> plane_patches = + mesh::BoxArray<2>::from_domain(plane_domain, std::array{2, 2}); + const mesh::RankSpace<2> plane_ranks{Index<2>{5, -2}, Extent<2>{2, 1}}; + const auto plane_coarse = + make_level<2>(0, plane_domain, plane_patches, plane_ranks, + {Index<2>{5, -2}, Index<2>{6, -2}, Index<2>{5, -2}, Index<2>{6, -2}}, + nd::RefinementRatio<2>{1, 1}); + const nd::RefinementRatio<2> plane_ratio{2, 3}; + const Box<2> plane_fine_patch = + nd::refine_box(Box<2>{Index<2>{-1, 5}, Index<2>{0, 6}}, plane_ratio); + const mesh::BoxArray<2> plane_fine_patches(std::vector>{plane_fine_patch}); + const auto plane_fine = + make_level<2>(1, nd::refine_box(plane_domain, plane_ratio), plane_fine_patches, plane_ranks, + {Index<2>{6, -2}}, plane_ratio); + const nd::HierarchyPlan<2> plane({plane_coarse, plane_fine}, kHierarchyBudget); + EXPECT_EQ(plane.level(1).domain(), (Box<2>{Index<2>{-4, 12}, Index<2>{3, 23}})); + EXPECT_EQ(plane_fine_patch, (Box<2>{Index<2>{-2, 15}, Index<2>{1, 20}})); + + const Box<3> volume_domain{Index<3>{-2, 3, -1}, Index<3>{1, 4, 1}}; + const mesh::BoxArray<3> volume_patches = + mesh::BoxArray<3>::from_domain(volume_domain, std::array{2, 2, 3}); + const mesh::RankSpace<3> volume_ranks{Index<3>{7, -3, 2}, Extent<3>{2, 1, 1}}; + const auto volume_coarse = + make_level<3>(0, volume_domain, volume_patches, volume_ranks, + {Index<3>{7, -3, 2}, Index<3>{8, -3, 2}}, nd::RefinementRatio<3>{1, 1, 1}); + const nd::RefinementRatio<3> volume_ratio{2, 1, 3}; + const Box<3> volume_fine_patch = + nd::refine_box(Box<3>{Index<3>{-2, 3, 0}, Index<3>{-1, 4, 1}}, volume_ratio); + const mesh::BoxArray<3> volume_fine_patches(std::vector>{volume_fine_patch}); + const auto volume_fine = + make_level<3>(1, nd::refine_box(volume_domain, volume_ratio), volume_fine_patches, + volume_ranks, {Index<3>{7, -3, 2}}, volume_ratio); + const nd::HierarchyPlan<3> volume({volume_coarse, volume_fine}, kHierarchyBudget); + EXPECT_EQ(volume.level(1).domain(), (Box<3>{Index<3>{-4, 3, -3}, Index<3>{3, 4, 5}})); + EXPECT_EQ(nd::coarsen_box(volume_fine_patch, volume_ratio), + (Box<3>{Index<3>{-2, 3, 0}, Index<3>{-1, 4, 1}})); +} + +TEST(test_nd_hierarchy_plan, layout_and_hierarchy_refuse_invalid_contracts) { + const Box<1> domain{Index<1>{0}, Index<1>{3}}; + const mesh::BoxArray<1> full(std::vector>{domain}); + const mesh::RankSpace<1> ranks{Index<1>{0}, Extent<1>{1}}; + const auto distribution = mesh::Distribution<1>::partitioned(full, ranks, {Index<1>{0}}); + + EXPECT_THROW((void)nd::LevelLayout<1>(0, domain, full, distribution, nd::RefinementRatio<1>{2}, + kLayoutBudget), + std::invalid_argument); + EXPECT_THROW((void)nd::LevelLayout<1>(1, domain, full, distribution, nd::RefinementRatio<1>{1}, + kLayoutBudget), + std::invalid_argument); + EXPECT_THROW( + (void)nd::LevelLayout<1>( + 0, domain, mesh::BoxArray<1>(std::vector>{Box<1>{Index<1>{0}, Index<1>{2}}}), + distribution, nd::RefinementRatio<1>{1}, kLayoutBudget), + std::invalid_argument); + EXPECT_THROW((void)nd::LevelLayout<1>( + 0, domain, full, + mesh::Distribution<1>::partitioned( + mesh::BoxArray<1>(std::vector>{Box<1>{Index<1>{0}, Index<1>{1}}, + Box<1>{Index<1>{2}, Index<1>{3}}}), + ranks, {Index<1>{0}, Index<1>{0}}), + nd::RefinementRatio<1>{1}, kLayoutBudget), + std::invalid_argument); + EXPECT_THROW((void)nd::LevelLayout<1>(0, domain, full, distribution, nd::RefinementRatio<1>{1}, + mesh::BoxArrayValidationBudget{0, 0}), + std::length_error); + + const auto coarse = + make_level<1>(0, domain, full, ranks, {Index<1>{0}}, nd::RefinementRatio<1>{1}); + const Box<1> fine_domain = nd::refine_box(domain, nd::RefinementRatio<1>{2}); + const mesh::BoxArray<1> unaligned(std::vector>{Box<1>{Index<1>{1}, Index<1>{4}}}); + const auto unaligned_level = + make_level<1>(1, fine_domain, unaligned, ranks, {Index<1>{0}}, nd::RefinementRatio<1>{2}); + EXPECT_THROW((void)nd::HierarchyPlan<1>({coarse, unaligned_level}, kHierarchyBudget), + std::invalid_argument); + + const mesh::RankSpace<1> changed_ranks{Index<1>{1}, Extent<1>{1}}; + const mesh::BoxArray<1> aligned(std::vector>{ + nd::refine_box(Box<1>{Index<1>{0}, Index<1>{1}}, nd::RefinementRatio<1>{2})}); + const auto changed_space = make_level<1>(1, fine_domain, aligned, changed_ranks, {Index<1>{1}}, + nd::RefinementRatio<1>{2}); + EXPECT_THROW((void)nd::HierarchyPlan<1>({coarse, changed_space}, kHierarchyBudget), + std::invalid_argument); + EXPECT_THROW((void)nd::HierarchyPlan<1>({coarse}, nd::HierarchyValidationBudget{0, 0}), + std::length_error); + EXPECT_THROW( + (void)nd::HierarchyPlan<1>({coarse, changed_space}, nd::HierarchyValidationBudget{2, 0}), + std::invalid_argument); + EXPECT_THROW((void)nd::refine_box(Box<1>{Index<1>{std::numeric_limits::max()}, + Index<1>{std::numeric_limits::max()}}, + nd::RefinementRatio<1>{2}), + std::overflow_error); + EXPECT_THROW((void)nd::refine_box(Box<1>{Index<1>{std::numeric_limits::min()}, + Index<1>{std::numeric_limits::min()}}, + nd::RefinementRatio<1>{2}), + std::overflow_error); +} + +TEST(test_nd_hierarchy_plan, sparse_parent_coverage_and_nonconsecutive_levels_fail_closed) { + const Box<1> coarse_domain{Index<1>{0}, Index<1>{3}}; + const mesh::BoxArray<1> coarse_patches(std::vector>{coarse_domain}); + const mesh::RankSpace<1> ranks{Index<1>{0}, Extent<1>{1}}; + const auto coarse = make_level<1>(0, coarse_domain, coarse_patches, ranks, {Index<1>{0}}, + nd::RefinementRatio<1>{1}); + + const Box<1> level_one_domain = nd::refine_box(coarse_domain, nd::RefinementRatio<1>{2}); + const mesh::BoxArray<1> sparse_one(std::vector>{Box<1>{Index<1>{0}, Index<1>{3}}}); + const auto level_one = make_level<1>(1, level_one_domain, sparse_one, ranks, {Index<1>{0}}, + nd::RefinementRatio<1>{2}); + const Box<1> level_two_domain = nd::refine_box(level_one_domain, nd::RefinementRatio<1>{2}); + const mesh::BoxArray<1> uncovered(std::vector>{Box<1>{Index<1>{8}, Index<1>{11}}}); + const auto level_two = make_level<1>(2, level_two_domain, uncovered, ranks, {Index<1>{0}}, + nd::RefinementRatio<1>{2}); + EXPECT_THROW((void)nd::HierarchyPlan<1>({coarse, level_one, level_two}, kHierarchyBudget), + std::invalid_argument); + + const auto mislabeled = make_level<1>(2, level_one_domain, sparse_one, ranks, {Index<1>{0}}, + nd::RefinementRatio<1>{2}); + EXPECT_THROW((void)nd::HierarchyPlan<1>({coarse, mislabeled}, kHierarchyBudget), + std::invalid_argument); + EXPECT_THROW((void)nd::HierarchyPlan<1>({coarse, level_one}, nd::HierarchyValidationBudget{2, 0}), + std::length_error); +} + +TEST(test_nd_hierarchy_plan, exact_identity_tracks_order_ownership_and_replacement) { + const Box<1> domain{Index<1>{-2}, Index<1>{1}}; + const mesh::BoxArray<1> patches = mesh::BoxArray<1>::from_domain(domain, std::array{2}); + const mesh::RankSpace<1> ranks{Index<1>{4}, Extent<1>{2}}; + const auto left_owned = make_level<1>(0, domain, patches, ranks, {Index<1>{4}, Index<1>{5}}, + nd::RefinementRatio<1>{1}); + const auto right_owned = make_level<1>(0, domain, patches, ranks, {Index<1>{5}, Index<1>{4}}, + nd::RefinementRatio<1>{1}); + const nd::HierarchyPlan<1> left_plan({left_owned}, kHierarchyBudget); + const nd::HierarchyPlan<1> right_plan({right_owned}, kHierarchyBudget); + EXPECT_NE(left_plan.exact_identity(), right_plan.exact_identity()); + const mesh::BoxArray<1> reordered_patches(std::vector>{patches[1], patches[0]}); + const auto reordered = make_level<1>(0, domain, reordered_patches, ranks, + {Index<1>{5}, Index<1>{4}}, nd::RefinementRatio<1>{1}); + const nd::HierarchyPlan<1> reordered_plan({reordered}, kHierarchyBudget); + EXPECT_NE(left_plan.exact_identity(), reordered_plan.exact_identity()); + + const nd::HierarchyValidationBudget append_forbidden{1, 4096}; + const nd::HierarchyPlan<1> limited_plan({left_owned}, append_forbidden); + EXPECT_NE(left_plan.exact_identity(), limited_plan.exact_identity()); + + const Box<1> fine_domain = nd::refine_box(domain, nd::RefinementRatio<1>{2}); + const mesh::BoxArray<1> fine_patches(std::vector>{ + nd::refine_box(Box<1>{Index<1>{-2}, Index<1>{-1}}, nd::RefinementRatio<1>{2})}); + const auto fine = + make_level<1>(1, fine_domain, fine_patches, ranks, {Index<1>{4}}, nd::RefinementRatio<1>{2}); + const nd::HierarchyPlan<1> appended = left_plan.with_level(fine); + ASSERT_EQ(appended.num_levels(), 2U); + EXPECT_EQ(appended.level(0).exact_identity(), left_owned.exact_identity()); + EXPECT_NE(appended.exact_identity(), left_plan.exact_identity()); + EXPECT_THROW((void)limited_plan.with_level(fine), std::length_error); + EXPECT_THROW((void)left_plan.level(1), std::out_of_range); + + const Box<1> finer_domain = nd::refine_box(fine_domain, nd::RefinementRatio<1>{2}); + const mesh::BoxArray<1> finer_patches( + std::vector>{nd::refine_box(fine_patches[0], nd::RefinementRatio<1>{2})}); + const auto finer = make_level<1>(2, finer_domain, finer_patches, ranks, {Index<1>{4}}, + nd::RefinementRatio<1>{2}); + const nd::HierarchyPlan<1> three_levels({left_owned, fine, finer}, kHierarchyBudget); + + const mesh::BoxArray<1> replacement_patches(std::vector>{ + nd::refine_box(Box<1>{Index<1>{0}, Index<1>{1}}, nd::RefinementRatio<1>{2})}); + const auto replacement = make_level<1>(1, fine_domain, replacement_patches, ranks, {Index<1>{5}}, + nd::RefinementRatio<1>{2}); + const nd::HierarchyPlan<1> truncated = three_levels.with_level(replacement); + ASSERT_EQ(truncated.num_levels(), 2U); + EXPECT_EQ(truncated.level(1).exact_identity(), replacement.exact_identity()); +} diff --git a/tests/cpp/unit/mesh/test_nd_layout.cpp b/tests/cpp/unit/mesh/test_nd_layout.cpp new file mode 100644 index 000000000..0797050d8 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_layout.cpp @@ -0,0 +1,302 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using pops::Box; +using pops::Extent; +using pops::Index; +using pops::mesh::nd_proof::BoxArray; +using pops::mesh::nd_proof::BoxArrayValidationBudget; +using pops::mesh::nd_proof::BoxHash; +using pops::mesh::nd_proof::BoxHashBudget; +using pops::mesh::nd_proof::BinCoordinate; +using pops::mesh::nd_proof::BinCoordinateHash; +using pops::mesh::nd_proof::ExactCellCount; +using pops::mesh::nd_proof::RankSpace; +using pops::mesh::nd_proof::suggest_bin; + +constexpr BoxHashBudget kHashBudget{128, 128, 256}; +constexpr BoxArrayValidationBudget kTilingBudget{128, 4096}; + +template +void expect_hash_superset(const BoxArray& boxes, const BoxHash& hash, + const std::vector>& queries) { + for (const Box& query : queries) { + const std::vector candidates = hash.query(query); + EXPECT_TRUE(std::is_sorted(candidates.begin(), candidates.end())); + EXPECT_EQ(std::adjacent_find(candidates.begin(), candidates.end()), candidates.end()); + for (std::size_t index = 0; index < boxes.size(); ++index) + if (!query.intersect(boxes[index]).empty()) + EXPECT_NE(std::find(candidates.begin(), candidates.end(), index), candidates.end()); + } +} + +TEST(test_nd_layout, rank_spaces_support_anisotropic_1d_2d_and_3d_extents) { + const RankSpace<1> line{Index<1>{-4}, Extent<1>{7}}; + EXPECT_EQ(line.size(), 7U); + EXPECT_TRUE(line.contains(Index<1>{-4})); + EXPECT_TRUE(line.contains(Index<1>{2})); + EXPECT_FALSE(line.contains(Index<1>{3})); + + const RankSpace<2> plane{Index<2>{-2, 5}, Extent<2>{3, 4}}; + EXPECT_EQ(plane.size(), 12U); + EXPECT_TRUE(plane.contains(Index<2>{0, 8})); + EXPECT_FALSE(plane.contains(Index<2>{1, 8})); + + const RankSpace<3> volume{Index<3>{3, -1, 9}, Extent<3>{2, 3, 4}}; + EXPECT_EQ(volume.size(), 24U); + EXPECT_TRUE(volume.contains(Index<3>{4, 1, 12})); + EXPECT_FALSE(volume.contains(Index<3>{4, 2, 12})); +} + +TEST(test_nd_layout, axis_zero_is_contiguous_and_round_trips_nonzero_origin) { + const RankSpace<3> space{Index<3>{-3, 10, 7}, Extent<3>{4, 2, 3}}; + + EXPECT_EQ(space.linear_rank(Index<3>{-3, 10, 7}), 0U); + EXPECT_EQ(space.linear_rank(Index<3>{0, 10, 7}), 3U); + EXPECT_EQ(space.linear_rank(Index<3>{-3, 11, 7}), 4U); + EXPECT_EQ(space.linear_rank(Index<3>{-3, 10, 8}), 8U); + + for (std::size_t rank = 0; rank < space.size(); ++rank) + EXPECT_EQ(space.linear_rank(space.coord_from_linear(rank)), rank); +} + +TEST(test_nd_layout, empty_rank_spaces_are_valid_but_have_no_coordinates) { + const RankSpace<2> empty{Index<2>{7, -3}, Extent<2>{0, 5}}; + EXPECT_TRUE(empty.empty()); + EXPECT_EQ(empty.size(), 0U); + EXPECT_FALSE(empty.contains(Index<2>{7, -3})); + EXPECT_THROW((void)empty.linear_rank(Index<2>{7, -3}), std::out_of_range); + EXPECT_THROW((void)empty.coord_from_linear(0), std::out_of_range); +} + +TEST(test_nd_layout, invalid_extents_coordinates_and_ranks_fail_deterministically) { + EXPECT_THROW((void)(RankSpace<1>{Index<1>{0}, Extent<1>{-1}}), std::invalid_argument); + EXPECT_THROW((void)(RankSpace<2>{Index<2>{0, 0}, Extent<2>{0, -1}}), std::invalid_argument); + + const RankSpace<2> space{Index<2>{4, -2}, Extent<2>{2, 3}}; + EXPECT_THROW((void)space.linear_rank(Index<2>{3, -2}), std::out_of_range); + EXPECT_THROW((void)space.linear_rank(Index<2>{4, 1}), std::out_of_range); + EXPECT_THROW((void)space.coord_from_linear(space.size()), std::out_of_range); +} + +TEST(test_nd_layout, coordinate_and_size_overflows_are_rejected_before_narrowing) { + constexpr std::int64_t full_axis = std::int64_t{1} << 32; + constexpr int min = std::numeric_limits::min(); + + EXPECT_THROW((void)(RankSpace<1>{Index<1>{0}, Extent<1>{full_axis}}), std::overflow_error); + EXPECT_THROW((void)(RankSpace<3>{Index<3>{min, min, 0}, Extent<3>{full_axis, full_axis, 1}}), + std::overflow_error); +} + +TEST(test_nd_layout, rank_space_extreme_extent_checks_before_signed_addition) { + constexpr int minimum = std::numeric_limits::min(); + EXPECT_THROW( + (void)(RankSpace<1>{Index<1>{minimum}, Extent<1>{std::numeric_limits::max()}}), + std::overflow_error); + const RankSpace<2> exact_boundary{Index<2>{minimum, 0}, Extent<2>{std::int64_t{1} << 32, 1}}; + EXPECT_EQ(exact_boundary.size(), std::size_t{1} << 32); +} + +TEST(test_nd_layout, box_array_balances_negative_anisotropic_tiles_in_axis_zero_order) { + const Box<1> line_domain{Index<1>{-5}, Index<1>{4}}; + const BoxArray<1> line = BoxArray<1>::from_domain(line_domain, std::array{4}); + ASSERT_EQ(line.size(), 3U); + const Box<1> first_line{Index<1>{-5}, Index<1>{-2}}; + const Box<1> second_line{Index<1>{-1}, Index<1>{1}}; + const Box<1> third_line{Index<1>{2}, Index<1>{4}}; + EXPECT_EQ(line[0], first_line); + EXPECT_EQ(line[1], second_line); + EXPECT_EQ(line[2], third_line); + EXPECT_TRUE(line.tiles_exactly(line_domain, kTilingBudget)); + + const Box<2> plane_domain{Index<2>{-3, 5}, Index<2>{4, 10}}; + const BoxArray<2> plane = BoxArray<2>::from_domain(plane_domain, std::array{3, 4}); + ASSERT_EQ(plane.size(), 6U); + const Box<2> first_plane{Index<2>{-3, 5}, Index<2>{-1, 7}}; + const Box<2> second_plane{Index<2>{0, 5}, Index<2>{2, 7}}; + const Box<2> fourth_plane{Index<2>{-3, 8}, Index<2>{-1, 10}}; + EXPECT_EQ(plane[0], first_plane); + EXPECT_EQ(plane[1], second_plane); + EXPECT_EQ(plane[3], fourth_plane); + EXPECT_EQ(plane.bounding_box(), plane_domain); + EXPECT_EQ(plane.exact_cell_count(), ExactCellCount::from_uint64(48)); + EXPECT_TRUE(plane.tiles_exactly(plane_domain, kTilingBudget)); + + const Box<3> volume_domain{Index<3>{-2, 1, 4}, Index<3>{2, 3, 6}}; + const BoxArray<3> volume = BoxArray<3>::from_domain(volume_domain, std::array{2, 2, 2}); + ASSERT_EQ(volume.size(), 12U); + const Box<3> first_volume{Index<3>{-2, 1, 4}, Index<3>{-1, 2, 5}}; + const Box<3> second_volume{Index<3>{0, 1, 4}, Index<3>{1, 2, 5}}; + const Box<3> fourth_volume{Index<3>{-2, 3, 4}, Index<3>{-1, 3, 5}}; + EXPECT_EQ(volume[0], first_volume); + EXPECT_EQ(volume[1], second_volume); + EXPECT_EQ(volume[3], fourth_volume); + EXPECT_EQ(volume.bounding_box(), volume_domain); + EXPECT_EQ(volume.exact_cell_count(), ExactCellCount::from_uint64(45)); + EXPECT_TRUE(volume.tiles_exactly(volume_domain, kTilingBudget)); +} + +TEST(test_nd_layout, box_array_rejects_holes_overlaps_outside_and_empty_members) { + const Box<1> line{Index<1>{0}, Index<1>{3}}; + EXPECT_FALSE(BoxArray<1>(std::vector>{Box<1>{Index<1>{0}, Index<1>{1}}, + Box<1>{Index<1>{3}, Index<1>{3}}}) + .tiles_exactly(line, kTilingBudget)); + EXPECT_FALSE(BoxArray<1>(std::vector>{Box<1>{Index<1>{0}, Index<1>{2}}, + Box<1>{Index<1>{2}, Index<1>{3}}}) + .tiles_exactly(line, kTilingBudget)); + EXPECT_FALSE(BoxArray<1>(std::vector>{Box<1>{Index<1>{0}, Index<1>{2}}, + Box<1>{Index<1>{3}, Index<1>{4}}}) + .tiles_exactly(line, kTilingBudget)); + EXPECT_FALSE(BoxArray<1>(std::vector>{Box<1>{}}).tiles_exactly(line, kTilingBudget)); + + const Box<2> plane{Index<2>{0, 0}, Index<2>{1, 1}}; + EXPECT_FALSE(BoxArray<2>(std::vector>{Box<2>{Index<2>{0, 0}, Index<2>{1, 0}}, + Box<2>{Index<2>{0, 0}, Index<2>{1, 1}}}) + .tiles_exactly(plane, kTilingBudget)); + const Box<3> volume{Index<3>{0, 0, 0}, Index<3>{1, 1, 1}}; + EXPECT_FALSE(BoxArray<3>(std::vector>{Box<3>{Index<3>{0, 0, 0}, Index<3>{1, 1, 0}}, + Box<3>{Index<3>{0, 0, 1}, Index<3>{1, 1, 2}}}) + .tiles_exactly(volume, kTilingBudget)); + + EXPECT_TRUE(BoxArray<2>{}.tiles_exactly(Box<2>{}, kTilingBudget)); + EXPECT_FALSE(BoxArray<2>(std::vector>{Box<2>{}}).tiles_exactly(Box<2>{}, kTilingBudget)); +} + +TEST(test_nd_layout, box_array_handles_full_signed_spans_without_narrowing) { + constexpr int minimum = std::numeric_limits::min(); + constexpr int maximum = std::numeric_limits::max(); + const Box<3> full{Index<3>{minimum, minimum, minimum}, Index<3>{maximum, maximum, maximum}}; + const BoxArray<3> single_full(std::vector>{full}); + + EXPECT_TRUE(single_full.tiles_exactly(full, kTilingBudget)); + EXPECT_EQ(single_full.exact_cell_count(), ExactCellCount::power_of_two(96)); + EXPECT_THROW((void)BoxArray<3>::from_domain(full, std::array{1, 1, 1}), + std::length_error); + EXPECT_THROW((void)BoxArray<1>::from_domain(Box<1>{}, std::array{0}), + std::invalid_argument); +} + +TEST(test_nd_layout, exact_cell_count_carries_across_portable_limbs) { + ExactCellCount lower = ExactCellCount::power_of_two(31); + EXPECT_TRUE(lower.add(ExactCellCount::power_of_two(31))); + EXPECT_EQ(lower, ExactCellCount::power_of_two(32)); + + ExactCellCount upper = ExactCellCount::power_of_two(63); + EXPECT_TRUE(upper.add(ExactCellCount::power_of_two(63))); + EXPECT_EQ(upper, ExactCellCount::power_of_two(64)); +} + +TEST(test_nd_layout, box_hash_uses_structural_negative_anisotropic_bins) { + const BinCoordinate<2> left{{-1, 2}}; + const BinCoordinate<2> same_left{{-1, 2}}; + const BinCoordinate<2> transposed{{2, -1}}; + std::unordered_map, int, BinCoordinateHash<2>> structural; + structural.emplace(left, 3); + structural.emplace(transposed, 7); + EXPECT_EQ(structural.size(), 2U); + EXPECT_EQ(structural.at(same_left), 3); + EXPECT_EQ(structural.at(transposed), 7); + + const BoxArray<1> line( + std::vector>{Box<1>{Index<1>{-7}, Index<1>{-3}}, Box<1>{Index<1>{-2}, Index<1>{2}}}); + const BoxHash<1> line_hash(line, std::array{3}, kHashBudget); + EXPECT_EQ(line_hash.query(Box<1>{Index<1>{-4}, Index<1>{-1}}), (std::vector{0, 1})); + + const BoxArray<2> plane(std::vector>{Box<2>{Index<2>{-7, -3}, Index<2>{-4, 1}}, + Box<2>{Index<2>{-3, -2}, Index<2>{1, 3}}, + Box<2>{Index<2>{5, -4}, Index<2>{7, -1}}}); + const BoxHash<2> plane_hash(plane, std::array{3, 2}, kHashBudget); + EXPECT_EQ(plane_hash.query(Box<2>{Index<2>{-5, -1}, Index<2>{0, 2}}), + (std::vector{0, 1})); + EXPECT_TRUE(plane_hash.query(Box<2>{}).empty()); + EXPECT_EQ(suggest_bin(plane), (std::array{5, 6})); + + const BoxArray<3> volume(std::vector>{Box<3>{Index<3>{-3, -2, -1}, Index<3>{-1, 0, 1}}, + Box<3>{Index<3>{0, -1, 0}, Index<3>{2, 1, 2}}}); + const BoxHash<3> volume_hash(volume, std::array{2, 3, 2}, kHashBudget); + EXPECT_EQ(volume_hash.query(Box<3>{Index<3>{-1, -1, 0}, Index<3>{0, 0, 1}}), + (std::vector{0, 1})); +} + +TEST(test_nd_layout, box_hash_has_no_omissions_against_bruteforce_intersections) { + const BoxArray<2> boxes(std::vector>{ + Box<2>{Index<2>{-7, -3}, Index<2>{-4, 1}}, Box<2>{Index<2>{-3, -2}, Index<2>{1, 3}}, + Box<2>{Index<2>{5, -4}, Index<2>{7, -1}}, Box<2>{Index<2>{0, 4}, Index<2>{2, 5}}}); + const BoxHash<2> hash(boxes, std::array{3, 2}, kHashBudget); + expect_hash_superset(boxes, hash, + std::vector>{Box<2>{Index<2>{-8, -4}, Index<2>{-6, -2}}, + Box<2>{Index<2>{-5, -1}, Index<2>{0, 2}}, + Box<2>{Index<2>{1, 2}, Index<2>{6, 5}}, + Box<2>{Index<2>{8, 8}, Index<2>{9, 9}}}); +} + +TEST(test_nd_layout, box_hash_refuses_invalid_and_unbounded_enumerations) { + const BoxArray<2> small(std::vector>{Box<2>{Index<2>{0, 0}, Index<2>{1, 1}}}); + EXPECT_THROW((void)(BoxHash<2>{small, std::array{0, 1}, kHashBudget}), + std::invalid_argument); + + constexpr int minimum = std::numeric_limits::min(); + constexpr int maximum = std::numeric_limits::max(); + const Box<3> full{Index<3>{minimum, minimum, minimum}, Index<3>{maximum, maximum, maximum}}; + const BoxArray<3> full_layout(std::vector>{full}); + EXPECT_THROW((void)(BoxHash<3>{full_layout, std::array{1, 1, 1}, kHashBudget}), + std::length_error); + + const BoxArray<3> one_cell(std::vector>{Box<3>{Index<3>{0, 0, 0}, Index<3>{0, 0, 0}}}); + const BoxHash<3> one_cell_hash(one_cell, std::array{1, 1, 1}, kHashBudget); + EXPECT_THROW((void)one_cell_hash.query(full), std::length_error); +} + +TEST(test_nd_layout, box_array_tiling_requires_explicit_bounded_work) { + const Box<1> domain{Index<1>{0}, Index<1>{3}}; + const BoxArray<1> boxes = BoxArray<1>::from_domain(domain, std::array{1}); + EXPECT_THROW((void)boxes.tiles_exactly(domain, BoxArrayValidationBudget{3, 6}), + std::length_error); + EXPECT_THROW((void)boxes.tiles_exactly(domain, BoxArrayValidationBudget{4, 5}), + std::length_error); + EXPECT_TRUE(boxes.tiles_exactly(domain, BoxArrayValidationBudget{4, 6})); +} + +TEST(test_nd_layout, box_hash_budgets_are_explicit_and_fail_before_work) { + const BoxArray<1> one_bin(std::vector>{Box<1>{Index<1>{0}, Index<1>{1}}}); + const BoxHashBudget exact{1, 1, 1}; + const BoxHash<1> exact_hash(one_bin, std::array{2}, exact); + EXPECT_EQ(exact_hash.query(Box<1>{Index<1>{0}, Index<1>{1}}), (std::vector{0})); + + const BoxArray<1> two_bins(std::vector>{Box<1>{Index<1>{0}, Index<1>{3}}}); + EXPECT_THROW((void)(BoxHash<1>{two_bins, std::array{2}, BoxHashBudget{1, 2, 2}}), + std::length_error); + const BoxHash<1> query_limited(two_bins, std::array{2}, BoxHashBudget{2, 1, 2}); + EXPECT_THROW((void)query_limited.query(Box<1>{Index<1>{0}, Index<1>{3}}), std::length_error); + + const BoxArray<1> same_bin( + std::vector>{Box<1>{Index<1>{0}, Index<1>{0}}, Box<1>{Index<1>{3}, Index<1>{3}}}); + const BoxHash<1> candidate_limited(same_bin, std::array{4}, BoxHashBudget{2, 1, 1}); + EXPECT_THROW((void)candidate_limited.query(Box<1>{Index<1>{0}, Index<1>{0}}), std::length_error); +} + +TEST(test_nd_layout, hash_false_positives_are_filtered_at_the_exact_intersection_boundary) { + const BoxArray<1> boxes( + std::vector>{Box<1>{Index<1>{0}, Index<1>{0}}, Box<1>{Index<1>{3}, Index<1>{3}}}); + const BoxHash<1> hash(boxes, std::array{4}, BoxHashBudget{2, 1, 2}); + const Box<1> query{Index<1>{0}, Index<1>{0}}; + const std::vector candidates = hash.query(query); + ASSERT_EQ(candidates, (std::vector{0, 1})); + std::vector exact; + for (const std::size_t index : candidates) + if (!query.intersect(boxes[index]).empty()) + exact.push_back(index); + EXPECT_EQ(exact, (std::vector{0})); +} diff --git a/tests/cpp/unit/mesh/test_nd_metric_provider.cpp b/tests/cpp/unit/mesh/test_nd_metric_provider.cpp new file mode 100644 index 000000000..a775b0433 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_metric_provider.cpp @@ -0,0 +1,288 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +using pops::Box; +using pops::CartesianCoordinateMap; +using pops::CoordinateMap; +using pops::CoordinateMapKind; +using pops::Index; +using pops::InverseMapStatus; +using pops::MetricFaceSide; +using pops::PlanarPolarCoordinateMap; +using pops::PreparedMappedMetricProvider; +using pops::PreparedMetricProvider; +using pops::Real; +using pops::RealVector; +using pops::prepare_metric_provider; + +namespace { + +constexpr Real kPi = Real(3.141592653589793238462643383279502884); + +bool close(Real left, Real right, Real tolerance = Real(2e-13)) { + return std::abs(left - right) <= tolerance * (Real(1) + std::abs(left) + std::abs(right)); +} + +template +void expect_vector_close(const RealVector& actual, const RealVector& expected, + Real tolerance = Real(2e-13)) { + for (int axis = 0; axis < Dim; ++axis) + EXPECT_TRUE(close(actual[axis], expected[axis], tolerance)) + << "axis=" << axis << " actual=" << actual[axis] << " expected=" << expected[axis]; +} + +template +void add_face_pair(const Provider& provider, const Index& index, + typename Provider::PhysicalPoint& sum) { + const auto lower = + provider.template oriented_face_area_vector(index); + const auto upper = + provider.template oriented_face_area_vector(index); + for (int component = 0; component < Provider::embedding_dimension; ++component) + sum[component] += lower[component] + upper[component]; +} + +template +typename Provider::PhysicalPoint face_closure(const Provider& provider, + const Index& index, + std::integer_sequence) { + typename Provider::PhysicalPoint sum{}; + (add_face_pair(provider, index, sum), ...); + return sum; +} + +template +typename Provider::PhysicalPoint face_closure(const Provider& provider, + const Index& index) { + return face_closure(provider, index, + std::make_integer_sequence{}); +} + +} // namespace + +static_assert(CoordinateMap<1, 1, CartesianCoordinateMap<1>>); +static_assert(CoordinateMap<2, 3, CartesianCoordinateMap<2, 3>>); +static_assert(CoordinateMap<3, 3, CartesianCoordinateMap<3>>); +static_assert(CoordinateMap<2, 2, PlanarPolarCoordinateMap>); +static_assert(PreparedMetricProvider<1, PreparedMappedMetricProvider>>); +static_assert(PreparedMetricProvider<2, PreparedMappedMetricProvider>); +static_assert( + std::is_trivially_copyable_v>>); + +TEST(test_nd_metric_provider, capabilities_and_identities_are_exact_values) { + constexpr auto cartesian = CartesianCoordinateMap<3>::capabilities(); + static_assert(cartesian.logical_dimension == 3); + static_assert(cartesian.embedding_dimension == 3); + static_assert(cartesian.kind == CoordinateMapKind::Cartesian); + static_assert(cartesian.affine && cartesian.exact_cell_measure && + cartesian.exact_oriented_face_area && cartesian.compile_time_axes && + cartesian.device_callable); + + constexpr auto polar = PlanarPolarCoordinateMap::capabilities(); + static_assert(polar.logical_dimension == 2); + static_assert(polar.embedding_dimension == 2); + static_assert(polar.kind == CoordinateMapKind::PlanarPolar); + static_assert(!polar.affine && polar.exact_cell_measure && polar.exact_oriented_face_area); + + const Box<2> domain{Index<2>{-2, 5}, Index<2>{1, 8}}; + const auto map = + CartesianCoordinateMap<2>::make(RealVector<2>{-1.0, 4.0}, RealVector<2>{2.0, 6.0}); + const auto first = prepare_metric_provider(domain, map); + const auto same = prepare_metric_provider(domain, map); + const auto moved = prepare_metric_provider( + domain, CartesianCoordinateMap<2>::make(RealVector<2>{-1.0, 4.5}, RealVector<2>{2.0, 6.0})); + const auto resized = prepare_metric_provider(Box<2>{Index<2>{-2, 5}, Index<2>{2, 8}}, map); + + EXPECT_EQ(first.identity(), same.identity()); + EXPECT_NE(first.identity(), moved.identity()); + EXPECT_NE(first.identity(), resized.identity()); + EXPECT_TRUE(decltype(first)::capabilities().exact_domain_identity); + EXPECT_TRUE(decltype(first)::capabilities().ghost_coordinates); + EXPECT_TRUE(decltype(first)::capabilities().allocation_free_queries); +} + +TEST(test_nd_metric_provider, cartesian_1d_centers_faces_measure_and_ghosts) { + const auto map = CartesianCoordinateMap<1>::make(RealVector<1>{5.0}, RealVector<1>{8.0}, + std::array{0}, std::array{-1}); + const auto metric = prepare_metric_provider(Box<1>{Index<1>{-2}, Index<1>{1}}, map); + + expect_vector_close(metric.cell_center(Index<1>{-2}), RealVector<1>{4.0}); + expect_vector_close(metric.template face_center<0, MetricFaceSide::Lower>(Index<1>{-2}), + RealVector<1>{5.0}); + expect_vector_close(metric.template face_center<0, MetricFaceSide::Upper>(Index<1>{-2}), + RealVector<1>{3.0}); + EXPECT_TRUE(close(metric.cell_measure(Index<1>{-2}), Real(2))); + EXPECT_TRUE(close(metric.jacobian(Index<1>{-2})[0][0], Real(-8))); + expect_vector_close( + metric.template oriented_face_area_vector<0, MetricFaceSide::Lower>(Index<1>{-2}), + RealVector<1>{1.0}); + expect_vector_close( + metric.template oriented_face_area_vector<0, MetricFaceSide::Upper>(Index<1>{-2}), + RealVector<1>{-1.0}); + + // Coordinate queries are deliberately defined outside the accepted domain for ghost kernels. + expect_vector_close(metric.cell_center(Index<1>{-3}), RealVector<1>{6.0}); + const auto inverse = metric.inverse_map(RealVector<1>{4.0}); + ASSERT_TRUE(inverse.succeeded()); + expect_vector_close(inverse.reference, RealVector<1>{0.125}); +} + +TEST(test_nd_metric_provider, cartesian_2d_exposes_the_same_complete_contract) { + const auto map = + CartesianCoordinateMap<2>::make(RealVector<2>{-2.0, 10.0}, RealVector<2>{6.0, 4.0}, + std::array{1, 0}, std::array{1, -1}); + const auto metric = prepare_metric_provider(Box<2>{Index<2>{3, -4}, Index<2>{5, -3}}, map); + const Index<2> index{3, -4}; + + expect_vector_close(metric.cell_center(index), RealVector<2>{-3.0, 11.0}); + expect_vector_close(metric.template face_center<0, MetricFaceSide::Upper>(index), + RealVector<2>{-3.0, 12.0}); + expect_vector_close(metric.template face_center<1, MetricFaceSide::Lower>(index), + RealVector<2>{-2.0, 11.0}); + EXPECT_TRUE(close(metric.cell_measure(index), Real(4))); + + const auto jacobian = metric.jacobian(index); + EXPECT_TRUE(close(jacobian[1][0], Real(6))); + EXPECT_TRUE(close(jacobian[0][1], Real(-4))); + expect_vector_close(metric.template oriented_face_area_vector<0, MetricFaceSide::Upper>(index), + RealVector<2>{0.0, 2.0}); + expect_vector_close(metric.template oriented_face_area_vector<1, MetricFaceSide::Upper>(index), + RealVector<2>{-2.0, 0.0}); + + const RealVector<2> reference{Real(1) / Real(6), Real(0.25)}; + const auto inverse = metric.inverse_map(map.map(reference)); + ASSERT_TRUE(inverse.succeeded()); + expect_vector_close(inverse.reference, reference); +} + +TEST(test_nd_metric_provider, cartesian_axis_permutation_is_reflected_in_every_metric) { + const auto map = + CartesianCoordinateMap<3>::make(RealVector<3>{10.0, 20.0, 30.0}, RealVector<3>{2.0, 4.0, 6.0}, + std::array{2, 0, 1}, std::array{1, -1, 1}); + const auto metric = prepare_metric_provider(Box<3>{Index<3>{0, 0, 0}, Index<3>{1, 3, 2}}, map); + const Index<3> index{0, 0, 0}; + + expect_vector_close(metric.cell_center(index), RealVector<3>{9.5, 21.0, 30.5}); + expect_vector_close(metric.template face_center<1, MetricFaceSide::Lower>(index), + RealVector<3>{10.0, 21.0, 30.5}); + EXPECT_TRUE(close(metric.cell_measure(index), Real(2))); + + const auto jacobian = metric.jacobian(index); + EXPECT_TRUE(close(jacobian[2][0], Real(2))); + EXPECT_TRUE(close(jacobian[0][1], Real(-4))); + EXPECT_TRUE(close(jacobian[1][2], Real(6))); + EXPECT_TRUE(close(jacobian[0][0], Real(0))); + EXPECT_TRUE(close(jacobian[1][0], Real(0))); + + expect_vector_close(metric.template oriented_face_area_vector<0, MetricFaceSide::Upper>(index), + RealVector<3>{0.0, 0.0, 2.0}); + expect_vector_close(metric.template oriented_face_area_vector<1, MetricFaceSide::Upper>(index), + RealVector<3>{-2.0, 0.0, 0.0}); + expect_vector_close(metric.template oriented_face_area_vector<2, MetricFaceSide::Upper>(index), + RealVector<3>{0.0, 1.0, 0.0}); + + const RealVector<3> reference{0.25, 0.125, Real(1) / Real(6)}; + const auto inverse = metric.inverse_map(map.map(reference)); + ASSERT_TRUE(inverse.succeeded()); + expect_vector_close(inverse.reference, reference); +} + +TEST(test_nd_metric_provider, embedded_cartesian_inverse_refuses_off_manifold_points) { + const auto map = CartesianCoordinateMap<1, 3>::make(RealVector<3>{2.0, 3.0, 4.0}, + RealVector<1>{2.0}, std::array{1}); + const auto metric = prepare_metric_provider(Box<1>{Index<1>{0}, Index<1>{3}}, map); + + const auto accepted = metric.inverse_map(RealVector<3>{2.0, 3.5, 4.0}); + ASSERT_TRUE(accepted.succeeded()); + EXPECT_TRUE(close(accepted.reference[0], Real(0.25))); + + const auto off_manifold = metric.inverse_map(RealVector<3>{2.01, 3.5, 4.0}); + EXPECT_EQ(off_manifold.status, InverseMapStatus::OffEmbeddedManifold); + const auto non_finite = + metric.inverse_map(RealVector<3>{2.0, std::numeric_limits::quiet_NaN(), 4.0}); + EXPECT_EQ(non_finite.status, InverseMapStatus::NonFinitePoint); +} + +TEST(test_nd_metric_provider, polar_sector_uses_exact_integrated_measures_and_face_vectors) { + const auto map = + PlanarPolarCoordinateMap::make(RealVector<2>{2.0, -1.0}, Real(1), Real(3), Real(0), kPi); + const auto metric = prepare_metric_provider(Box<2>{Index<2>{0, 0}, Index<2>{1, 3}}, map); + const Index<2> index{0, 0}; + const Real angle = kPi / Real(8); + + expect_vector_close( + metric.cell_center(index), + RealVector<2>{Real(2) + Real(1.5) * std::cos(angle), Real(-1) + Real(1.5) * std::sin(angle)}); + EXPECT_TRUE(close(metric.cell_measure(index), Real(3) * kPi / Real(8))); + + const auto jacobian = metric.jacobian(index); + EXPECT_TRUE(close(jacobian[0][0], Real(2) * std::cos(angle))); + EXPECT_TRUE(close(jacobian[1][0], Real(2) * std::sin(angle))); + EXPECT_TRUE(close(jacobian[0][1], -Real(1.5) * kPi * std::sin(angle))); + EXPECT_TRUE(close(jacobian[1][1], Real(1.5) * kPi * std::cos(angle))); + + const Real sine = std::sin(kPi / Real(4)); + const Real cosine = std::cos(kPi / Real(4)); + expect_vector_close(metric.template oriented_face_area_vector<0, MetricFaceSide::Upper>(index), + RealVector<2>{Real(2) * sine, Real(2) * (Real(1) - cosine)}); + expect_vector_close(metric.template oriented_face_area_vector<1, MetricFaceSide::Lower>(index), + RealVector<2>{0.0, -1.0}); + expect_vector_close(metric.template face_center<1, MetricFaceSide::Upper>(index), + RealVector<2>{Real(2) + Real(1.5) * cosine, Real(-1) + Real(1.5) * sine}); + + const RealVector<2> reference{0.25, 0.125}; + const auto inverse = metric.inverse_map(map.map(reference)); + ASSERT_TRUE(inverse.succeeded()); + expect_vector_close(inverse.reference, reference); +} + +TEST(test_nd_metric_provider, integrated_face_vectors_close_for_cartesian_and_polar_cells) { + const auto cartesian = prepare_metric_provider( + Box<3>{Index<3>{-2, 3, 7}, Index<3>{1, 5, 8}}, + CartesianCoordinateMap<3>::make(RealVector<3>{1.0, -2.0, 5.0}, RealVector<3>{4.0, 6.0, 8.0}, + std::array{1, 2, 0}, std::array{-1, 1, -1})); + expect_vector_close(face_closure(cartesian, Index<3>{0, 4, 8}), RealVector<3>{}); + + const auto polar = prepare_metric_provider( + Box<2>{Index<2>{-4, 8}, Index<2>{3, 15}}, + PlanarPolarCoordinateMap::make(RealVector<2>{-3.0, 2.0}, Real(0.5), Real(5), -kPi / Real(3), + kPi / Real(2))); + // This is the geometric-conservation/free-stream identity: a constant physical flux has zero + // divergence because the exact outward face vectors close on every mapped control volume. + expect_vector_close(face_closure(polar, Index<2>{-1, 11}), RealVector<2>{}, Real(2e-12)); +} + +TEST(test_nd_metric_provider, invalid_maps_domains_and_polar_inverse_fail_closed) { + EXPECT_THROW( + (void)(CartesianCoordinateMap<2, 3>::make(RealVector<3>{0.0, 0.0, 0.0}, + RealVector<2>{1.0, 2.0}, std::array{1, 1})), + std::invalid_argument); + EXPECT_THROW((void)CartesianCoordinateMap<1>::make(RealVector<1>{0.0}, RealVector<1>{0.0}), + std::invalid_argument); + EXPECT_THROW((void)CartesianCoordinateMap<1>::make( + RealVector<1>{std::numeric_limits::infinity()}, RealVector<1>{1.0}), + std::invalid_argument); + EXPECT_THROW((void)PlanarPolarCoordinateMap::make(RealVector<2>{}, Real(0), Real(2)), + std::invalid_argument); + EXPECT_THROW((void)PlanarPolarCoordinateMap::make(RealVector<2>{}, Real(1), Real(2), Real(0), + Real(2) * kPi + Real(0.1)), + std::invalid_argument); + + const auto cartesian = CartesianCoordinateMap<2>::make(RealVector<2>{}, RealVector<2>{1.0, 1.0}); + EXPECT_THROW((void)prepare_metric_provider(Box<2>{}, cartesian), std::invalid_argument); + + const auto polar = prepare_metric_provider( + Box<2>{Index<2>{0, 0}, Index<2>{3, 3}}, + PlanarPolarCoordinateMap::make(RealVector<2>{}, Real(1), Real(3), Real(0), kPi)); + EXPECT_EQ(polar.inverse_map(RealVector<2>{}).status, InverseMapStatus::SingularPoint); + EXPECT_EQ(polar.inverse_map(RealVector<2>{4.0, 0.0}).status, InverseMapStatus::OutsidePatch); + EXPECT_EQ(polar.inverse_map(RealVector<2>{0.0, -2.0}).status, InverseMapStatus::OutsidePatch); +} diff --git a/tests/cpp/unit/mesh/test_nd_tag_mask.cpp b/tests/cpp/unit/mesh/test_nd_tag_mask.cpp new file mode 100644 index 000000000..91f2167a4 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_tag_mask.cpp @@ -0,0 +1,130 @@ +#include + +#include + +#include +#include +#include + +namespace nd = pops::amr::hierarchy::nd; +namespace mesh = pops::mesh; + +using pops::Box; +using pops::Extent; +using pops::Index; + +namespace { + +constexpr mesh::BoxArrayValidationBudget kLayoutBudget{64, 2016}; +constexpr std::size_t kIdentityBudget = 1U << 20; + +constexpr nd::TagMaskBudget tag_budget(std::size_t global_patches, std::size_t owned_patches, + std::size_t cells_per_patch, std::size_t owned_cells) { + return nd::TagMaskBudget{global_patches, owned_patches, cells_per_patch, + owned_cells, owned_cells, kIdentityBudget}; +} + +template +nd::LevelLayout make_partitioned_level(int level, const Box& domain, + const mesh::BoxArray& patches, + const mesh::RankSpace& ranks, + const std::vector>& owners, + const nd::RefinementRatio& ratio) { + return nd::LevelLayout(level, domain, patches, + mesh::Distribution::partitioned(patches, ranks, owners), ratio, + kLayoutBudget); +} + +} // namespace + +TEST(test_nd_tag_mask, partitioned_storage_contains_only_owned_patches) { + const Box<1> domain{Index<1>{-4}, Index<1>{3}}; + const mesh::BoxArray<1> patches = mesh::BoxArray<1>::from_domain(domain, std::array{2}); + const mesh::RankSpace<1> ranks{Index<1>{10}, Extent<1>{2}}; + const auto level = make_partitioned_level<1>( + 0, domain, patches, ranks, {Index<1>{10}, Index<1>{11}, Index<1>{10}, Index<1>{11}}, + nd::RefinementRatio<1>{1}); + nd::TagMask<1> mask(level, Index<1>{10}, tag_budget(4, 2, 2, 4)); + + ASSERT_EQ(mask.local_patch_count(), 2U); + EXPECT_EQ(mask.local_cell_count(), 4U); + EXPECT_EQ(mask.patches()[0].global_patch, 0U); + EXPECT_EQ(mask.patches()[1].global_patch, 2U); + mask.set(Index<1>{-4}); + mask.set(2, Index<1>{0}); + EXPECT_TRUE(mask.tagged(0, Index<1>{-4})); + EXPECT_TRUE(mask.tagged(2, Index<1>{0})); + EXPECT_EQ(mask.count(), 2U); + EXPECT_THROW(mask.set(Index<1>{-2}), std::out_of_range); + EXPECT_THROW(mask.set(1, Index<1>{-2}), std::out_of_range); + EXPECT_THROW((void)mask.tagged(0, Index<1>{3}), std::out_of_range); +} + +TEST(test_nd_tag_mask, all_storage_dimensions_honor_nonzero_origins_and_axis_zero_order) { + const Box<2> plane{Index<2>{-2, 5}, Index<2>{0, 6}}; + const mesh::BoxArray<2> plane_patches(std::vector>{plane}); + const mesh::RankSpace<2> plane_ranks{Index<2>{3, -1}, Extent<2>{1, 1}}; + const auto plane_level = make_partitioned_level<2>( + 0, plane, plane_patches, plane_ranks, {Index<2>{3, -1}}, nd::RefinementRatio<2>{1, 1}); + nd::TagMask<2> plane_mask(plane_level, Index<2>{3, -1}, tag_budget(1, 1, 6, 6)); + plane_mask.set(Index<2>{-2, 5}); + plane_mask.set(Index<2>{0, 5}); + plane_mask.set(Index<2>{-1, 6}); + std::vector> plane_tags; + plane_mask.for_each_tagged_in(plane, [&](const Index<2>& index) { plane_tags.push_back(index); }); + EXPECT_EQ(plane_tags, (std::vector>{Index<2>{-2, 5}, Index<2>{0, 5}, Index<2>{-1, 6}})); + + const Box<3> volume{Index<3>{4, -2, 7}, Index<3>{5, 0, 8}}; + const mesh::BoxArray<3> volume_patches(std::vector>{volume}); + const mesh::RankSpace<3> volume_ranks{Index<3>{-3, 2, 1}, Extent<3>{1, 1, 1}}; + const auto volume_level = + make_partitioned_level<3>(0, volume, volume_patches, volume_ranks, {Index<3>{-3, 2, 1}}, + nd::RefinementRatio<3>{1, 1, 1}); + nd::TagMask<3> volume_mask(volume_level, Index<3>{-3, 2, 1}, tag_budget(1, 1, 12, 12)); + volume_mask.set(Index<3>{5, -1, 8}); + EXPECT_EQ(volume_mask.count(), 1U); + EXPECT_TRUE(volume_mask.tagged(0, Index<3>{5, -1, 8})); +} + +TEST(test_nd_tag_mask, explicit_metadata_cell_byte_and_identity_budgets_fail_closed) { + const Box<1> domain{Index<1>{0}, Index<1>{7}}; + const mesh::BoxArray<1> patches = mesh::BoxArray<1>::from_domain(domain, std::array{4}); + const mesh::RankSpace<1> ranks{Index<1>{0}, Extent<1>{1}}; + const auto level = make_partitioned_level<1>( + 0, domain, patches, ranks, {Index<1>{0}, Index<1>{0}}, nd::RefinementRatio<1>{1}); + + EXPECT_THROW( + (void)nd::TagMask<1>(level, Index<1>{0}, nd::TagMaskBudget{1, 2, 4, 8, 8, kIdentityBudget}), + std::length_error); + EXPECT_THROW( + (void)nd::TagMask<1>(level, Index<1>{0}, nd::TagMaskBudget{2, 1, 4, 8, 8, kIdentityBudget}), + std::length_error); + EXPECT_THROW( + (void)nd::TagMask<1>(level, Index<1>{0}, nd::TagMaskBudget{2, 2, 3, 8, 8, kIdentityBudget}), + std::length_error); + EXPECT_THROW( + (void)nd::TagMask<1>(level, Index<1>{0}, nd::TagMaskBudget{2, 2, 4, 7, 8, kIdentityBudget}), + std::length_error); + EXPECT_THROW( + (void)nd::TagMask<1>(level, Index<1>{0}, nd::TagMaskBudget{2, 2, 4, 8, 7, kIdentityBudget}), + std::length_error); + EXPECT_THROW((void)nd::TagMask<1>(level, Index<1>{0}, nd::TagMaskBudget{2, 2, 4, 8, 8, 1}), + std::length_error); + EXPECT_THROW((void)nd::TagMask<1>(level, Index<1>{2}, tag_budget(2, 2, 4, 8)), std::out_of_range); +} + +TEST(test_nd_tag_mask, exact_identity_tracks_rank_patch_topology_and_tag_bits) { + const Box<1> domain{Index<1>{0}, Index<1>{3}}; + const mesh::BoxArray<1> patches = mesh::BoxArray<1>::from_domain(domain, std::array{2}); + const mesh::RankSpace<1> ranks{Index<1>{4}, Extent<1>{2}}; + const auto level = make_partitioned_level<1>( + 0, domain, patches, ranks, {Index<1>{4}, Index<1>{5}}, nd::RefinementRatio<1>{1}); + nd::TagMask<1> first(level, Index<1>{4}, tag_budget(2, 1, 2, 2)); + nd::TagMask<1> same(level, Index<1>{4}, tag_budget(2, 1, 2, 2)); + EXPECT_EQ(first.exact_identity(), same.exact_identity()); + first.set(Index<1>{0}); + EXPECT_NE(first.exact_identity(), same.exact_identity()); + + nd::TagMask<1> other_rank(level, Index<1>{5}, tag_budget(2, 1, 2, 2)); + EXPECT_NE(first.exact_identity(), other_rank.exact_identity()); +} diff --git a/tests/cpp/unit/mesh/test_nd_topology.cpp b/tests/cpp/unit/mesh/test_nd_topology.cpp new file mode 100644 index 000000000..237d9254a --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_topology.cpp @@ -0,0 +1,308 @@ +#include + +#include +#include + +#include +#include +#include +#include + +using namespace pops; +using namespace pops::mesh::nd_proof; + +namespace { + +constexpr BoxHashBudget kHashBudget{4096, 4096, 4096}; +constexpr LocalNeighborWorkBudget kNeighborBudget{4096, 4096, {4096, 4096}, {4096, 4096}}; + +template +std::vector> brute_translation_neighbors( + const BoxArray& boxes, const Box& domain, const Extent& ghosts, + const PeriodicTopology& topology) { + std::vector> result; + const auto images = + enumerate_axis_translation_images(domain, ghosts, topology, AxisTranslationImageBudget{4096}); + for (std::size_t destination = 0; destination < boxes.size(); ++destination) { + const Box grown = periodicity_detail::grow_box(boxes[destination], ghosts); + for (const AxisTranslationImage& image : images) { + std::array source_from_destination{}; + for (int axis = 0; axis < Dim; ++axis) + source_from_destination[axis] = -image.translation[axis]; + for (std::size_t source = 0; source < boxes.size(); ++source) { + if (image.is_zero() && source == destination) + continue; + const Box region = grown.intersect(image.apply(boxes[source])); + if (!region.empty()) + result.push_back( + LocalNeighborJob{source, destination, region, source_from_destination}); + } + } + } + return result; +} + +template +const LocalNeighborJob* find_job(const std::vector>& jobs, + std::size_t source, std::size_t destination, + const std::array& translation) { + for (const LocalNeighborJob& job : jobs) + if (job.source_box == source && job.destination_box == destination && + job.source_from_destination_translation == translation) + return &job; + return nullptr; +} + +} // namespace + +TEST(test_nd_topology, faces_validate_and_topologies_canonicalize_identity) { + EXPECT_EQ((Face<1>{0, Side::lower}.ordinal()), 0); + EXPECT_EQ((Face<3>{2, Side::upper}.ordinal()), 5); + EXPECT_THROW((Face<2>{2, Side::lower}), std::invalid_argument); + + const PeriodicIdentification<2> forward{Face<2>{0, Side::lower}, Face<2>{0, Side::upper}}; + const PeriodicIdentification<2> reverse{Face<2>{0, Side::upper}, Face<2>{0, Side::lower}}; + EXPECT_EQ((PeriodicTopology<2>{std::vector>{forward}}), + (PeriodicTopology<2>{std::vector>{reverse}})); + EXPECT_TRUE( + PeriodicTopology<3>::axis_translations({true, false, true}).is_axis_translation_only()); + EXPECT_THROW( + (PeriodicTopology<2>{std::vector>{ + forward, PeriodicIdentification<2>{Face<2>{0, Side::upper}, Face<2>{1, Side::lower}, + SignedPermutation<2>{{1, 0}, {1, 1}}}}}), + std::invalid_argument); +} + +TEST(test_nd_topology, signed_permutations_invert_and_compose_in_all_ranks) { + const SignedPermutation<1> one{{0}, {-1}}; + const SignedPermutation<2> two{{1, 0}, {-1, 1}}; + const SignedPermutation<3> three{{1, 2, 0}, {1, -1, 1}}; + EXPECT_TRUE(one.compose(one.inverse()).is_identity()); + EXPECT_TRUE(two.compose(two.inverse()).is_identity()); + EXPECT_TRUE(three.compose(three.inverse()).is_identity()); + EXPECT_THROW((SignedPermutation<2>{{0, 0}, {1, 1}}), std::invalid_argument); + EXPECT_THROW((SignedPermutation<3>{{0, 1, 2}, {1, 0, 1}}), std::invalid_argument); +} + +TEST(test_nd_topology, affine_identifications_are_exact_for_axis_and_permuted_faces) { + const Box<2> axis_domain{Index<2>{-4, 10}, Index<2>{1, 13}}; + const PeriodicIdentification<2> axis_aligned{Face<2>{0, Side::lower}, Face<2>{0, Side::upper}}; + const AffineIndexTransform<2> axis_forward = + axis_aligned.source_interior_to_target_exterior(axis_domain); + EXPECT_EQ(axis_forward.apply(Index<2>{-4, 11}), (Index<2>{2, 11})); + EXPECT_EQ(axis_forward.inverse().apply(Index<2>{2, 11}), (Index<2>{-4, 11})); + + const Box<3> compatible{Index<3>{-5, 10, -2}, Index<3>{-2, 13, 4}}; + const SignedPermutation<3> permutation{{1, 0, 2}, {1, -1, 1}}; + const PeriodicIdentification<3> mapped{Face<3>{0, Side::lower}, Face<3>{1, Side::upper}, + permutation}; + const AffineIndexTransform<3> mapped_forward = + mapped.source_interior_to_target_exterior(compatible); + EXPECT_EQ(mapped_forward.apply(Index<3>{-5, 10, -2}), (Index<3>{-2, 14, -2})); + EXPECT_EQ(mapped.target_exterior_to_source_interior(compatible).apply(Index<3>{-2, 14, -2}), + (Index<3>{-5, 10, -2})); + EXPECT_EQ(mapped_forward.apply(Box<3>{Index<3>{-5, 10, -2}, Index<3>{-4, 11, 0}}), + (Box<3>{Index<3>{-3, 14, -2}, Index<3>{-2, 15, 0}})); + + const Box<3> incompatible{Index<3>{-5, 10, -2}, Index<3>{-2, 14, 4}}; + EXPECT_THROW((void)mapped.source_interior_to_target_exterior(incompatible), + std::invalid_argument); + + const PeriodicIdentification<1> upper_to_lower{Face<1>{0, Side::upper}, Face<1>{0, Side::lower}}; + EXPECT_EQ(upper_to_lower.source_interior_to_target_exterior(Box<1>{Index<1>{-2}, Index<1>{1}}) + .apply(Index<1>{1}), + (Index<1>{-3})); +} + +TEST(test_nd_topology, affine_and_translation_narrow_only_after_checked_int64_arithmetic) { + const AffineIndexTransform<1> overflowing{SignedPermutation<1>{}, + {std::numeric_limits::max()}}; + EXPECT_THROW((void)overflowing.apply(Index<1>{1}), std::overflow_error); + const AxisTranslationImage<1> image{{1}, {std::numeric_limits::max()}}; + EXPECT_THROW((void)image.apply(Index<1>{1}), std::overflow_error); + EXPECT_THROW((void)image.apply(Box<1>{Index<1>{0}, Index<1>{1}}), std::overflow_error); + + const AffineIndexTransform<1> reflected_minimum{SignedPermutation<1>{{0}, {-1}}, + {std::numeric_limits::min()}}; + EXPECT_EQ(reflected_minimum.inverse().target_offsets()[0], + std::numeric_limits::min()); +} + +TEST(test_nd_topology, axis_translation_images_cover_deep_halos_with_explicit_order_and_budget) { + const Box<1> line{Index<1>{0}, Index<1>{3}}; + const auto topology = PeriodicTopology<1>::axis_translations({true}); + const auto images = enumerate_axis_translation_images(line, Extent<1>{5}, topology, + AxisTranslationImageBudget{5}); + ASSERT_EQ(images.size(), 5U); + EXPECT_EQ(images[0].translation, (std::array{0})); + EXPECT_EQ(images[1].translation, (std::array{-4})); + EXPECT_EQ(images[2].translation, (std::array{4})); + EXPECT_EQ(images[3].translation, (std::array{-8})); + EXPECT_EQ(images[4].translation, (std::array{8})); + EXPECT_THROW((void)enumerate_axis_translation_images(line, Extent<1>{5}, topology, + AxisTranslationImageBudget{4}), + std::length_error); + + const Box<2> plane{Index<2>{0, 5}, Index<2>{1, 7}}; + const auto only_x = PeriodicTopology<2>::axis_translations({true, false}); + const auto anisotropic = enumerate_axis_translation_images(plane, Extent<2>{3, 100}, only_x, + AxisTranslationImageBudget{5}); + ASSERT_EQ(anisotropic.size(), 5U); + for (const AxisTranslationImage<2>& candidate : anisotropic) + EXPECT_EQ(candidate.translation[1], 0); +} + +TEST(test_nd_topology, axis_translation_image_corners_are_axis_zero_fastest_and_reject_mapped) { + const Box<2> plane{Index<2>{0, 0}, Index<2>{1, 2}}; + const auto topology = PeriodicTopology<2>::axis_translations({true, true}); + const auto images = enumerate_axis_translation_images(plane, Extent<2>{1, 1}, topology, + AxisTranslationImageBudget{9}); + ASSERT_EQ(images.size(), 9U); + EXPECT_EQ(images[0].multiples, (std::array{0, 0})); + EXPECT_EQ(images[1].multiples, (std::array{-1, 0})); + EXPECT_EQ(images[2].multiples, (std::array{1, 0})); + EXPECT_EQ(images[3].multiples, (std::array{0, -1})); + EXPECT_EQ(images[8].multiples, (std::array{1, 1})); + + const Box<3> volume{Index<3>{0, 0, 0}, Index<3>{0, 0, 0}}; + EXPECT_EQ( + enumerate_axis_translation_images(volume, Extent<3>{1, 1, 1}, + PeriodicTopology<3>::axis_translations({true, true, true}), + AxisTranslationImageBudget{27}) + .size(), + 27U); + + const PeriodicTopology<2> mapped{std::vector>{PeriodicIdentification<2>{ + Face<2>{0, Side::lower}, Face<2>{1, Side::upper}, SignedPermutation<2>{{1, 0}, {1, -1}}}}}; + EXPECT_THROW((void)enumerate_axis_translation_images(plane, Extent<2>{1, 1}, mapped, + AxisTranslationImageBudget{9}), + std::invalid_argument); +} + +TEST(test_nd_topology, local_neighbors_enumerate_internal_and_periodic_self_seams_in_1d) { + const Box<1> domain{Index<1>{0}, Index<1>{3}}; + const BoxArray<1> split = BoxArray<1>::from_domain(domain, std::array{2}); + const auto internal = + enumerate_local_translation_neighbors(split, domain, Extent<1>{1}, PeriodicTopology<1>{}, + std::array{2}, kHashBudget, kNeighborBudget); + EXPECT_EQ(internal, + brute_translation_neighbors(split, domain, Extent<1>{1}, PeriodicTopology<1>{})); + ASSERT_EQ(internal.size(), 2U); + EXPECT_EQ(internal[0].source_box, 1U); + EXPECT_EQ(internal[0].destination_box, 0U); + EXPECT_EQ(internal[0].destination_region, (Box<1>{Index<1>{2}, Index<1>{2}})); + EXPECT_THROW((void)enumerate_local_translation_neighbors( + split, domain, Extent<1>{1}, PeriodicTopology<1>{}, std::array{2}, + kHashBudget, LocalNeighborWorkBudget{4096, 4096, {4096, 4096}, {1, 4096}}), + std::length_error); + + const Box<1> small_domain{Index<1>{0}, Index<1>{1}}; + const BoxArray<1> one_box = BoxArray<1>::from_domain(small_domain, std::array{2}); + const auto periodic = enumerate_local_translation_neighbors( + one_box, small_domain, Extent<1>{3}, PeriodicTopology<1>::axis_translations({true}), + std::array{2}, kHashBudget, kNeighborBudget); + EXPECT_EQ(periodic, brute_translation_neighbors(one_box, small_domain, Extent<1>{3}, + PeriodicTopology<1>::axis_translations({true}))); + ASSERT_EQ(periodic.size(), 4U); + EXPECT_EQ(periodic[0].source_box, 0U); + EXPECT_EQ(periodic[0].source_from_destination_translation, (std::array{2})); + EXPECT_EQ(periodic[0].destination_region, (Box<1>{Index<1>{-2}, Index<1>{-1}})); +} + +TEST(test_nd_topology, local_neighbors_are_exact_unique_and_ordered_for_2d_corners) { + const Box<2> domain{Index<2>{0, 0}, Index<2>{3, 3}}; + const BoxArray<2> boxes = BoxArray<2>::from_domain(domain, std::array{2, 2}); + const auto topology = PeriodicTopology<2>::axis_translations({true, true}); + const auto jobs = + enumerate_local_translation_neighbors(boxes, domain, Extent<2>{1, 1}, topology, + std::array{2, 2}, kHashBudget, kNeighborBudget); + const auto brute = brute_translation_neighbors(boxes, domain, Extent<2>{1, 1}, topology); + EXPECT_EQ(jobs, brute); + const auto coarse_jobs = + enumerate_local_translation_neighbors(boxes, domain, Extent<2>{1, 1}, topology, + std::array{4, 4}, kHashBudget, kNeighborBudget); + EXPECT_EQ(coarse_jobs, + brute); // Coarse bins produce false positives; exact intersections filter them. + const LocalNeighborJob<2>* corner = find_job(jobs, 3, 0, {4, 4}); + ASSERT_NE(corner, nullptr); + EXPECT_EQ(corner->destination_region, (Box<2>{Index<2>{-1, -1}, Index<2>{-1, -1}})); + + EXPECT_THROW((void)enumerate_local_translation_neighbors( + boxes, domain, Extent<2>{1, 1}, topology, std::array{2, 2}, kHashBudget, + LocalNeighborWorkBudget{9, 1, {4096, 4096}, {4096, 4096}}), + std::length_error); +} + +TEST(test_nd_topology, topology_canonical_reverse_and_affine_round_trips_are_exact) { + const Box<1> line{Index<1>{-2}, Index<1>{3}}; + const PeriodicIdentification<1> forward1{Face<1>{0, Side::lower}, Face<1>{0, Side::upper}}; + const PeriodicIdentification<1> reverse1{Face<1>{0, Side::upper}, Face<1>{0, Side::lower}}; + EXPECT_EQ(PeriodicTopology<1>{{forward1}}, PeriodicTopology<1>{{reverse1}}); + const auto map1 = forward1.source_interior_to_target_exterior(line); + const Box<1> box1{Index<1>{-2}, Index<1>{0}}; + EXPECT_EQ(map1.inverse().apply(map1.apply(box1)), box1); + EXPECT_EQ(map1.inverse().apply(map1.apply(Index<1>{-2})), (Index<1>{-2})); + + const Box<2> plane{Index<2>{0, 0}, Index<2>{3, 3}}; + const SignedPermutation<2> reflected2{{1, 0}, {1, -1}}; + const PeriodicIdentification<2> forward2{Face<2>{0, Side::lower}, Face<2>{1, Side::upper}, + reflected2}; + const PeriodicIdentification<2> reverse2{Face<2>{1, Side::upper}, Face<2>{0, Side::lower}, + reflected2.inverse()}; + EXPECT_EQ(PeriodicTopology<2>{{forward2}}, PeriodicTopology<2>{{reverse2}}); + const auto map2 = forward2.source_interior_to_target_exterior(plane); + const Box<2> box2{Index<2>{0, 1}, Index<2>{2, 3}}; + EXPECT_EQ(map2.inverse().apply(map2.apply(box2)), box2); + EXPECT_EQ(map2.inverse().apply(map2.apply(Index<2>{0, 3})), (Index<2>{0, 3})); + + const Box<3> volume{Index<3>{-1, 2, 4}, Index<3>{2, 5, 7}}; + const SignedPermutation<3> reflected3{{1, 2, 0}, {1, -1, 1}}; + const PeriodicIdentification<3> forward3{Face<3>{0, Side::lower}, Face<3>{1, Side::upper}, + reflected3}; + const PeriodicIdentification<3> reverse3{Face<3>{1, Side::upper}, Face<3>{0, Side::lower}, + reflected3.inverse()}; + EXPECT_EQ(PeriodicTopology<3>{{forward3}}, PeriodicTopology<3>{{reverse3}}); + const auto map3 = forward3.source_interior_to_target_exterior(volume); + const Box<3> box3{Index<3>{-1, 3, 4}, Index<3>{1, 5, 6}}; + EXPECT_EQ(map3.inverse().apply(map3.apply(box3)), box3); + EXPECT_EQ(map3.inverse().apply(map3.apply(Index<3>{-1, 5, 6})), (Index<3>{-1, 5, 6})); +} + +TEST(test_nd_topology, local_neighbors_cover_3d_multibox_and_deep_corner_images) { + const Box<3> domain{Index<3>{0, 0, 0}, Index<3>{3, 1, 1}}; + const BoxArray<3> split = BoxArray<3>::from_domain(domain, std::array{2, 2, 2}); + const auto topology = PeriodicTopology<3>::axis_translations({true, true, true}); + const auto jobs = enumerate_local_translation_neighbors(split, domain, Extent<3>{2, 1, 1}, + topology, std::array{2, 2, 2}, + kHashBudget, kNeighborBudget); + EXPECT_EQ(jobs, brute_translation_neighbors(split, domain, Extent<3>{2, 1, 1}, topology)); + + const Box<3> one_cell_domain{Index<3>{0, 0, 0}, Index<3>{0, 0, 0}}; + const BoxArray<3> one_cell = + BoxArray<3>::from_domain(one_cell_domain, std::array{1, 1, 1}); + const auto deep = enumerate_local_translation_neighbors( + one_cell, one_cell_domain, Extent<3>{2, 1, 1}, topology, std::array{1, 1, 1}, + kHashBudget, kNeighborBudget); + EXPECT_EQ(deep, + brute_translation_neighbors(one_cell, one_cell_domain, Extent<3>{2, 1, 1}, topology)); + EXPECT_NE(find_job(deep, 0, 0, {2, 1, 1}), nullptr); +} + +TEST(test_nd_topology, local_neighbors_reject_unmappable_topology_and_checked_ghost_growth) { + const Box<2> domain{Index<2>{0, 0}, Index<2>{1, 1}}; + const BoxArray<2> boxes = BoxArray<2>::from_domain(domain, std::array{2, 2}); + const PeriodicTopology<2> mapped{std::vector>{PeriodicIdentification<2>{ + Face<2>{0, Side::lower}, Face<2>{1, Side::upper}, SignedPermutation<2>{{1, 0}, {1, -1}}}}}; + EXPECT_THROW((void)enumerate_local_translation_neighbors(boxes, domain, Extent<2>{1, 1}, mapped, + std::array{2, 2}, kHashBudget, + kNeighborBudget), + std::invalid_argument); + + const Box<1> edge{Index<1>{std::numeric_limits::min()}, + Index<1>{std::numeric_limits::min()}}; + const BoxArray<1> edge_boxes(std::vector>{edge}); + EXPECT_THROW((void)enumerate_local_translation_neighbors( + edge_boxes, edge, Extent<1>{1}, PeriodicTopology<1>{}, std::array{1}, + kHashBudget, kNeighborBudget), + std::overflow_error); +} diff --git a/tests/cpp/unit/mesh/test_nd_translation_schedule.cpp b/tests/cpp/unit/mesh/test_nd_translation_schedule.cpp new file mode 100644 index 000000000..80421a838 --- /dev/null +++ b/tests/cpp/unit/mesh/test_nd_translation_schedule.cpp @@ -0,0 +1,479 @@ +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace pops; +using namespace pops::mesh::nd_proof; + +namespace { + +template +TranslationScheduleBudget schedule_budget(std::size_t jobs = 512, std::size_t peers = 64, + std::size_t local = 4096, std::size_t send = 4096, + std::size_t receive = 4096) { + return TranslationScheduleBudget{ + jobs, peers, local, + send, receive, LocalNeighborWorkBudget{512, jobs, {512, 200000}, {200000, 200000}}}; +} + +constexpr BoxHashBudget kHashBudget{4096, 4096, 4096}; + +template +Index index_from_cell(const Box& box, std::size_t cell) { + Index index{}; + for (int axis = 0; axis < Dim; ++axis) { + const std::size_t extent = static_cast(box.length(axis)); + index[axis] = box.lo[axis] + static_cast(cell % extent); + cell /= extent; + } + return index; +} + +template +Real value_for(const Index& index, int component) { + Real value = static_cast(component * 10000); + Real scale = 1; + for (int axis = 0; axis < Dim; ++axis) { + value += scale * static_cast(index[axis]); + scale *= 97; + } + return value; +} + +template +void fill_valid(MultiFab& fields, Real ghost_value = Real{-777}) { + for (const std::size_t global_box : fields.local_global_indices()) { + auto& fab = fields.fab(global_box); + auto host = fab.create_host_mirror(); + const Box& grown = fab.grown_box(); + const std::size_t cells = static_cast(grown.numPts()); + for (int component = 0; component < fab.ncomp(); ++component) + for (std::size_t cell = 0; cell < cells; ++cell) { + const Index index = index_from_cell(grown, cell); + host(static_cast(component) * cells + cell) = + fab.box().contains(index) ? value_for(index, component) : ghost_value; + } + fab.copy_from_host(host); + } +} + +template +Real value_at(const MultiFab& fields, std::size_t global_box, + const Index& index, int component) { + const auto& fab = fields.fab(global_box); + const Box& grown = fab.grown_box(); + std::size_t stride = 1; + std::size_t cell = 0; + for (int axis = 0; axis < Dim; ++axis) { + cell += static_cast(index[axis] - grown.lo[axis]) * stride; + stride *= static_cast(grown.length(axis)); + } + auto host = fab.create_host_mirror(); + fab.copy_to_host(host); + return host(static_cast(component) * stride + cell); +} + +template +std::vector snapshot(const MultiFab& fields) { + std::vector result; + for (const std::size_t global_box : fields.local_global_indices()) { + const auto& fab = fields.fab(global_box); + auto host = fab.create_host_mirror(); + fab.copy_to_host(host); + for (std::size_t element = 0; element < host.size(); ++element) + result.push_back(host(element)); + } + return result; +} + +template +std::vector snapshot_buffer(const Buffer& buffer) { + const auto host = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, buffer); + std::vector result; + result.reserve(host.extent(0)); + for (std::size_t element = 0; element < host.extent(0); ++element) + result.push_back(host(element)); + return result; +} + +template +std::vector expected_payload(const typename TranslationSchedule::Job& job, + int first_component, int component_count) { + std::vector result; + const std::size_t cells = static_cast(job.destination_region.numPts()); + result.reserve(job.elements); + for (int component = first_component; component < first_component + component_count; ++component) + for (std::size_t cell = 0; cell < cells; ++cell) { + const Index destination = index_from_cell(job.destination_region, cell); + Index source{}; + for (int axis = 0; axis < Dim; ++axis) + source[axis] = static_cast(static_cast(destination[axis]) + + job.source_from_destination[axis]); + result.push_back(value_for(source, component)); + } + return result; +} + +template +void expect_partitioned_two_rank_multi_job_transfer() { + Index lower{}; + Index upper{}; + lower.values[0] = 0; + upper.values[0] = 5; + for (int axis = 1; axis < Dim; ++axis) { + lower.values[axis] = 0; + upper.values[axis] = 1; + } + const Box domain{lower, upper}; + std::vector> boxes; + for (int slab = 0; slab < 3; ++slab) { + Index slab_lower = lower; + Index slab_upper = upper; + slab_lower.values[0] = 2 * slab; + slab_upper.values[0] = 2 * slab + 1; + boxes.push_back(Box{slab_lower, slab_upper}); + } + const BoxArray layout(std::move(boxes)); + Extent rank_extent{}; + rank_extent.values[0] = 2; + for (int axis = 1; axis < Dim; ++axis) + rank_extent.values[axis] = 1; + const RankSpace ranks{Index{}, rank_extent}; + const Index rank0{}; + Index rank1{}; + rank1.values[0] = 1; + const auto distribution = Distribution::partitioned(layout, ranks, {rank0, rank1, rank0}); + std::array hash_bins{}; + hash_bins.fill(2); + Extent ghosts{}; + for (int axis = 0; axis < Dim; ++axis) + ghosts.values[axis] = 1; + TranslationSchedule sender(layout, distribution, domain, PeriodicTopology{}, ghosts, 3, + 1, 2, rank0, hash_bins, kHashBudget, schedule_budget()); + TranslationSchedule receiver(layout, distribution, domain, PeriodicTopology{}, ghosts, + 3, 1, 2, rank1, hash_bins, kHashBudget, schedule_budget()); + const auto& send = sender.send_plan(rank1); + const auto& receive = receiver.receive_plan(rank0); + ASSERT_EQ(send.jobs.size(), 2U); + EXPECT_EQ(send.jobs, receive.jobs); + EXPECT_EQ(send.elements, receive.elements); + EXPECT_EQ(send.jobs[0].offset, 0U); + EXPECT_EQ(send.jobs[1].offset, send.jobs[0].elements); + EXPECT_GT(send.jobs[1].offset, 0U); + + const auto& reverse_send = receiver.send_plan(rank0); + const auto& reverse_receive = sender.receive_plan(rank1); + EXPECT_EQ(reverse_send.jobs, reverse_receive.jobs); + EXPECT_EQ(reverse_send.elements, reverse_receive.elements); + EXPECT_EQ(reverse_send.jobs.size(), 2U); + + MultiFab source(layout, distribution, rank0, 3, ghosts); + MultiFab destination(layout, distribution, rank1, 3, ghosts); + fill_valid(source); + fill_valid(destination); + typename TranslationSchedule::buffer_type buffer("translation_multi_job", send.elements); + sender.pack(source, rank1, buffer); + std::vector expected; + for (const auto& job : send.jobs) { + const std::vector job_payload = expected_payload(job, 1, 2); + expected.insert(expected.end(), job_payload.begin(), job_payload.end()); + } + EXPECT_EQ(snapshot_buffer(buffer), expected); + destination.fab(1).set_val(Real{-113}); + receiver.unpack(destination, rank0, buffer); + for (const auto& job : receive.jobs) { + const std::size_t cells = static_cast(job.destination_region.numPts()); + for (int component = 1; component <= 2; ++component) + for (std::size_t cell = 0; cell < cells; ++cell) { + const Index destination_index = index_from_cell(job.destination_region, cell); + Index source_index{}; + for (int axis = 0; axis < Dim; ++axis) + source_index.values[axis] = + static_cast(static_cast(destination_index.values[axis]) + + job.source_from_destination[axis]); + EXPECT_EQ(value_at(destination, job.destination_box, destination_index, component), + value_for(source_index, component)); + } + } +} + +} // namespace + +TEST(test_nd_translation_schedule, + partitioned_two_rank_multi_job_payloads_are_identical_in_dim1_dim2_and_dim3) { + expect_partitioned_two_rank_multi_job_transfer<1>(); + expect_partitioned_two_rank_multi_job_transfer<2>(); + expect_partitioned_two_rank_multi_job_transfer<3>(); +} + +TEST(test_nd_translation_schedule, + partitioned_2d_pack_unpack_has_shared_ordinals_and_component_axis_zero_order) { + const Box<2> domain{Index<2>{0, 0}, Index<2>{2, 1}}; + const BoxArray<2> layout(std::vector>{Box<2>{Index<2>{0, 0}, Index<2>{2, 0}}, + Box<2>{Index<2>{0, 1}, Index<2>{2, 1}}}); + const RankSpace<2> ranks{Index<2>{4, -2}, Extent<2>{2, 1}}; + const Index<2> sender_rank{4, -2}; + const Index<2> receiver_rank{5, -2}; + const auto distribution = + Distribution<2>::partitioned(layout, ranks, {sender_rank, receiver_rank}); + const auto topology = PeriodicTopology<2>{}; + const auto budget = schedule_budget<2>(); + TranslationSchedule<2> sender(layout, distribution, domain, topology, Extent<2>{1, 1}, 3, 1, 2, + sender_rank, {3, 1}, kHashBudget, budget); + TranslationSchedule<2> receiver(layout, distribution, domain, topology, Extent<2>{1, 1}, 3, 1, 2, + receiver_rank, {3, 1}, kHashBudget, budget); + + ASSERT_EQ(sender.send_plan_count(), 1U); + ASSERT_EQ(receiver.receive_plan_count(), 1U); + const auto& send = sender.send_plan(receiver_rank); + const auto& receive = receiver.receive_plan(sender_rank); + ASSERT_EQ(send.jobs.size(), 1U); + EXPECT_EQ(send.jobs, receive.jobs); + EXPECT_EQ(send.elements, receive.elements); + EXPECT_EQ(send.jobs[0].ordinal, receive.jobs[0].ordinal); + EXPECT_EQ(send.jobs[0].destination_region, (Box<2>{Index<2>{0, 0}, Index<2>{2, 0}})); + EXPECT_EQ(send.elements, 6U); + EXPECT_EQ(send.jobs[0].offset, 0U); + + MultiFab<2> source(layout, distribution, sender_rank, 3, Extent<2>{1, 1}); + MultiFab<2> destination(layout, distribution, receiver_rank, 3, Extent<2>{1, 1}); + fill_valid(source); + fill_valid(destination); + typename TranslationSchedule<2>::buffer_type buffer("translation_payload", send.elements); + Kokkos::deep_copy(buffer, Real{-31}); + sender.pack(source, receiver_rank, buffer); + const std::vector expected = expected_payload<2>(send.jobs[0], 1, 2); + EXPECT_EQ(snapshot_buffer(buffer), expected); + EXPECT_EQ(expected, (std::vector{10000, 10001, 10002, 20000, 20001, 20002})); + + destination.fab(1).set_val(Real{-19}); + receiver.unpack(destination, sender_rank, buffer); + for (int component = 1; component <= 2; ++component) + for (int x = 0; x <= 2; ++x) + EXPECT_EQ(value_at(destination, 1, Index<2>{x, 0}, component), + value_for(Index<2>{x, 0}, component)); +} + +TEST(test_nd_translation_schedule, replicated_dim1_and_deep_dim3_periodic_replay_are_local_only) { + const Box<1> line_domain{Index<1>{0}, Index<1>{2}}; + const BoxArray<1> line_layout(std::vector>{line_domain}); + const RankSpace<1> line_ranks{Index<1>{-3}, Extent<1>{1}}; + const auto line_distribution = Distribution<1>::replicated(line_layout, line_ranks); + MultiFab<1> line(line_layout, line_distribution, Index<1>{-3}, 2, Extent<1>{1}); + fill_valid(line); + TranslationSchedule<1> line_schedule( + line_layout, line_distribution, line_domain, PeriodicTopology<1>::axis_translations({true}), + Extent<1>{1}, 2, 1, 1, Index<1>{-3}, {3}, kHashBudget, schedule_budget<1>()); + EXPECT_FALSE(line_schedule.local_jobs().empty()); + EXPECT_EQ(line_schedule.send_plan_count(), 0U); + EXPECT_EQ(line_schedule.receive_plan_count(), 0U); + line_schedule.replay(line); + EXPECT_EQ(value_at(line, 0, Index<1>{-1}, 1), value_for(Index<1>{2}, 1)); + EXPECT_EQ(value_at(line, 0, Index<1>{3}, 1), value_for(Index<1>{0}, 1)); + + const Box<3> point{Index<3>{0, 0, 0}, Index<3>{0, 0, 0}}; + const BoxArray<3> volume_layout(std::vector>{point}); + const RankSpace<3> volume_ranks{Index<3>{1, -2, 7}, Extent<3>{1, 1, 1}}; + const auto volume_distribution = Distribution<3>::replicated(volume_layout, volume_ranks); + TranslationSchedule<3> volume_schedule(volume_layout, volume_distribution, point, + PeriodicTopology<3>::axis_translations({true, true, true}), + Extent<3>{2, 2, 2}, 1, 0, 1, Index<3>{1, -2, 7}, {1, 1, 1}, + kHashBudget, schedule_budget<3>(256)); + ASSERT_EQ(volume_schedule.global_job_count(), 124U); + ASSERT_EQ(volume_schedule.local_job_count(), 124U); + EXPECT_EQ(volume_schedule.send_plan_count(), 0U); + EXPECT_EQ(volume_schedule.receive_plan_count(), 0U); + for (std::size_t job = 0; job < volume_schedule.local_jobs().size(); ++job) + EXPECT_EQ(volume_schedule.local_jobs()[job].ordinal, job); + MultiFab<3> volume(volume_layout, volume_distribution, Index<3>{1, -2, 7}, 1, Extent<3>{2, 2, 2}); + fill_valid(volume); + volume_schedule.replay(volume); + EXPECT_EQ(value_at(volume, 0, Index<3>{-2, 2, -1}, 0), value_for(Index<3>{0, 0, 0}, 0)); +} + +TEST(test_nd_translation_schedule, peer_plans_sort_in_rank_space_order_and_budgets_are_cumulative) { + const Box<1> domain{Index<1>{0}, Index<1>{2}}; + const BoxArray<1> layout(std::vector>{Box<1>{Index<1>{0}, Index<1>{0}}, + Box<1>{Index<1>{1}, Index<1>{1}}, + Box<1>{Index<1>{2}, Index<1>{2}}}); + const RankSpace<1> ranks{Index<1>{0}, Extent<1>{3}}; + const Index<1> local{1}; + const auto distribution = + Distribution<1>::partitioned(layout, ranks, {Index<1>{2}, local, Index<1>{0}}); + TranslationSchedule<1> schedule(layout, distribution, domain, PeriodicTopology<1>{}, Extent<1>{1}, + 1, 0, 1, local, {1}, kHashBudget, schedule_budget<1>()); + ASSERT_EQ(schedule.send_plan_count(), 2U); + ASSERT_EQ(schedule.receive_plan_count(), 2U); + EXPECT_EQ(schedule.send_plans()[0].peer, (Index<1>{0})); + EXPECT_EQ(schedule.send_plans()[1].peer, (Index<1>{2})); + EXPECT_EQ(schedule.receive_plans()[0].peer, (Index<1>{0})); + EXPECT_EQ(schedule.receive_plans()[1].peer, (Index<1>{2})); + EXPECT_THROW((void)TranslationSchedule<1>(layout, distribution, domain, PeriodicTopology<1>{}, + Extent<1>{1}, 1, 0, 1, local, {1}, kHashBudget, + schedule_budget<1>(32, 3)), + std::length_error); + EXPECT_THROW((void)TranslationSchedule<1>(layout, distribution, domain, PeriodicTopology<1>{}, + Extent<1>{1}, 1, 0, 1, local, {1}, kHashBudget, + schedule_budget<1>(32, 8, 8, 1, 8)), + std::length_error); + EXPECT_THROW((void)TranslationSchedule<1>(layout, distribution, domain, PeriodicTopology<1>{}, + Extent<1>{1}, 1, 0, 1, local, {1}, kHashBudget, + schedule_budget<1>(32, 8, 8, 8, 1)), + std::length_error); + const auto replicated = Distribution<1>::replicated(layout, ranks); + EXPECT_THROW( + (void)TranslationSchedule<1>(layout, replicated, domain, PeriodicTopology<1>{}, Extent<1>{1}, + 1, 0, 1, local, {1}, kHashBudget, schedule_budget<1>(32, 0, 1)), + std::length_error); + EXPECT_THROW((void)TranslationSchedule<1>(layout, distribution, domain, PeriodicTopology<1>{}, + Extent<1>{1}, 1, 0, 1, local, {1}, kHashBudget, + schedule_budget<1>(0)), + std::length_error); +} + +TEST(test_nd_translation_schedule, identity_and_buffer_refusals_leave_caller_storage_unchanged) { + const Box<1> domain{Index<1>{0}, Index<1>{3}}; + const BoxArray<1> layout( + std::vector>{Box<1>{Index<1>{0}, Index<1>{1}}, Box<1>{Index<1>{2}, Index<1>{3}}}); + const RankSpace<1> ranks{Index<1>{0}, Extent<1>{2}}; + const auto distribution = Distribution<1>::partitioned(layout, ranks, {Index<1>{0}, Index<1>{1}}); + TranslationSchedule<1> sender(layout, distribution, domain, PeriodicTopology<1>{}, Extent<1>{1}, + 2, 1, 1, Index<1>{0}, {1}, kHashBudget, schedule_budget<1>()); + TranslationSchedule<1> receiver(layout, distribution, domain, PeriodicTopology<1>{}, Extent<1>{1}, + 2, 1, 1, Index<1>{1}, {1}, kHashBudget, schedule_budget<1>()); + MultiFab<1> source(layout, distribution, Index<1>{0}, 2, Extent<1>{1}); + MultiFab<1> destination(layout, distribution, Index<1>{1}, 2, Extent<1>{1}); + fill_valid(source); + fill_valid(destination); + const std::size_t elements = sender.send_plan(Index<1>{1}).elements; + TranslationSchedule<1>::buffer_type buffer("refusal_buffer", elements); + Kokkos::deep_copy(buffer, Real{42}); + const std::vector original_buffer = snapshot_buffer(buffer); + const std::vector original_destination = snapshot(destination); + + TranslationSchedule<1>::buffer_type wrong("wrong_buffer", elements + 1); + Kokkos::deep_copy(wrong, Real{17}); + const std::vector original_wrong = snapshot_buffer(wrong); + EXPECT_THROW(sender.pack(source, Index<1>{1}, wrong), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(wrong), original_wrong); + EXPECT_THROW(sender.pack(source, Index<1>{0}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + EXPECT_THROW(receiver.unpack(destination, Index<1>{0}, wrong), std::invalid_argument); + EXPECT_EQ(snapshot(destination), original_destination); + EXPECT_EQ(snapshot_buffer(wrong), original_wrong); + + const BoxArray<1> regridded( + std::vector>{Box<1>{Index<1>{0}, Index<1>{0}}, Box<1>{Index<1>{1}, Index<1>{3}}}); + const auto regridded_distribution = + Distribution<1>::partitioned(regridded, ranks, {Index<1>{0}, Index<1>{1}}); + MultiFab<1> layout_stale(regridded, regridded_distribution, Index<1>{0}, 2, Extent<1>{1}); + fill_valid(layout_stale); + EXPECT_THROW(sender.pack(layout_stale, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + const BoxArray<1> reordered(std::vector>{layout[1], layout[0]}); + const auto reordered_distribution = + Distribution<1>::partitioned(reordered, ranks, {Index<1>{0}, Index<1>{1}}); + MultiFab<1> reordered_stale(reordered, reordered_distribution, Index<1>{0}, 2, Extent<1>{1}); + fill_valid(reordered_stale); + EXPECT_THROW(sender.pack(reordered_stale, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + const auto changed_owners = + Distribution<1>::partitioned(layout, ranks, {Index<1>{1}, Index<1>{0}}); + MultiFab<1> owner_stale(layout, changed_owners, Index<1>{0}, 2, Extent<1>{1}); + fill_valid(owner_stale); + EXPECT_THROW(sender.pack(owner_stale, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + MultiFab<1> rank_stale(layout, distribution, Index<1>{1}, 2, Extent<1>{1}); + fill_valid(rank_stale); + EXPECT_THROW(sender.pack(rank_stale, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + MultiFab<1> ghosts_stale(layout, distribution, Index<1>{0}, 2, Extent<1>{2}); + fill_valid(ghosts_stale); + EXPECT_THROW(sender.pack(ghosts_stale, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + MultiFab<1> ncomp_stale(layout, distribution, Index<1>{0}, 3, Extent<1>{1}); + fill_valid(ncomp_stale); + EXPECT_THROW(sender.pack(ncomp_stale, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + const auto replicated = Distribution<1>::replicated(layout, ranks); + MultiFab<1> mode_stale(layout, replicated, Index<1>{0}, 2, Extent<1>{1}); + fill_valid(mode_stale); + EXPECT_THROW(sender.pack(mode_stale, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot_buffer(buffer), original_buffer); + + destination.fab(1).set_val(Real{-5}); + const std::vector before_unpack = snapshot(destination); + EXPECT_THROW(receiver.unpack(destination, Index<1>{1}, buffer), std::invalid_argument); + EXPECT_EQ(snapshot(destination), before_unpack); + const std::vector before_replay = snapshot(destination); + EXPECT_THROW(sender.replay(destination), std::invalid_argument); + EXPECT_EQ(snapshot(destination), before_replay); +} + +TEST(test_nd_translation_schedule, metadata_and_large_3d_element_overflow_fail_before_storage) { + const Box<1> domain{Index<1>{0}, Index<1>{1}}; + const BoxArray<1> layout( + std::vector>{Box<1>{Index<1>{0}, Index<1>{0}}, Box<1>{Index<1>{1}, Index<1>{1}}}); + const RankSpace<1> ranks{Index<1>{0}, Extent<1>{2}}; + const auto distribution = Distribution<1>::partitioned(layout, ranks, {Index<1>{0}, Index<1>{1}}); + const auto good = schedule_budget<1>(); + EXPECT_THROW( + (void)TranslationSchedule<1>(layout, distribution, Box<1>{}, PeriodicTopology<1>{}, + Extent<1>{1}, 1, 0, 1, Index<1>{0}, {1}, kHashBudget, good), + std::invalid_argument); + EXPECT_THROW( + (void)TranslationSchedule<1>(layout, distribution, domain, PeriodicTopology<1>{}, + Extent<1>{-1}, 1, 0, 1, Index<1>{0}, {1}, kHashBudget, good), + std::invalid_argument); + EXPECT_THROW( + (void)TranslationSchedule<1>(layout, distribution, domain, PeriodicTopology<1>{}, + Extent<1>{1}, 1, 1, 1, Index<1>{0}, {1}, kHashBudget, good), + std::invalid_argument); + EXPECT_THROW( + (void)TranslationSchedule<1>(layout, distribution, domain, PeriodicTopology<1>{}, + Extent<1>{1}, 1, 0, 1, Index<1>{2}, {1}, kHashBudget, good), + std::invalid_argument); + const Box<2> plane{Index<2>{0, 0}, Index<2>{1, 1}}; + const BoxArray<2> plane_layout(std::vector>{plane}); + const RankSpace<2> plane_ranks{Index<2>{0, 0}, Extent<2>{1, 1}}; + const auto plane_distribution = Distribution<2>::replicated(plane_layout, plane_ranks); + const PeriodicTopology<2> mapped{std::vector>{PeriodicIdentification<2>{ + Face<2>{0, Side::lower}, Face<2>{1, Side::upper}, SignedPermutation<2>{{1, 0}, {1, -1}}}}}; + EXPECT_THROW((void)TranslationSchedule<2>(plane_layout, plane_distribution, plane, mapped, + Extent<2>{1, 1}, 1, 0, 1, Index<2>{0, 0}, {2, 2}, + kHashBudget, schedule_budget<2>()), + std::invalid_argument); + + constexpr int minimum = std::numeric_limits::min(); + constexpr int maximum = std::numeric_limits::max(); + const Box<3> huge_domain{Index<3>{0, minimum, minimum}, Index<3>{1, maximum, maximum}}; + const BoxArray<3> huge_layout( + std::vector>{Box<3>{Index<3>{0, minimum, minimum}, Index<3>{0, maximum, maximum}}, + Box<3>{Index<3>{1, minimum, minimum}, Index<3>{1, maximum, maximum}}}); + const RankSpace<3> huge_ranks{Index<3>{0, 0, 0}, Extent<3>{1, 1, 1}}; + const auto huge_distribution = Distribution<3>::replicated(huge_layout, huge_ranks); + const Box<3> execution_domain{Index<3>{0, minimum, 0}, Index<3>{1, maximum, 1073741823}}; + const BoxArray<3> execution_layout( + std::vector>{Box<3>{Index<3>{0, minimum, 0}, Index<3>{0, maximum, 1073741823}}, + Box<3>{Index<3>{1, minimum, 0}, Index<3>{1, maximum, 1073741823}}}); + const auto execution_distribution = Distribution<3>::replicated(execution_layout, huge_ranks); + EXPECT_THROW((void)TranslationSchedule<3>( + execution_layout, execution_distribution, execution_domain, + PeriodicTopology<3>{}, Extent<3>{1, 0, 0}, 3, 0, 3, Index<3>{0, 0, 0}, + {maximum, maximum, maximum}, BoxHashBudget{64, 64, 64}, schedule_budget<3>(32)), + std::overflow_error); + EXPECT_THROW((void)TranslationSchedule<3>(huge_layout, huge_distribution, huge_domain, + PeriodicTopology<3>{}, Extent<3>{1, 0, 0}, 2, 0, 2, + Index<3>{0, 0, 0}, {maximum, maximum, maximum}, + BoxHashBudget{64, 64, 64}, schedule_budget<3>(32)), + std::overflow_error); +} diff --git a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index d262516c5..f8fbe91ff 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -1,11 +1,15 @@ #include +#include #include #include #include #include #include +#include +#include +#include #include #include @@ -18,23 +22,42 @@ MultiFab scalar_field(const Box2D& domain, int ncomp = 1, int ngrow = 0) { return MultiFab(boxes, DistributionMapping(boxes.size(), n_ranks()), ncomp, ngrow); } -BCRec physical_bc() { - BCRec bc; - bc.xlo = BCType::Foextrap; - bc.xhi = BCType::Dirichlet; - bc.xhi_val = Real(4); - bc.ylo = BCType::Foextrap; - bc.yhi = BCType::Foextrap; - return bc; +PreparedHyperbolicBoundary<2> physical_boundary(std::vector xhi_values = {4.0}, + std::vector roles = {"Scalar"}) { + std::vector values; + values.reserve(4 * xhi_values.size()); + for (double value : xhi_values) + values.insert(values.end(), {0.0, value, 0.0, 0.0}); + return prepare_hyperbolic_boundary<2>({"foextrap", "dirichlet", "foextrap", "foextrap"}, values, + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, + roles); } -BCRec reflected_x_periodic_bc() { - BCRec bc; - bc.xlo = BCType::Periodic; - bc.xhi = BCType::Periodic; - bc.ylo = BCType::Foextrap; - bc.yhi = BCType::Foextrap; - return bc; +PreparedHyperbolicBoundary<2> periodic_boundary( + std::vector face_types = {"periodic", "periodic", "foextrap", "foextrap"}, + bool explicit_identifications = false) { + if (face_types.empty()) + face_types = {"periodic", "periodic", "foextrap", "foextrap"}; + return prepare_hyperbolic_boundary<2>( + face_types, std::vector(4, 0.0), + {"case::periodic::xlo", "case::periodic::xhi", "case::periodic::ylo", "case::periodic::yhi"}, + {"Scalar"}, explicit_identifications); +} + +PreparedHyperbolicBoundary<2> analytic_xlo_boundary( + std::vector opcodes = {"x", "y", "add", "input", "add"}, + std::vector literals = {0.0, 0.0, 0.0, 0.0, 0.0}, bool periodic_tangent = false) { + const bool reads_time = std::find(opcodes.begin(), opcodes.end(), "input") != opcodes.end(); + return prepare_hyperbolic_boundary<2>( + periodic_tangent ? std::vector{"dirichlet", "foextrap", "periodic", "periodic"} + : std::vector{"dirichlet", "foextrap", "foextrap", "foextrap"}, + std::vector(4, 0.0), + {"case::analytic::xlo", "case::analytic::xhi", "case::analytic::ylo", "case::analytic::yhi"}, + {"Scalar"}, false, {}, {}, + {std::move(opcodes), std::vector{}, std::vector{}, + std::vector{}}, + {std::move(literals), std::vector{}, std::vector{}, std::vector{}}, + {reads_time ? "clock.analytic" : "", "", "", ""}); } PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, std::string output) { @@ -65,22 +88,199 @@ PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, s return spec; } +RecoveryReport recover_positive_scalar(const double* conserved, double* primitive) { + RecoveryReport report; + if (std::isfinite(conserved[0]) && conserved[0] > 0.0) { + primitive[0] = conserved[0]; + report.status = RecoveryStatus::kRecovered; + report.cause = RecoveryCause::kNone; + } else { + report.status = RecoveryStatus::kRejected; + report.cause = RecoveryCause::kInadmissibleCandidate; + report.failing_component = 0; + } + return report; +} + +struct TwoModeCharacteristicModel { + static constexpr int n_vars = 2; + using State = StateVec; + + POPS_HD bool characteristic_no_inflow(const State& interior, const State& reference, int axis, + int outward_sign, State& ghost) const { + if (axis != 0 || outward_sign != -1) + return false; + ghost[0] = Real(2) * reference[0] - interior[0]; + ghost[1] = interior[1]; + return true; + } +}; + +struct RefusingCharacteristicModel { + static constexpr int n_vars = 2; + using State = StateVec; + + POPS_HD bool characteristic_no_inflow(const State&, const State&, int, int, State&) const { + return false; + } +}; + +PreparedHyperbolicBoundary<2> characteristic_boundary() { + return prepare_hyperbolic_boundary<2>( + {"characteristic_no_inflow", "foextrap", "foextrap", "foextrap"}, + {10.0, 0.0, 0.0, 0.0, 20.0, 0.0, 0.0, 0.0}, + {"case::characteristic::xlo", "case::characteristic::xhi", "case::characteristic::ylo", + "case::characteristic::yhi"}, + {"Scalar", "Scalar"}); +} + } // namespace +TEST(test_prepared_boundary_plan, executes_prepared_model_characteristics_without_scalar_fallback) { + const Box2D domain = Box2D::from_extents(4, 3); + MultiFab state = scalar_field(domain, 2, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + }); + } + auto boundary = characteristic_boundary(); + PreparedBoundaryPlan plan("case::characteristic", 1, boundary); + EXPECT_THROW(plan.fill_same_level_and_physical(state, domain), std::runtime_error); + + plan.prepare_characteristic_no_inflow( + detail::make_characteristic_no_inflow_fill(TwoModeCharacteristicModel{}, boundary)); + ASSERT_NO_THROW(plan.fill_same_level_and_physical(state, domain)); + state.sync_host(); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& values = state.fab(local); + if (values.grown_box().contains(domain.lo[0] - 1, 1)) { + EXPECT_EQ(values(domain.lo[0] - 1, 1, 0), Real(19)); + EXPECT_EQ(values(domain.lo[0] - 1, 1, 1), Real(2)); + } + } +} + +TEST(test_prepared_boundary_plan, rolls_back_every_ghost_when_characteristic_preflight_refuses) { + const Box2D domain = Box2D::from_extents(4, 3); + MultiFab state = scalar_field(domain, 2, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + }); + } + auto boundary = characteristic_boundary(); + PreparedBoundaryPlan plan("case::characteristic-refusal", 1, boundary); + plan.prepare_characteristic_no_inflow( + detail::make_characteristic_no_inflow_fill(RefusingCharacteristicModel{}, boundary)); + + EXPECT_THROW(plan.fill_same_level_and_physical(state, domain), std::runtime_error); + state.sync_host(); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& values = state.fab(local); + if (values.grown_box().contains(domain.lo[0] - 1, 1)) + EXPECT_EQ(values(domain.lo[0] - 1, 1, 0), Real(-99)); + if (values.grown_box().contains(domain.hi[0] + 1, 1)) + EXPECT_EQ(values(domain.hi[0] + 1, 1, 0), Real(-99)); + } +} + +TEST(PreparedBoundaryTraceRecovery, + accepts_admissible_physical_traces_without_hot_path_allocation) { + const Box2D domain = Box2D::from_extents(4, 4); + const Geometry geometry(domain, Real(0), Real(1), Real(0), Real(1)); + MultiFab state = scalar_field(domain, 1, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(1); }); + } + device_fence(); + + auto plan = std::make_shared("case::boundary::recoverable-traces", 1, + physical_boundary({4.0}, {"Scalar"})); + plan->prepare_trace_recovery(recover_positive_scalar); + GridContext context; + context.dom = domain; + context.geom = geometry; + context.boundary_plan = plan; + const auto lane = ExecutionLane::world("case::boundary::recoverable-traces-lane"); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.boundary", 0, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession session(context, lane, state, point); + + session.fill(state, point); + const AllocationEventStats before = allocation_event_stats(); + session.fill(state, point); + const AllocationEventStats after = allocation_event_stats(); + + EXPECT_EQ(after, before); + if (state.local_size() > 0) { + state.sync_host(); + EXPECT_EQ(state.fab(0)(domain.hi[0] + 1, 2, 0), Real(7)); + } +} + +TEST(PreparedBoundaryTraceRecovery, + rejects_inadmissible_traces_and_restores_complete_ghost_transaction) { + const Box2D domain = Box2D::from_extents(4, 4); + const Geometry geometry(domain, Real(0), Real(1), Real(0), Real(1)); + MultiFab state = scalar_field(domain, 1, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(1); }); + } + device_fence(); + const MultiFab before = state; + + auto plan = std::make_shared("case::boundary::rejected-traces", 1, + physical_boundary({0.0}, {"Scalar"})); + plan->prepare_trace_recovery(recover_positive_scalar); + GridContext context; + context.dom = domain; + context.geom = geometry; + context.boundary_plan = plan; + const auto lane = ExecutionLane::world("case::boundary::rejected-traces-lane"); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.boundary", 0, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession session(context, lane, state, point); + + EXPECT_THROW(session.fill(state, point), std::runtime_error); + state.sync_host(); + before.sync_host(); + ASSERT_EQ(state.local_size(), before.local_size()); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& observed = state.fab(local); + const Fab2D& expected = before.fab(local); + const Box2D grown = observed.grown_box(); + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + EXPECT_EQ(observed(i, j, 0), expected(i, j, 0)) + << "rejected trace mutated local fab " << local << " at (" << i << ", " << j << ")"; + } +} + TEST(test_prepared_boundary_plan, explicit_read_dependencies_are_exact_and_strict) { PreparedBoundaryPlan plan( - "case::boundary::read-dependencies", 1, {physical_bc()}, {}, "case::state::primary", + "case::boundary::read-dependencies", 1, physical_boundary(), {}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::other"}, {"case::field::potential"}}); EXPECT_EQ(plan.required_state_identities(), std::vector{"case::state::other"}); EXPECT_EQ(plan.required_field_identities(), std::vector{"case::field::potential"}); EXPECT_THROW( PreparedBoundaryPlan( - "case::boundary::duplicate-state", 1, {physical_bc()}, {}, "case::state::primary", + "case::boundary::duplicate-state", 1, physical_boundary(), {}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::other", "case::state::other"}, {}}), std::runtime_error); EXPECT_THROW( - PreparedBoundaryPlan("case::boundary::empty-field", 1, {physical_bc()}, {}, + PreparedBoundaryPlan("case::boundary::empty-field", 1, physical_boundary(), {}, "case::state::primary", PreparedBoundaryReadDependencies{{}, {""}}), std::runtime_error); } @@ -91,11 +291,11 @@ TEST(test_prepared_boundary_plan, prepared_read_tokens_are_owner_bound_and_epoch MultiFab coupled = scalar_field(domain, 1, 1); MultiFab auxiliary = scalar_field(domain, 1, 0); auto plan = std::make_shared( - "case::boundary::prepared-reads", 1, std::vector{physical_bc()}, std::vector{}, + "case::boundary::prepared-reads", 1, physical_boundary(), std::vector{}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::coupled"}, {"case::field::auxiliary"}}); auto foreign_plan = std::make_shared( - "case::boundary::foreign-reads", 1, std::vector{physical_bc()}, std::vector{}, + "case::boundary::foreign-reads", 1, physical_boundary(), std::vector{}, "case::state::primary", PreparedBoundaryReadDependencies{{"case::state::coupled"}, {}}); const auto coupled_read = plan->prepare_state_read("case::state::coupled"); const auto auxiliary_read = plan->prepare_field_read("case::field::auxiliary"); @@ -139,10 +339,8 @@ TEST(test_prepared_boundary_plan, executes_same_level_and_component_physical_pro values(i, j, 1) = Real(2); }); } - BCRec first = physical_bc(); - BCRec second = physical_bc(); - second.xhi_val = Real(9); - PreparedBoundaryPlan plan("case::block::ghost-plan", 1, {first, second}); + PreparedBoundaryPlan plan("case::block::ghost-plan", 1, + physical_boundary({4.0, 9.0}, {"Scalar", "Scalar"})); plan.fill_same_level_and_physical(state, domain); @@ -153,6 +351,311 @@ TEST(test_prepared_boundary_plan, executes_same_level_and_component_physical_pro EXPECT_EQ(field(4, 2, 1), Real(16)); // 2*9 - interior(2) } +TEST(test_prepared_boundary_plan, + evaluates_prepared_coordinate_time_inflow_on_device_without_hot_path_allocation) { + const Box2D domain = Box2D::from_extents(4, 3); + const Geometry geometry(domain, Real(1), Real(5), Real(0), Real(3)); + MultiFab state = scalar_field(domain, 1, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(2); }); + } + PreparedBoundaryPlan plan("case::analytic::plan", 1, analytic_xlo_boundary()); + const auto lane = ExecutionLane::world("case::analytic::lane"); + auto session = plan.make_session(lane); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.analytic", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}; + + EXPECT_THROW(session.fill_same_level_and_physical(state, domain), std::logic_error); + EXPECT_THROW(session.fill_same_level_and_physical( + state, geometry, + runtime::multiblock::BoundaryEvaluationPoint{"clock.other", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}), + std::invalid_argument); + if (state.local_size() > 0) + EXPECT_EQ(state.fab(0)(-1, 1, 0), Real(-99)); + + session.fill_same_level_and_physical(state, geometry, point); + if (state.local_size() > 0) + EXPECT_EQ(state.fab(0)(-1, 1, 0), Real(3.5)); + const AllocationEventStats before = allocation_event_stats(); + session.fill_same_level_and_physical(state, geometry, point); + const AllocationEventStats after = allocation_event_stats(); + EXPECT_EQ(after, before); +} + +TEST(test_prepared_boundary_plan, analytic_inflow_preflights_nonfinite_values_before_any_mutation) { + const Box2D domain = Box2D::from_extents(4, 3); + const Geometry geometry(domain, Real(0), Real(4), Real(0), Real(3)); + const BoxArray boxes = BoxArray::from_domain(domain, 2); + MultiFab state(boxes, DistributionMapping(boxes.size(), n_ranks()), 1, 1); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(2 + i + 10 * j); }); + } + device_fence(); + const MultiFab before = state; + PreparedBoundaryPlan plan("case::analytic::invalid-plan", 1, + analytic_xlo_boundary({"constant", "log"}, {-1.0, 0.0}, true)); + const auto lane = ExecutionLane::world("case::analytic::invalid-lane"); + auto session = plan.make_session(lane); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.analytic", 1, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.25}; + + EXPECT_THROW(session.fill_same_level_and_physical(state, geometry, point), std::runtime_error); + state.sync_host(); + before.sync_host(); + ASSERT_EQ(state.local_size(), before.local_size()); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& observed = state.fab(local); + const Fab2D& expected = before.fab(local); + const Box2D grown = observed.grown_box(); + for (int j = grown.lo[1]; j <= grown.hi[1]; ++j) + for (int i = grown.lo[0]; i <= grown.hi[0]; ++i) + EXPECT_EQ(observed(i, j, 0), expected(i, j, 0)) + << "analytic refusal mutated local fab " << local << " at (" << i << ", " << j << ")"; + } +} + +TEST(test_prepared_boundary_plan, analytic_inflow_authenticates_one_clock_and_time_slot_per_plan) { + const auto face_types = + std::vector{"dirichlet", "foextrap", "dirichlet", "foextrap"}; + const auto face_values = std::vector(4, 0.0); + const auto face_identities = std::vector{ + "case::analytic::xlo", "case::analytic::xhi", "case::analytic::ylo", "case::analytic::yhi"}; + const auto roles = std::vector{"Scalar"}; + const auto opcodes = std::vector>{{"input"}, {}, {"input"}, {}}; + const auto literals = std::vector>{{0.0}, {}, {0.0}, {}}; + + EXPECT_THROW( + prepare_hyperbolic_boundary<2>(face_types, face_values, face_identities, roles, false, {}, {}, + opcodes, literals, {"clock.first", "", "clock.second", ""}), + std::invalid_argument); + EXPECT_THROW( + prepare_hyperbolic_boundary<2>({"dirichlet", "foextrap", "foextrap", "foextrap"}, face_values, + face_identities, roles, false, {}, {}, {{"input"}, {}, {}, {}}, + {{1.0}, {}, {}, {}}, {"clock.first", "", "", ""}), + std::invalid_argument); + auto ambiguous_values = face_values; + ambiguous_values[0] = 1.0; + EXPECT_THROW( + prepare_hyperbolic_boundary<2>({"dirichlet", "foextrap", "foextrap", "foextrap"}, + ambiguous_values, face_identities, roles, false, {}, {}, + {{"x"}, {}, {}, {}}, {{0.0}, {}, {}, {}}, {"", "", "", ""}), + std::invalid_argument); + EXPECT_THROW(prepare_hyperbolic_boundary<2>(face_types, face_values, face_identities, roles, + false, {}, {}, {{}, {}, {}, {}}, {{}, {}, {}, {}}, + {"clock.without-program", "", "", ""}), + std::invalid_argument); +} + +TEST(test_prepared_boundary_plan, + no_flux_uses_prepared_extrapolation_and_marks_only_its_post_riemann_face) { + const Box2D domain = Box2D::from_extents(3, 3); + MultiFab state = scalar_field(domain, 1, 1); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(10 * j + i + 1); }); + } + auto boundary = prepare_hyperbolic_boundary<2>( + {"no_flux", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::closed::xlo", "case::closed::xhi", "case::closed::ylo", "case::closed::yhi"}, + {"Scalar"}); + PreparedBoundaryPlan plan("case::closed::plan", 1, std::move(boundary)); + + EXPECT_TRUE(plan.has_zero_flux_faces()); + EXPECT_TRUE(plan.zeroes_face(0, -1)); + EXPECT_FALSE(plan.zeroes_face(0, 1)); + EXPECT_FALSE(plan.zeroes_face(1, -1)); + EXPECT_FALSE(plan.zeroes_face(1, 1)); + plan.fill_same_level_and_physical(state, domain); + state.sync_host(); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& values = state.fab(local); + const Box2D valid = state.box(local); + if (valid.lo[0] != domain.lo[0]) + continue; + for (int j = valid.lo[1]; j <= valid.hi[1]; ++j) + EXPECT_EQ(values(domain.lo[0] - 1, j, 0), values(domain.lo[0], j, 0)); + } + + EXPECT_THROW( + prepare_hyperbolic_boundary<2>( + {"no_flux", "foextrap", "foextrap", "foextrap"}, {1.0, 0.0, 0.0, 0.0}, + {"case::bad::xlo", "case::bad::xhi", "case::bad::ylo", "case::bad::yhi"}, {"Scalar"}), + std::invalid_argument); + EXPECT_THROW(PreparedBoundaryPlan( + "case::closed::interface-conflict", 1, + prepare_hyperbolic_boundary<2>({"no_flux", "foextrap", "foextrap", "foextrap"}, + std::vector(4, 0.0), + {"case::conflict::xlo", "case::conflict::xhi", + "case::conflict::ylo", "case::conflict::yhi"}, + {"Scalar"}), + {0}), + std::invalid_argument); +} + +TEST(test_prepared_boundary_plan, + converts_primitive_fixed_state_once_before_conservative_face_execution) { + const Box2D domain = Box2D::from_extents(4, 4); + MultiFab state = scalar_field(domain, 4, 1); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + for (int component = 0; component < 4; ++component) + values(i, j, component) = Real(1); + }); + } + std::vector face_values; + for (const double primitive : {2.0, 3.0, -1.0, 4.0}) + face_values.insert(face_values.end(), {0.0, primitive, 0.0, 0.0}); + auto boundary = prepare_hyperbolic_boundary<2>( + {"foextrap", "dirichlet", "foextrap", "foextrap"}, face_values, + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY", "Energy"}, false, + {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::fluid::model-p2c", "", ""}); + PreparedBoundaryPlan plan("case::fluid::primitive-inflow", 1, std::move(boundary)); + const auto lane = ExecutionLane::world("case::fluid::primitive-inflow-lane"); + auto stale_session = plan.make_session(lane); + + EXPECT_TRUE(plan.requires_fixed_state_conversion()); + EXPECT_THROW(plan.fill_same_level_and_physical(state, domain), std::logic_error); + plan.prepare_fixed_state_conversion([](const double* primitive, double* conservative) { + constexpr double gamma = 1.4; + conservative[0] = primitive[0]; + conservative[1] = primitive[0] * primitive[1]; + conservative[2] = primitive[0] * primitive[2]; + conservative[3] = + primitive[3] / (gamma - 1.0) + + 0.5 * primitive[0] * (primitive[1] * primitive[1] + primitive[2] * primitive[2]); + }); + + EXPECT_FALSE(plan.requires_fixed_state_conversion()); + const auto& prepared_xhi = plan.hyperbolic_boundary().face(0, 1); + EXPECT_EQ(prepared_xhi.authored_representation, HyperbolicStateRepresentation::Primitive); + EXPECT_EQ(prepared_xhi.converter_identity, "case::fluid::model-p2c"); + EXPECT_TRUE(prepared_xhi.fixed_state_converted); + EXPECT_THROW(stale_session.fill_same_level_and_physical(state, domain), std::logic_error); + plan.fill_same_level_and_physical(state, domain); + const Fab2D& field = state.fab(0); + EXPECT_EQ(field(4, 2, 0), Real(3)); + EXPECT_EQ(field(4, 2, 1), Real(11)); + EXPECT_EQ(field(4, 2, 2), Real(-5)); + EXPECT_NEAR(field(4, 2, 3), Real(39), Real(1e-12)); +} + +TEST(test_prepared_boundary_plan, primitive_fixed_state_conversion_is_transactional_and_finite) { + auto make_plan = [] { + return PreparedBoundaryPlan( + "case::fluid::nonfinite-inflow", 1, + prepare_hyperbolic_boundary<2>( + {"foextrap", "dirichlet", "foextrap", "foextrap"}, {0.0, 2.0, 0.0, 0.0}, + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Scalar"}, false, {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::fluid::model-p2c", "", ""})); + }; + auto plan = make_plan(); + EXPECT_THROW(plan.prepare_fixed_state_conversion([](const double*, double* conservative) { + conservative[0] = std::numeric_limits::quiet_NaN(); + }), + std::runtime_error); + EXPECT_TRUE(plan.requires_fixed_state_conversion()); + + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"foextrap", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Scalar"}, false, + {"primitive", "conservative", "conservative", "conservative"}, + {"case::fluid::model-p2c", "", "", ""}), + std::invalid_argument); +} + +TEST(test_prepared_boundary_plan, + primitive_conversion_preserves_explicit_periodic_identification_validation) { + auto boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "dirichlet", "foextrap", "periodic"}, {0.0, 2.0, 0.0, 0.0}, + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, {"Scalar"}, + true, {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::fluid::model-p2c", "", ""}); + + EXPECT_TRUE(boundary.requires_fixed_state_conversion()); + const auto converted = boundary.with_converted_fixed_states( + [](const double* primitive, double* conservative) { conservative[0] = primitive[0]; }); + EXPECT_FALSE(converted.requires_fixed_state_conversion()); +} + +TEST(test_prepared_boundary_plan, + model_aware_slip_wall_handles_multiple_normal_and_out_of_plane_components) { + const Box2D domain = Box2D::from_extents(4, 4); + MultiFab state = scalar_field(domain, 5, 2); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(5); + values(i, j, 3) = Real(3); + values(i, j, 4) = Real(4); + }); + } + auto boundary = prepare_hyperbolic_boundary<2>( + {"slip_wall", "slip_wall", "slip_wall", "slip_wall"}, std::vector(20, 0.0), + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Density", "MomentumX", "MomentumX", "MomentumY", "AxialZ"}); + PreparedBoundaryPlan plan("case::fluid::slip-plan", 2, std::move(boundary)); + + plan.fill_same_level_and_physical(state, domain); + + const Fab2D& field = state.fab(0); + EXPECT_EQ(field(-1, 2, 0), Real(1)); + EXPECT_EQ(field(-1, 2, 1), Real(-2)); + EXPECT_EQ(field(-1, 2, 2), Real(-5)); + EXPECT_EQ(field(-1, 2, 3), Real(3)); + EXPECT_EQ(field(-1, 2, 4), Real(-4)); + EXPECT_EQ(field(-2, 2, 1), Real(-2)); + EXPECT_EQ(field(2, -1, 1), Real(2)); + EXPECT_EQ(field(2, -1, 2), Real(5)); + EXPECT_EQ(field(2, -1, 3), Real(-3)); + EXPECT_EQ(field(2, -1, 4), Real(-4)); + EXPECT_EQ(field(2, -2, 3), Real(-3)); +} + +TEST(test_prepared_boundary_plan, polar_and_axial_reflections_are_distinct_in_1d_2d_3d_frames) { + const auto polar_1d = HyperbolicComponentTransform<1>::polar_vector(0); + EXPECT_EQ(polar_1d.reflection_sign(0), Real(-1)); + + const auto polar_normal_2d = HyperbolicComponentTransform<2>::polar_vector(0); + const auto polar_tangent_2d = HyperbolicComponentTransform<2>::polar_vector(1); + const auto polar_out_of_plane_2d = HyperbolicComponentTransform<2>::polar_vector(2); + const auto axial_normal_2d = HyperbolicComponentTransform<2>::axial_vector(0); + const auto axial_tangent_2d = HyperbolicComponentTransform<2>::axial_vector(1); + const auto axial_out_of_plane_2d = HyperbolicComponentTransform<2>::axial_vector(2); + EXPECT_EQ(polar_normal_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(polar_tangent_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(polar_out_of_plane_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(polar_out_of_plane_2d.reflection_sign(1), Real(1)); + EXPECT_EQ(axial_normal_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_tangent_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(axial_out_of_plane_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(axial_out_of_plane_2d.reflection_sign(1), Real(-1)); + + const auto polar_z_3d = HyperbolicComponentTransform<3>::polar_vector(2); + const auto axial_z_3d = HyperbolicComponentTransform<3>::axial_vector(2); + EXPECT_EQ(polar_z_3d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_z_3d.reflection_sign(0), Real(-1)); + EXPECT_EQ(polar_z_3d.reflection_sign(2), Real(-1)); + EXPECT_EQ(axial_z_3d.reflection_sign(2), Real(1)); +} + +TEST(test_prepared_boundary_plan, slip_wall_fails_without_declared_normal_polar_role) { + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"slip_wall", "slip_wall", "foextrap", "foextrap"}, std::vector(8, 0.0), + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Density", "MomentumY"}), + std::invalid_argument); +} + TEST(test_prepared_boundary_plan, materializes_move_only_lane_session_before_execution) { static_assert(!std::is_copy_constructible_v); static_assert(!std::is_copy_assignable_v); @@ -164,7 +667,7 @@ TEST(test_prepared_boundary_plan, materializes_move_only_lane_session_before_exe Array4 values = state.fab(local).array(); for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(3); }); } - PreparedBoundaryPlan plan("case::block::session-plan", 1, {physical_bc()}); + PreparedBoundaryPlan plan("case::block::session-plan", 1, physical_boundary()); const auto lane = ExecutionLane::world("case::block::session-lane"); auto original = plan.make_session(lane); auto session = std::move(original); @@ -175,65 +678,22 @@ TEST(test_prepared_boundary_plan, materializes_move_only_lane_session_before_exe EXPECT_EQ(state.fab(0)(4, 2, 0), Real(5)); } -TEST(test_prepared_boundary_plan, grid_sessions_apply_robin_with_each_level_geometry) { - const Box2D coarse_domain = Box2D::from_extents(2, 2); - const Box2D fine_domain = Box2D::from_extents(4, 4); - MultiFab coarse = scalar_field(coarse_domain, 1, 1); - MultiFab fine = scalar_field(fine_domain, 1, 1); - coarse.set_val(Real(2)); - fine.set_val(Real(2)); - - BCRec robin; - robin.xlo = BCType::Robin; - robin.xhi = BCType::Foextrap; - robin.ylo = BCType::Foextrap; - robin.yhi = BCType::Foextrap; - robin.xlo_alpha = Real(1); - robin.xlo_beta = Real(1); - robin.xlo_val = Real(0); - robin.dx = Real(37); // Deliberately not either level metric. - auto plan = std::make_shared("case::block::robin-plan", 1, - std::vector{robin}); - - // A Box2D has no physical metric. Keeping the historical overload for metric-independent laws - // is harmless, but Robin must never reuse the declaration-time placeholder spacing. - EXPECT_THROW(plan->fill_same_level_and_physical(coarse, coarse_domain), std::invalid_argument); - const auto metricless_lane = ExecutionLane::world("case::block::robin-metricless-lane"); - auto metricless_session = plan->make_session(metricless_lane); - EXPECT_THROW(metricless_session.fill_same_level_and_physical(coarse, coarse_domain), +TEST(test_prepared_boundary_plan, rejects_field_only_robin_as_transport_semantics) { + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"robin", "foextrap", "foextrap", "foextrap"}, {0.0, 0.0, 0.0, 0.0}, + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Scalar"}), std::invalid_argument); - - GridContext coarse_context; - coarse_context.dom = coarse_domain; - coarse_context.geom = Geometry(coarse_domain, Real(0), Real(1), Real(0), Real(1)); - coarse_context.boundary_plan = plan; - GridContext fine_context; - fine_context.dom = fine_domain; - fine_context.geom = Geometry(fine_domain, Real(0), Real(1), Real(0), Real(1)); - fine_context.boundary_plan = plan; - - const auto coarse_lane = ExecutionLane::world("case::block::robin-coarse-lane"); - const auto fine_lane = ExecutionLane::world("case::block::robin-fine-lane"); - PreparedGridBoundarySession coarse_session(coarse_context, coarse_lane); - PreparedGridBoundarySession fine_session(fine_context, fine_lane); - coarse_session.fill(coarse); - fine_session.fill(fine); - - // alpha=beta=1, value=0 gives u_g=((1/h)-1/2)/((1/h)+1/2) u_i. - EXPECT_EQ(plan->component_bc(0).dx, Real(37)); // Execution did not mutate shared authority. - EXPECT_NEAR(coarse.fab(0)(-1, 0, 0), Real(1.2), 1e-12); // h = 1/2 - EXPECT_NEAR(fine.fab(0)(-1, 0, 0), Real(14) / Real(9), 1e-12); // h = 1/4 } TEST(test_prepared_boundary_plan, rejects_incomplete_periodic_pairs_and_insufficient_ghosts) { - BCRec mixed = physical_bc(); - mixed.xlo = BCType::Periodic; - EXPECT_THROW(PreparedBoundaryPlan("case::bad-periodic::ghost-plan", 1, {mixed}), - std::runtime_error); + EXPECT_THROW(prepare_hyperbolic_boundary<2>( + {"periodic", "foextrap", "foextrap", "foextrap"}, {0.0, 0.0, 0.0, 0.0}, + {"case::xlo", "case::xhi", "case::ylo", "case::yhi"}, {"Scalar"}), + std::invalid_argument); const Box2D domain = Box2D::from_extents(2, 2); MultiFab state = scalar_field(domain, 1, 1); - PreparedBoundaryPlan deep("case::deep::ghost-plan", 2, {physical_bc()}); + PreparedBoundaryPlan deep("case::deep::ghost-plan", 2, physical_boundary()); EXPECT_THROW(deep.fill_same_level_and_physical(state, domain), std::runtime_error); } @@ -247,7 +707,7 @@ TEST(test_prepared_boundary_plan, executes_reflected_periodic_ghosts_on_a_multib } const PeriodicIdentification2D reflected_x{0, 1, std::array{{0, 1}}, std::array{{1, -1}}}; - PreparedBoundaryPlan plan("case::block::reflected-x", 1, {reflected_x_periodic_bc()}, {}, "", {}, + PreparedBoundaryPlan plan("case::block::reflected-x", 1, periodic_boundary({}, true), {}, "", {}, {reflected_x}); plan.fill_same_level_and_physical(state, domain); @@ -288,9 +748,9 @@ TEST(test_prepared_boundary_plan, explicit_identity_periodicity_keeps_the_legacy } const PeriodicIdentification2D identity{0, 1, std::array{{0, 1}}, std::array{{1, 1}}}; - PreparedBoundaryPlan legacy_plan("case::block::legacy-periodic", 1, {reflected_x_periodic_bc()}); + PreparedBoundaryPlan legacy_plan("case::block::legacy-periodic", 1, periodic_boundary()); PreparedBoundaryPlan explicit_plan("case::block::explicit-identity-periodic", 1, - {reflected_x_periodic_bc()}, {}, "", {}, {identity}); + periodic_boundary({}, true), {}, "", {}, {identity}); legacy_plan.fill_same_level_and_physical(legacy, domain); explicit_plan.fill_same_level_and_physical(explicit_identity, domain); @@ -306,29 +766,78 @@ TEST(test_prepared_boundary_plan, explicit_identity_periodicity_keeps_the_legacy } TEST(test_prepared_boundary_plan, axis_permutation_refuses_incompatible_rectangular_geometry) { - BCRec rotated; - rotated.xlo = BCType::Periodic; - rotated.xhi = BCType::Foextrap; - rotated.ylo = BCType::Foextrap; - rotated.yhi = BCType::Periodic; const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, std::array{{1, 1}}}; - PreparedBoundaryPlan plan("case::block::rotated-periodic", 1, {rotated}, {}, "", {}, - {xlo_to_yhi}); + PreparedBoundaryPlan plan( + "case::block::rotated-periodic", 1, + periodic_boundary({"periodic", "foextrap", "foextrap", "periodic"}, true), {}, "", {}, + {xlo_to_yhi}); const Box2D rectangular_domain = Box2D::from_extents(8, 6); MultiFab state = scalar_field(rectangular_domain, 1, 1); EXPECT_THROW(plan.fill_same_level_and_physical(state, rectangular_domain), std::invalid_argument); } +TEST(test_prepared_boundary_plan, mapped_periodicity_refuses_unmapped_vector_components) { + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + auto vector_boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(8, 0.0), + {"case::vector::xlo", "case::vector::xhi", "case::vector::ylo", "case::vector::yhi"}, + {"Density", "MomentumX"}, true); + EXPECT_THROW(PreparedBoundaryPlan("case::block::rotated-vector-periodic", 1, + std::move(vector_boundary), {}, "", {}, {xlo_to_yhi}), + std::runtime_error); +} + +TEST(test_prepared_boundary_plan, mapped_periodicity_refuses_unmapped_analytic_coordinates) { + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + auto analytic_boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "dirichlet", "foextrap", "periodic"}, std::vector(4, 0.0), + {"case::analytic::xlo", "case::analytic::xhi", "case::analytic::ylo", "case::analytic::yhi"}, + {"Scalar"}, true, {}, {}, {{}, {"x"}, {}, {}}, {{}, {0.0}, {}, {}}, {"", "", "", ""}); + + EXPECT_THROW(PreparedBoundaryPlan("case::block::rotated-analytic-periodic", 1, + std::move(analytic_boundary), {}, "", {}, {xlo_to_yhi}), + std::runtime_error); +} + +TEST(test_prepared_boundary_plan, axis_permutation_executes_on_a_square_domain) { + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + PreparedBoundaryPlan plan( + "case::block::rotated-periodic-square", 1, + periodic_boundary({"periodic", "foextrap", "foextrap", "periodic"}, true), {}, "", {}, + {xlo_to_yhi}); + const Box2D domain = Box2D::from_extents(6, 6); + MultiFab state = scalar_field(domain, 1, 1); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(i + 100 * j); }); + } + + EXPECT_NO_THROW(plan.fill_same_level_and_physical(state, domain)); + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& field = state.fab(local); + const Box2D grown = field.grown_box(); + for (int j = domain.lo[1]; j <= domain.hi[1]; ++j) + if (grown.contains(domain.lo[0] - 1, j)) + EXPECT_EQ(field(domain.lo[0] - 1, j, 0), Real(j + 100 * domain.hi[1])); + for (int i = domain.lo[0]; i <= domain.hi[0]; ++i) + if (grown.contains(i, domain.hi[1] + 1)) + EXPECT_EQ(field(i, domain.hi[1] + 1, 0), Real(domain.lo[0] + 100 * i)); + } +} + TEST(test_prepared_boundary_plan, grid_context_routes_exact_nary_storage_registry) { const Box2D domain = Box2D::from_extents(3, 3); MultiFab primary = scalar_field(domain, 1, 1); MultiFab coupled = scalar_field(domain, 2, 1); MultiFab auxiliary = scalar_field(domain, 3, 1); MultiFab output = scalar_field(domain, 1, 0); - auto plan = std::make_shared("case::nary::ghost-plan", 1, - std::vector{physical_bc()}); + auto plan = + std::make_shared("case::nary::ghost-plan", 1, physical_boundary()); GridContext context; context.dom = domain; context.geom = Geometry(domain, Real(0), Real(1), Real(0), Real(1)); diff --git a/tests/cpp/unit/numerics/test_cfl_dt.cpp b/tests/cpp/unit/numerics/test_cfl_dt.cpp index eba10e80d..554a04a24 100644 --- a/tests/cpp/unit/numerics/test_cfl_dt.cpp +++ b/tests/cpp/unit/numerics/test_cfl_dt.cpp @@ -30,10 +30,10 @@ struct AdvectX { using Aux = pops::Aux; static constexpr int n_vars = 1; Real a = Real(1); - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{dir == 0 ? a * u[0] : Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return a < 0 ? -a : a; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return a < 0 ? -a : a; } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; @@ -44,8 +44,8 @@ struct NanSpeed { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return std::numeric_limits::quiet_NaN(); } POPS_HD State source(const State&, const Aux&) const { return State{}; } @@ -63,7 +63,7 @@ struct BoundProbe { Real frequency = Real(0); Real direct_dt = std::numeric_limits::infinity(); - POPS_HD Real max_wave_speed(const State&, const Aux&, int direction) const { + POPS_HD Real max_wave_speed(const State&, const auto&, int direction) const { return direction == 0 ? wave_x : wave_y; } POPS_HD Real stability_speed(const State&, const Aux&, int direction) const { diff --git a/tests/cpp/unit/numerics/test_diffusion.cpp b/tests/cpp/unit/numerics/test_diffusion.cpp index fbc8cc80d..1b70f6331 100644 --- a/tests/cpp/unit/numerics/test_diffusion.cpp +++ b/tests/cpp/unit/numerics/test_diffusion.cpp @@ -33,8 +33,8 @@ struct Heat { using Aux = pops::Aux; static constexpr int n_vars = 1; Real nu = 0.0; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } POPS_HD Real diffusivity() const { return nu; } diff --git a/tests/cpp/unit/numerics/test_flux_interfaces.cpp b/tests/cpp/unit/numerics/test_flux_interfaces.cpp index 7c3d7a211..4ae58c0bc 100644 --- a/tests/cpp/unit/numerics/test_flux_interfaces.cpp +++ b/tests/cpp/unit/numerics/test_flux_interfaces.cpp @@ -3,11 +3,16 @@ #include #include #include +#include +#include #include +#include #include #include #include +#include +#include namespace { @@ -17,25 +22,127 @@ struct Advect { static constexpr int n_vars = 1; pops::Real speed = pops::Real(2); - POPS_HD State flux(const State& state, const Aux&, int) const { return State{state[0] * speed}; } - POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { + POPS_HD State flux(const State& state, const auto&, int) const { return State{state[0] * speed}; } + POPS_HD pops::Real max_wave_speed(const State&, const auto&, int) const { return speed < pops::Real(0) ? -speed : speed; } }; struct OtherAdvect : Advect {}; +struct NonFiniteRoeAdvect : Advect { + POPS_HD State roe_dissipation(const State&, const auto&, const State&, const auto&, int) const { + return State{std::numeric_limits::quiet_NaN()}; + } +}; + +struct NonFiniteRoeFluxAdvect : Advect { + POPS_HD State flux(const State&, const auto&, int) const { + return State{std::numeric_limits::quiet_NaN()}; + } + POPS_HD State roe_dissipation(const State&, const auto&, const State&, const auto&, int) const { + return State{}; + } +}; + +enum class RiemannPolicyCase : std::uint8_t { kRequestedSucceeds, kFallbackSucceeds, kRejects }; + +struct RiemannPolicyAdvect : Advect { + RiemannPolicyCase policy_case = RiemannPolicyCase::kRequestedSucceeds; + + RiemannPolicyAdvect() = default; + POPS_HD explicit RiemannPolicyAdvect(RiemannPolicyCase selected) : policy_case(selected) {} + + POPS_HD pops::Real max_wave_speed(const State&, const auto&, int) const { + return policy_case == RiemannPolicyCase::kRejects ? std::numeric_limits::quiet_NaN() + : pops::Real(2); + } + POPS_HD void wave_speeds(const State&, const auto&, int, pops::Real& lower, + pops::Real& upper) const { + if (policy_case == RiemannPolicyCase::kRejects) { + lower = upper = std::numeric_limits::quiet_NaN(); + return; + } + lower = pops::Real(-1); + upper = pops::Real(3); + } + POPS_HD State roe_dissipation(const State& left, const auto&, const State& right, const auto&, + int) const { + if (policy_case == RiemannPolicyCase::kFallbackSucceeds) + return State{std::numeric_limits::quiet_NaN()}; + return State{pops::Real(2) * (right[0] - left[0])}; + } +}; + +using PreparedRoeRecovery = + pops::PreparedRiemannRecoveryPolicy; + +struct DeviceRiemannRecoveryProbe { + POPS_HD void operator()(int, int, std::uint64_t& encoded) const { + pops::FluxProviderValues values{}; + const auto bound = pops::bind_flux_providers(values); + const auto evaluation = pops::evaluate_numerical_flux( + PreparedRoeRecovery{}, RiemannPolicyAdvect{RiemannPolicyCase::kFallbackSucceeds}, + RiemannPolicyAdvect::State{pops::Real(1)}, bound, RiemannPolicyAdvect::State{pops::Real(2)}, + bound, pops::FaceContext::axis_aligned(0)); + encoded = (static_cast(evaluation.used_solver) << 8) | + static_cast(evaluation.attempt_count); + } +}; + +enum class HllcFailureSite { kPhysicalFlux, kPressure, kContact, kStarState, kFinalFlux }; + +struct SelectiveInvalidHllc { + using State = pops::StateVec<1>; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + HllcFailureSite failure_site; + + POPS_HD State flux(const State& state, const auto&, int) const { + return failure_site == HllcFailureSite::kPhysicalFlux + ? State{std::numeric_limits::quiet_NaN()} + : state; + } + POPS_HD pops::Real max_wave_speed(const State&, const auto&, int) const { return pops::Real(1); } + POPS_HD void wave_speeds(const State&, const auto&, int, pops::Real& lower, + pops::Real& upper) const { + const pops::Real magnitude = failure_site == HllcFailureSite::kFinalFlux + ? std::numeric_limits::max() + : pops::Real(1); + lower = -magnitude; + upper = magnitude; + } + POPS_HD pops::Real pressure(const State&) const { + return failure_site == HllcFailureSite::kPressure ? std::numeric_limits::quiet_NaN() + : pops::Real(1); + } + POPS_HD pops::Real contact_speed(const State&, const State&, pops::Real, pops::Real, pops::Real, + pops::Real, int) const { + return failure_site == HllcFailureSite::kContact ? std::numeric_limits::quiet_NaN() + : pops::Real(0); + } + POPS_HD State hllc_star_state(const State& state, pops::Real, pops::Real, pops::Real, int) const { + if (failure_site == HllcFailureSite::kStarState) + return State{std::numeric_limits::quiet_NaN()}; + if (failure_site == HllcFailureSite::kFinalFlux) + return State{std::numeric_limits::max()}; + return state; + } +}; + struct SelectiveInvalidAdvect { using State = pops::StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State& state, const Aux&, int) const { return State{state[0]}; } - POPS_HD pops::Real max_wave_speed(const State& state, const Aux&, int) const { + POPS_HD State flux(const State& state, const auto&, int) const { return State{state[0]}; } + POPS_HD pops::Real max_wave_speed(const State& state, const auto&, int) const { return state[0] == pops::Real(-1) ? std::numeric_limits::quiet_NaN() : pops::Real(2); } - POPS_HD void wave_speeds(const State& state, const Aux&, int, pops::Real& lower, + POPS_HD void wave_speeds(const State& state, const auto&, int, pops::Real& lower, pops::Real& upper) const { if (state[0] == pops::Real(-2)) { lower = upper = std::numeric_limits::quiet_NaN(); @@ -52,11 +159,12 @@ struct ProviderAdvect { static constexpr int n_vars = 1; static constexpr int n_aux = 3; - POPS_HD State flux(const State& state, const Aux& providers, int) const { - return State{state[0] * providers.grad_x}; + POPS_HD State flux(const State& state, const auto& providers, int) const { + return State{state[0] * providers.template flux_provider<1>()}; } - POPS_HD pops::Real max_wave_speed(const State&, const Aux& providers, int) const { - return providers.grad_x < pops::Real(0) ? -providers.grad_x : providers.grad_x; + POPS_HD pops::Real max_wave_speed(const State&, const auto& providers, int) const { + const pops::Real gradient = providers.template flux_provider<1>(); + return gradient < pops::Real(0) ? -gradient : gradient; } }; @@ -68,6 +176,49 @@ struct ProviderStorage { } }; +struct QualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 1; + inline static constexpr std::array + flux_provider_requirements{{ + {"model::qualified", "field", "electric", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::electric", true, 1}, + }}; +}; + +struct UnavailableQualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 1; + inline static constexpr std::array + flux_provider_requirements{{ + {"model::unavailable", "field", "electric", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::electric", false, 1}, + }}; +}; + +struct IncompleteQualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 1; +}; + +struct DuplicateQualifiedProviderAdvect : ProviderAdvect { + static constexpr int n_flux_providers = 2; + inline static constexpr std::array + flux_provider_requirements{{ + {"model::duplicate", "field", "electric", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::electric", true, 1}, + {"model::duplicate", "field", "magnetic", "grad_x", "scalar", "cell", "", + "layout::primary", "", "field::magnetic", true, 1}, + }}; +}; + +struct CountingProviderStorage { + pops::Real values[3]{pops::Real(11), pops::Real(4), pops::Real(13)}; + mutable int reads[3]{}; + + POPS_HD pops::Real operator()(int, int, int component) const { + ++reads[component]; + return values[component]; + } +}; + template auto providers(std::initializer_list values = {}) { pops::FluxProviderValues resolved{}; @@ -109,6 +260,22 @@ struct RecordFatalFluxFailures { } }; +struct NonFinitePrimitiveModel { + using State = pops::StateVec<1>; + using Prim = pops::StateVec<1>; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + POPS_HD State flux(const State& state, const auto&, int) const { return state; } + POPS_HD pops::Real max_wave_speed(const State&, const auto&, int) const { return pops::Real(1); } + POPS_HD State source(const State&, const Aux&) const { return {}; } + POPS_HD pops::Real elliptic_rhs(const State&) const { return pops::Real(0); } + POPS_HD Prim to_primitive(const State&) const { + return Prim{std::numeric_limits::quiet_NaN()}; + } + POPS_HD State to_conservative(const Prim& primitive) const { return primitive; } +}; + } // namespace TEST(test_flux_interfaces, equal_state_consistency_and_declared_stability) { @@ -124,6 +291,76 @@ TEST(test_flux_interfaces, equal_state_consistency_and_declared_stability) { EXPECT_DOUBLE_EQ(evaluation.stability.value, physical.speed); EXPECT_EQ(evaluation.stability.unit, pops::StabilityUnit::kLengthPerTime); EXPECT_EQ(evaluation.stability.convention, pops::StabilityConvention::kNormalSpectralRadius); + EXPECT_EQ(evaluation.requested_solver, pops::RiemannSolverId::kRusanov); + EXPECT_EQ(evaluation.used_solver, pops::RiemannSolverId::kRusanov); + EXPECT_EQ(evaluation.last_attempted_solver, pops::RiemannSolverId::kRusanov); + EXPECT_EQ(evaluation.attempt_count, 1); + EXPECT_FALSE(evaluation.used_fallback()); +} + +TEST(test_flux_interfaces, prepared_riemann_recovery_is_ordered_typed_and_device_copyable) { + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_empty_v); + static_assert(PreparedRoeRecovery::candidate_count == 3); + static_assert(PreparedRoeRecovery::ordered_solver_ids[0] == pops::RiemannSolverId::kRoe); + static_assert(PreparedRoeRecovery::ordered_solver_ids[1] == pops::RiemannSolverId::kHll); + static_assert(PreparedRoeRecovery::ordered_solver_ids[2] == pops::RiemannSolverId::kRusanov); + static_assert(PreparedRoeRecovery::ordered_solver_ids[3] == pops::RiemannSolverId::kReject); + + const auto evaluate = [](RiemannPolicyCase policy_case) { + const RiemannPolicyAdvect physical{policy_case}; + const auto bound = providers(); + return pops::evaluate_numerical_flux( + pops::prepare_riemann_recovery_policy(), + physical, RiemannPolicyAdvect::State{pops::Real(1)}, bound, + RiemannPolicyAdvect::State{pops::Real(2)}, bound, pops::FaceContext::axis_aligned(0)); + }; + + const auto requested = evaluate(RiemannPolicyCase::kRequestedSucceeds); + ASSERT_TRUE(requested.succeeded()); + EXPECT_EQ(requested.requested_solver, pops::RiemannSolverId::kRoe); + EXPECT_EQ(requested.used_solver, pops::RiemannSolverId::kRoe); + EXPECT_EQ(requested.last_attempted_solver, pops::RiemannSolverId::kRoe); + EXPECT_EQ(requested.attempt_count, 1); + EXPECT_EQ(requested.recovery_reason_code, 0u); + EXPECT_FALSE(requested.used_fallback()); + + const auto recovered = evaluate(RiemannPolicyCase::kFallbackSucceeds); + ASSERT_TRUE(recovered.succeeded()); + EXPECT_EQ(recovered.requested_solver, pops::RiemannSolverId::kRoe); + EXPECT_EQ(recovered.used_solver, pops::RiemannSolverId::kHll); + EXPECT_EQ(recovered.last_attempted_solver, pops::RiemannSolverId::kHll); + EXPECT_EQ(recovered.attempt_count, 2); + EXPECT_EQ(recovered.recovery_reason_code, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeNonFiniteDissipation)); + EXPECT_TRUE(recovered.used_fallback()); + + const std::uint64_t device_encoded = + pops::reduce_max_uint64_cell(pops::Box2D{{0, 0}, {0, 0}}, DeviceRiemannRecoveryProbe{}); + EXPECT_EQ(device_encoded >> 8, static_cast(pops::RiemannSolverId::kHll)); + EXPECT_EQ(device_encoded & UINT64_C(0xff), UINT64_C(2)); +} + +TEST(test_flux_interfaces, prepared_riemann_recovery_exhaustion_is_typed_and_cannot_publish) { + const RiemannPolicyAdvect physical{RiemannPolicyCase::kRejects}; + const auto bound = providers(); + const auto rejected = pops::evaluate_numerical_flux( + pops::prepare_riemann_recovery_policy(), + physical, RiemannPolicyAdvect::State{pops::Real(1)}, bound, + RiemannPolicyAdvect::State{pops::Real(2)}, bound, pops::FaceContext::axis_aligned(0)); + + EXPECT_EQ(rejected.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(rejected.requested_solver, pops::RiemannSolverId::kRoe); + EXPECT_EQ(rejected.used_solver, pops::RiemannSolverId::kReject); + EXPECT_EQ(rejected.last_attempted_solver, pops::RiemannSolverId::kRusanov); + EXPECT_EQ(rejected.attempt_count, 3); + EXPECT_EQ(rejected.recovery_reason_code, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeInvalidStability)); + EXPECT_EQ(rejected.reason_code, + pops::riemann_reason_code(pops::RiemannFailureCause::kRusanovInvalidStability)); + EXPECT_TRUE(std::isnan(rejected.checked_density().value[0])); } TEST(test_flux_interfaces, orientation_reversal_swaps_traces_and_negates_flux) { @@ -253,6 +490,30 @@ TEST(test_flux_interfaces, provider_pack_is_model_qualified_and_failure_action_i pops::TransactionFailureAction::kAbortRun); } +TEST(test_flux_interfaces, generated_provider_requirements_own_native_slot_reads) { + static_assert(pops::has_qualified_flux_provider_requirements); + static_assert(pops::qualified_flux_provider_requirements_valid()); + static_assert( + !pops::qualified_flux_provider_requirements_valid()); + static_assert( + !pops::qualified_flux_provider_requirements_valid()); + static_assert( + !pops::qualified_flux_provider_requirements_valid()); + + const CountingProviderStorage storage{}; + const auto bound = pops::bind_flux_providers_at(storage, 0, 0); + EXPECT_EQ(storage.reads[0], 0); + EXPECT_EQ(storage.reads[1], 1); + EXPECT_EQ(storage.reads[2], 0); + + const QualifiedProviderAdvect::State state{pops::Real(3)}; + const auto trace = pops::make_face_trace(state, bound); + const auto density = + pops::PhysicalFluxView{QualifiedProviderAdvect{}}.evaluate( + trace, pops::FaceContext::axis_aligned(0)); + EXPECT_DOUBLE_EQ(density.value[0], pops::Real(12)); +} + TEST(test_flux_interfaces, failed_evaluation_never_publishes_a_density) { const Advect physical{}; const Advect::State state{pops::Real(3)}; @@ -264,7 +525,63 @@ TEST(test_flux_interfaces, failed_evaluation_never_publishes_a_density) { EXPECT_EQ(evaluation.status, pops::EvaluationStatus::kReject); EXPECT_EQ(evaluation.failure_action(), pops::TransactionFailureAction::kRejectStep); EXPECT_EQ(evaluation.reason_code, 0x682u); + EXPECT_EQ(evaluation.requested_solver, pops::RiemannSolverId::kExternal); + EXPECT_EQ(evaluation.used_solver, pops::RiemannSolverId::kReject); + EXPECT_EQ(evaluation.last_attempted_solver, pops::RiemannSolverId::kExternal); + EXPECT_EQ(evaluation.attempt_count, 1); + EXPECT_TRUE(std::isnan(evaluation.checked_density().value[0])); +} + +TEST(test_flux_interfaces, roe_rejects_nonfinite_dissipation_with_a_typed_cause) { + const NonFiniteRoeAdvect physical{}; + const NonFiniteRoeAdvect::State left{pops::Real(1)}, right{pops::Real(2)}; + const auto bound = providers(); + const auto evaluation = pops::evaluate_numerical_flux( + pops::RoeFlux{}, physical, left, bound, right, bound, pops::FaceContext::axis_aligned(0)); + + EXPECT_EQ(evaluation.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(evaluation.failure_action(), pops::TransactionFailureAction::kRejectStep); + EXPECT_EQ(evaluation.reason_code, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeNonFiniteDissipation)); EXPECT_TRUE(std::isnan(evaluation.checked_density().value[0])); + + const NonFiniteRoeFluxAdvect invalid_flux{}; + const auto invalid_flux_bound = providers(); + const auto flux_evaluation = pops::evaluate_numerical_flux( + pops::RoeFlux{}, invalid_flux, NonFiniteRoeFluxAdvect::State{pops::Real(1)}, + invalid_flux_bound, NonFiniteRoeFluxAdvect::State{pops::Real(2)}, invalid_flux_bound, + pops::FaceContext::axis_aligned(0)); + EXPECT_EQ(flux_evaluation.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(flux_evaluation.reason_code, + pops::riemann_reason_code(pops::RiemannFailureCause::kRoeNonFiniteFlux)); + EXPECT_TRUE(std::isnan(flux_evaluation.checked_density().value[0])); +} + +TEST(test_flux_interfaces, hllc_rejects_each_nonfinite_provider_stage_with_a_typed_cause) { + struct ExpectedFailure { + HllcFailureSite site; + pops::RiemannFailureCause cause; + }; + const ExpectedFailure expected[] = { + {HllcFailureSite::kPhysicalFlux, pops::RiemannFailureCause::kHllcNonFinitePhysicalFlux}, + {HllcFailureSite::kPressure, pops::RiemannFailureCause::kHllcNonFinitePressure}, + {HllcFailureSite::kContact, pops::RiemannFailureCause::kHllcNonFiniteContact}, + {HllcFailureSite::kStarState, pops::RiemannFailureCause::kHllcNonFiniteStarState}, + {HllcFailureSite::kFinalFlux, pops::RiemannFailureCause::kHllcNonFiniteFlux}, + }; + + for (const auto& failure : expected) { + const SelectiveInvalidHllc physical{failure.site}; + const auto bound = providers(); + const auto evaluation = pops::evaluate_numerical_flux( + pops::HLLCFlux{}, physical, SelectiveInvalidHllc::State{pops::Real(1)}, bound, + SelectiveInvalidHllc::State{pops::Real(2)}, bound, pops::FaceContext::axis_aligned(0)); + + EXPECT_EQ(evaluation.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(evaluation.failure_action(), pops::TransactionFailureAction::kRejectStep); + EXPECT_EQ(evaluation.reason_code, pops::riemann_reason_code(failure.cause)); + EXPECT_TRUE(std::isnan(evaluation.checked_density().value[0])); + } } TEST(test_flux_interfaces, device_failure_reduction_orders_status_then_reason_deterministically) { @@ -297,6 +614,68 @@ TEST(test_flux_interfaces, fatal_flux_failure_remains_typed_and_preserves_reason FAIL() << "fatal device flux failure was not propagated as FluxEvaluationFailure"; } +TEST(test_flux_interfaces, recovery_report_uses_the_flux_failure_reduction_without_type_erasure) { + pops::RecoveryReport recovery; + recovery.status = pops::RecoveryStatus::kRejected; + recovery.cause = pops::RecoveryCause::kExplicitRejection; + recovery.reason_code = 0x755u; + + std::uint64_t packed = 0; + pops::FluxEvaluationTracker tracker{pops::process_world_flux_collective}; + tracker.recorder().record_recovery(recovery, packed); + tracker.merge(packed); + + const pops::FluxFailureReport report = tracker.collective_report(); + EXPECT_EQ(report.status, pops::EvaluationStatus::kReject); + EXPECT_EQ(report.reason_code, 0x755u); + EXPECT_EQ(report.action(), pops::TransactionFailureAction::kRejectStep); +} + +TEST(test_flux_interfaces, face_recovery_refusal_never_reaches_the_numerical_flux) { + static_assert( + std::is_trivially_copyable_v>); + static_assert( + std::is_trivially_copyable_v>); + const pops::Box2D domain = pops::Box2D::from_extents(4, 4); + const pops::BoxArray cells(std::vector{domain}); + const pops::DistributionMapping distribution(1, pops::n_ranks()); + pops::MultiFab state(cells, distribution, 1, 2); + pops::MultiFab providers_field(cells, distribution, pops::kAuxBaseComps, 2); + state.set_val(pops::Real(1)); + providers_field.set_val(pops::Real(0)); + + const auto local_state = state.fab(0).const_array(); + const auto reconstructed = pops::reconstruct_pp_recovered( + NonFinitePrimitiveModel{}, local_state, domain.lo[0] + 1, domain.lo[1] + 1, 0, pops::Real(1), + pops::Minmod{}, true, pops::Real(0), 0); + ASSERT_FALSE(reconstructed.publication_permitted()); + EXPECT_EQ(reconstructed.recovery.status, pops::RecoveryStatus::kInvalidContract); + EXPECT_EQ(reconstructed.recovery.cause, pops::RecoveryCause::kNonFiniteCandidate); + EXPECT_EQ(reconstructed.value[0], pops::Real(1)); + const auto value_only = pops::reconstruct_pp( + NonFinitePrimitiveModel{}, local_state, domain.lo[0] + 1, domain.lo[1] + 1, 0, pops::Real(1), + pops::Minmod{}, true, pops::Real(0), 0); + EXPECT_TRUE(std::isnan(value_only[0])); + + std::vector x_faces{pops::xface_box(domain)}; + std::vector y_faces{pops::yface_box(domain)}; + pops::MultiFab flux_x(pops::BoxArray(std::move(x_faces)), distribution, 1, 0); + pops::MultiFab flux_y(pops::BoxArray(std::move(y_faces)), distribution, 1, 0); + try { + pops::compute_face_fluxes(NonFinitePrimitiveModel{}, state, + providers_field, flux_x, flux_y, + pops::Real(1), pops::Real(1), true); + } catch (const pops::FluxEvaluationFailure& failure) { + EXPECT_EQ(failure.status(), pops::EvaluationStatus::kFailed); + EXPECT_EQ(failure.reason_code(), + pops::detail::kVariableRecoveryReasonBase | + static_cast(pops::RecoveryCause::kNonFiniteCandidate)); + EXPECT_EQ(failure.phase(), "compute_face_fluxes"); + return; + } + FAIL() << "a refused primitive recovery reached or escaped the face-flux path"; +} + TEST(test_flux_interfaces, native_storage_binds_only_the_exact_model_pack) { const ProviderAdvect physical{}; const ProviderAdvect::State state{pops::Real(3)}; diff --git a/tests/cpp/unit/numerics/test_imex_partial.cpp b/tests/cpp/unit/numerics/test_imex_partial.cpp index 1306c65bf..2d532697d 100644 --- a/tests/cpp/unit/numerics/test_imex_partial.cpp +++ b/tests/cpp/unit/numerics/test_imex_partial.cpp @@ -29,8 +29,8 @@ struct TwoVarRelax { using State = StateVec<2>; using Aux = pops::Aux; static constexpr int n_vars = 2; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux&) const { return State{-Real(100) * (u[0] - Real(1)), -Real(1) * (u[1] - Real(2))}; } diff --git a/tests/cpp/unit/numerics/test_imex_transport.cpp b/tests/cpp/unit/numerics/test_imex_transport.cpp index 3fe2058e4..a8f15273d 100644 --- a/tests/cpp/unit/numerics/test_imex_transport.cpp +++ b/tests/cpp/unit/numerics/test_imex_transport.cpp @@ -28,10 +28,10 @@ struct AdvectX { using Aux = pops::Aux; static constexpr int n_vars = 1; Real a = Real(1); - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{dir == 0 ? a * u[0] : Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return a < 0 ? -a : a; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return a < 0 ? -a : a; } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/unit/numerics/test_nd_finite_volume.cpp b/tests/cpp/unit/numerics/test_nd_finite_volume.cpp new file mode 100644 index 000000000..9185ebdb6 --- /dev/null +++ b/tests/cpp/unit/numerics/test_nd_finite_volume.cpp @@ -0,0 +1,403 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace pops; + +namespace { + +template +Box make_box(const std::array& extents) { + Index lower{}; + Index upper{}; + for (int axis = 0; axis < Dim; ++axis) + upper[axis] = extents[axis] - 1; + return {lower, upper}; +} + +template +void for_each_index(const Box& box, Function&& function) { + const std::int64_t count = box.numPts(); + for (std::int64_t linear = 0; linear < count; ++linear) { + std::int64_t remaining = linear; + Index index{}; + for (int axis = 0; axis < Dim; ++axis) { + index[axis] = box.lo[axis] + static_cast(remaining % box.length(axis)); + remaining /= box.length(axis); + } + function(index); + } +} + +template +class HostFaceStorage { + public: + explicit HostFaceStorage(Box cells) { + view_.cells = cells; + view_.ncomp = N; + for (int axis = 0; axis < Dim; ++axis) { + boxes_[axis] = nd::face_box(cells, axis); + const std::int64_t count = boxes_[axis].numPts(); + values_[axis].resize(static_cast(count) * N); + FieldView axis_view{}; + axis_view.data = values_[axis].data(); + axis_view.origin = boxes_[axis].lo; + axis_view.extents = boxes_[axis].extent(); + std::int64_t stride = 1; + for (int direction = 0; direction < Dim; ++direction) { + axis_view.strides[direction] = stride; + stride *= axis_view.extents[direction]; + } + axis_view.ncomp = N; + axis_view.component_stride = count; + view_.axes[axis] = axis_view; + } + } + + const Box& box(int axis) const { return boxes_[axis]; } + const nd::FaceFieldView& view() const { return view_; } + + void set(int axis, const Index& index, int component, Real value) { + values_[axis][offset(axis, index, component)] = value; + } + + void fill(Real value) { + for (auto& axis : values_) + std::fill(axis.begin(), axis.end(), value); + } + + private: + std::size_t offset(int axis, const Index& index, int component) const { + std::int64_t linear = 0; + std::int64_t stride = 1; + for (int direction = 0; direction < Dim; ++direction) { + linear += static_cast(index[direction] - boxes_[axis].lo[direction]) * stride; + stride *= boxes_[axis].length(direction); + } + return static_cast(component * boxes_[axis].numPts() + linear); + } + + std::array, Dim> boxes_{}; + std::array, Dim> values_{}; + nd::FaceFieldView view_{}; +}; + +template +void check_scalar_axis(const nd::ScalarAdvection& model) { + using State = typename nd::ScalarAdvection::State; + const State left{Real(1.25)}; + const State right{Real(2.75)}; + const Real speed = model.velocity()[Axis]; + const Real expected = speed * (speed >= Real(0) ? left[0] : right[0]); + + const auto rusanov = nd::evaluate_axis_flux(RusanovFlux{}, model, left, right); + ASSERT_TRUE(rusanov.succeeded()); + EXPECT_EQ(rusanov.requested_solver, RiemannSolverId::kRusanov); + EXPECT_EQ(rusanov.used_solver, RiemannSolverId::kRusanov); + EXPECT_NEAR(rusanov.checked_density().value[0], expected, Real(2e-14)); + + const auto hll = nd::evaluate_axis_flux(HLLFlux{}, model, left, right); + ASSERT_TRUE(hll.succeeded()); + EXPECT_EQ(hll.requested_solver, RiemannSolverId::kHll); + EXPECT_NEAR(hll.checked_density().value[0], expected, Real(2e-14)); + + if constexpr (Axis + 1 < Dim) + check_scalar_axis(model); +} + +template +void check_scalar_law() { + RealVector velocity{}; + for (int axis = 0; axis < Dim; ++axis) + velocity[axis] = axis % 2 == 0 ? Real(0.35 + 0.1 * axis) : Real(-0.45 - 0.1 * axis); + check_scalar_axis<0>(nd::ScalarAdvection::prepare(velocity)); +} + +template +void check_euler_axis(const nd::IdealGasEuler& model, + const typename nd::IdealGasEuler::State& conservative, + const typename nd::IdealGasEuler::Primitive& primitive) { + using Schema = nd::EulerStateSchema; + const auto flux = model.template flux(conservative); + const Real normal_velocity = primitive[Schema::template velocity]; + EXPECT_NEAR(flux[Schema::density], conservative[Schema::template momentum], Real(2e-14)); + for (int momentum_axis = 0; momentum_axis < Dim; ++momentum_axis) { + Real expected = conservative[momentum_axis + 1] * normal_velocity; + if (momentum_axis == Axis) + expected += primitive[Schema::pressure]; + EXPECT_NEAR(flux[momentum_axis + 1], expected, Real(2e-14)); + } + EXPECT_NEAR(flux[Schema::energy], + (conservative[Schema::energy] + primitive[Schema::pressure]) * normal_velocity, + Real(2e-14)); + + const auto rusanov = + nd::evaluate_axis_flux(RusanovFlux{}, model, conservative, conservative); + const auto hll = nd::evaluate_axis_flux(HLLFlux{}, model, conservative, conservative); + ASSERT_TRUE(rusanov.succeeded()); + ASSERT_TRUE(hll.succeeded()); + for (int component = 0; component < Schema::nvars; ++component) { + EXPECT_NEAR(rusanov.checked_density().value[component], flux[component], Real(4e-14)); + EXPECT_NEAR(hll.checked_density().value[component], flux[component], Real(4e-14)); + } + + if constexpr (Axis + 1 < Dim) + check_euler_axis(model, conservative, primitive); +} + +template +void check_euler_law() { + using Schema = nd::EulerStateSchema; + const auto model = nd::IdealGasEuler::prepare(Real(1.4)); + typename nd::IdealGasEuler::Primitive primitive{}; + primitive[Schema::density] = Real(1.25); + primitive[Schema::pressure] = Real(0.9); + for (int axis = 0; axis < Dim; ++axis) + primitive[axis + 1] = axis % 2 == 0 ? Real(0.2 * (axis + 1)) : Real(-0.15 * (axis + 1)); + const auto conservative = model.make_conservative(primitive); + ASSERT_TRUE(conservative.succeeded()); + const auto recovered = model.recover(conservative.value); + ASSERT_TRUE(recovered.succeeded()); + for (int component = 0; component < Schema::nvars; ++component) + EXPECT_NEAR(recovered.value[component], primitive[component], Real(3e-14)); + check_euler_axis<0>(model, conservative.value, primitive); +} + +template +void fill_constant_physical_flux(HostFaceStorage& faces, const Model& model, + const typename Model::State& state, const Metric& metric, + const Box& cells) { + for_each_index(faces.box(Axis), [&](const Index& face) { + Index left_cell = face; + if (face[Axis] == cells.lo[Axis]) + left_cell[Axis] = cells.hi[Axis]; + else + --left_cell[Axis]; + const auto evaluation = nd::evaluate_metric_face_flux( + RusanovFlux{}, model, state, state, metric, left_cell); + ASSERT_TRUE(evaluation.succeeded()); + const FaceContext context = + nd::metric_face_context(metric, left_cell); + const auto integrated = apply_face_measure(evaluation.checked_density(), context); + for (int component = 0; component < Model::n_vars; ++component) + faces.set(Axis, face, component, integrated.value[component]); + }); + if constexpr (Axis + 1 < Dim) + fill_constant_physical_flux(faces, model, state, metric, cells); +} + +template +void check_metric_cfl_and_divergence() { + std::array extents{}; + RealVector lengths{}; + RealVector origin{}; + RealVector velocity{}; + for (int axis = 0; axis < Dim; ++axis) { + extents[axis] = 4 + axis; + lengths[axis] = Real(1.5 + 0.5 * axis); + velocity[axis] = axis % 2 == 0 ? Real(0.3 + 0.1 * axis) : Real(-0.4 - 0.1 * axis); + } + const Box cells = make_box(extents); + const auto map = CartesianCoordinateMap::make(origin, lengths); + const auto metric = prepare_metric_provider(cells, map); + const auto model = nd::ScalarAdvection::prepare(velocity); + const typename nd::ScalarAdvection::State state{Real(1.7)}; + Index sample{}; + for (int axis = 0; axis < Dim; ++axis) + sample[axis] = extents[axis] / 2; + + const auto cfl = nd::cell_cfl_bound(model, state, metric, sample); + ASSERT_TRUE(cfl.succeeded()); + Real expected_inverse_dt = Real(0); + for (int axis = 0; axis < Dim; ++axis) + expected_inverse_dt += + std::abs(velocity[axis]) / (lengths[axis] / static_cast(extents[axis])); + EXPECT_NEAR(cfl.inverse_dt, expected_inverse_dt, Real(2e-13)); + const auto step = nd::cell_time_step(model, state, metric, sample, Real(0.4)); + ASSERT_TRUE(step.succeeded()); + EXPECT_NEAR(step.value, Real(0.4) / expected_inverse_dt, Real(2e-14)); + + HostFaceStorage faces(cells); + fill_constant_physical_flux<0>(faces, model, state, metric, cells); + for_each_index(cells, [&](const Index& cell) { + const auto residual = nd::conservative_residual<1>(metric, faces.view(), cell); + ASSERT_TRUE(residual.succeeded()); + EXPECT_NEAR(residual.value[0], Real(0), Real(3e-14)); + }); + + constexpr Real two_pi = Real(6.283185307179586476925286766559); + for (int axis = 0; axis < Dim; ++axis) { + for_each_index(faces.box(axis), [&](const Index& face) { + const int periodic_coordinate = + (face[axis] - cells.lo[axis]) % static_cast(cells.length(axis)); + Real value = std::sin(two_pi * static_cast(periodic_coordinate) / + static_cast(cells.length(axis))); + for (int tangent = 0; tangent < Dim; ++tangent) + if (tangent != axis) + value += Real(0.03 * (tangent + 1)) * static_cast(face[tangent]); + faces.set(axis, face, 0, value); + }); + } + Real global_residual = Real(0); + for_each_index(cells, [&](const Index& cell) { + const auto residual = nd::conservative_residual<1>(metric, faces.view(), cell); + ASSERT_TRUE(residual.succeeded()); + global_residual += residual.value[0] * metric.cell_measure(cell); + }); + EXPECT_NEAR(global_residual, Real(0), Real(3e-13)); +} + +} // namespace + +TEST(test_nd_finite_volume, state_schemas_are_axis_indexed_at_compile_time) { + static_assert(nd::EulerStateSchema<1>::density == 0); + static_assert(nd::EulerStateSchema<1>::energy == 2); + static_assert(nd::EulerStateSchema<2>::template momentum<1> == 2); + static_assert(nd::EulerStateSchema<3>::template momentum<2> == 3); + static_assert(nd::EulerStateSchema<3>::template tangent_momentum<1, 0> == 1); + static_assert(nd::EulerStateSchema<3>::template tangent_momentum<1, 1> == 3); + constexpr auto tangents = nd::EulerStateSchema<3>::tangent_axes<1>(); + static_assert(tangents[0] == 0 && tangents[1] == 2); + SUCCEED(); +} + +TEST(test_nd_finite_volume, scalar_advection_uses_the_same_rusanov_and_hll_templates_in_1d_2d_3d) { + check_scalar_law<1>(); + check_scalar_law<2>(); + check_scalar_law<3>(); +} + +TEST(test_nd_finite_volume, euler_dim_plus_two_flux_and_fallible_recovery_work_in_1d_2d_3d) { + check_euler_law<1>(); + check_euler_law<2>(); + check_euler_law<3>(); +} + +TEST(test_nd_finite_volume, euler_flux_is_invariant_under_an_axis_permutation) { + using Schema = nd::EulerStateSchema<3>; + constexpr std::array permutation{2, 0, 1}; + const auto model = nd::IdealGasEuler<3>::prepare(Real(1.4)); + nd::IdealGasEuler<3>::Primitive original{}; + original[Schema::density] = Real(1.3); + original[1] = Real(0.2); + original[2] = Real(-0.4); + original[3] = Real(0.7); + original[Schema::pressure] = Real(0.8); + nd::IdealGasEuler<3>::Primitive permuted{}; + permuted[Schema::density] = original[Schema::density]; + permuted[Schema::pressure] = original[Schema::pressure]; + for (int axis = 0; axis < 3; ++axis) + permuted[axis + 1] = original[permutation[axis] + 1]; + const auto original_state = model.make_conservative(original); + const auto permuted_state = model.make_conservative(permuted); + ASSERT_TRUE(original_state.succeeded()); + ASSERT_TRUE(permuted_state.succeeded()); + const auto original_flux = model.flux<2>(original_state.value); + const auto permuted_flux = model.flux<0>(permuted_state.value); + EXPECT_NEAR(permuted_flux[Schema::density], original_flux[Schema::density], Real(2e-14)); + for (int axis = 0; axis < 3; ++axis) + EXPECT_NEAR(permuted_flux[axis + 1], original_flux[permutation[axis] + 1], Real(2e-14)); + EXPECT_NEAR(permuted_flux[Schema::energy], original_flux[Schema::energy], Real(2e-14)); +} + +TEST(test_nd_finite_volume, prepared_metric_drives_cfl_and_conservative_face_divergence) { + check_metric_cfl_and_divergence<1>(); + check_metric_cfl_and_divergence<2>(); + check_metric_cfl_and_divergence<3>(); +} + +TEST(test_nd_finite_volume, embedded_axis_permutation_does_not_change_logical_cfl) { + const Box<3> cells = make_box<3>({4, 5, 6}); + const RealVector<3> lengths{Real(2), Real(3), Real(4)}; + const auto canonical = + prepare_metric_provider(cells, CartesianCoordinateMap<3>::make(RealVector<3>{}, lengths)); + const auto permuted = prepare_metric_provider( + cells, CartesianCoordinateMap<3>::make(RealVector<3>{}, lengths, {2, 0, 1}, {-1, 1, -1})); + const auto model = + nd::ScalarAdvection<3>::prepare(RealVector<3>{Real(0.3), Real(-0.5), Real(0.7)}); + const nd::ScalarAdvection<3>::State state{Real(1)}; + const Index<3> cell{1, 2, 3}; + const auto left = nd::cell_cfl_bound<3>(model, state, canonical, cell); + const auto right = nd::cell_cfl_bound<3>(model, state, permuted, cell); + ASSERT_TRUE(left.succeeded()); + ASSERT_TRUE(right.succeeded()); + EXPECT_NEAR(left.inverse_dt, right.inverse_dt, Real(2e-14)); +} + +TEST(test_nd_finite_volume, face_field_owns_one_axis_static_fab_per_direction) { + const Box<3> cells = make_box<3>({3, 4, 5}); + nd::FaceField<3> faces(cells, nd::EulerStateSchema<3>::nvars); + EXPECT_EQ(faces.ncomp(), 5); + EXPECT_EQ(faces.field<0>().box(), nd::face_box<0>(cells)); + EXPECT_EQ(faces.field<1>().box(), nd::face_box<1>(cells)); + EXPECT_EQ(faces.field<2>().box(), nd::face_box<2>(cells)); + EXPECT_EQ(faces.view().ncomp, 5); + const auto metric = prepare_metric_provider( + cells, CartesianCoordinateMap<3>::make(RealVector<3>{}, RealVector<3>{1, 1, 1})); + EXPECT_TRUE(nd::conservative_residual<5>(metric, faces.view(), Index<3>{}).succeeded()); +} + +TEST(test_nd_finite_volume, inadmissible_states_and_invalid_metric_inputs_fail_closed) { + EXPECT_THROW((void)nd::IdealGasEuler<3>::prepare(Real(1)), std::invalid_argument); + EXPECT_THROW((void)nd::ScalarAdvection<2>::prepare( + RealVector<2>{Real(0), std::numeric_limits::infinity()}), + std::invalid_argument); + + using Schema = nd::EulerStateSchema<3>; + const auto model = nd::IdealGasEuler<3>::prepare(Real(1.4)); + nd::IdealGasEuler<3>::Primitive primitive{}; + primitive[Schema::density] = Real(1); + primitive[Schema::pressure] = Real(1); + const auto valid = model.make_conservative(primitive); + ASSERT_TRUE(valid.succeeded()); + + auto vacuum = valid.value; + vacuum[Schema::density] = Real(0); + EXPECT_EQ(model.recover(vacuum).status, nd::StateConversionStatus::NonPositiveDensity); + auto cold = valid.value; + cold[Schema::energy] = Real(-1); + EXPECT_EQ(model.recover(cold).status, nd::StateConversionStatus::NonPositivePressure); + auto nonfinite = valid.value; + nonfinite[1] = std::numeric_limits::quiet_NaN(); + EXPECT_EQ(model.recover(nonfinite).status, nd::StateConversionStatus::NonFiniteState); + + const auto refused = nd::evaluate_axis_flux<0>(RusanovFlux{}, model, cold, valid.value); + EXPECT_FALSE(refused.succeeded()); + EXPECT_EQ(refused.status, EvaluationStatus::kReject); + EXPECT_EQ(refused.requested_solver, RiemannSolverId::kRusanov); + EXPECT_EQ(refused.used_solver, RiemannSolverId::kReject); + + const Box<3> cells = make_box<3>({2, 2, 2}); + const auto metric = prepare_metric_provider( + cells, CartesianCoordinateMap<3>::make(RealVector<3>{}, RealVector<3>{1, 1, 1})); + EXPECT_EQ(nd::cell_cfl_bound<3>(model, cold, metric, Index<3>{}).status, + nd::FiniteVolumeStatus::NonPositivePressure); + EXPECT_EQ(nd::cell_time_step<3>(model, valid.value, metric, Index<3>{}, Real(0)).status, + nd::FiniteVolumeStatus::InvalidCourantNumber); + EXPECT_FALSE( + nd::evaluate_axis_flux<0>(RusanovFlux{}, model, valid.value, valid.value, Real(0), Real(1)) + .succeeded()); + + HostFaceStorage<3, 5> faces(cells); + auto forged = faces.view(); + forged.ncomp = 4; + EXPECT_EQ(nd::conservative_residual<5>(metric, forged, Index<3>{}).status, + nd::FiniteVolumeStatus::InvalidFaceField); + + const Box<3> other_cells = make_box<3>({1, 2, 2}); + const auto other_metric = prepare_metric_provider( + other_cells, CartesianCoordinateMap<3>::make(RealVector<3>{}, RealVector<3>{1, 1, 1})); + EXPECT_EQ(nd::conservative_residual<5>(other_metric, faces.view(), Index<3>{}).status, + nd::FiniteVolumeStatus::InvalidMetric); + EXPECT_FALSE((nd::evaluate_metric_face_flux<0, MetricFaceSide::Upper>( + RusanovFlux{}, model, valid.value, valid.value, metric, Index<3>{2, 0, 0}) + .succeeded())); +} diff --git a/tests/cpp/unit/numerics/test_positivity_floor.cpp b/tests/cpp/unit/numerics/test_positivity_floor.cpp index 0caed89c6..c9f9c7e89 100644 --- a/tests/cpp/unit/numerics/test_positivity_floor.cpp +++ b/tests/cpp/unit/numerics/test_positivity_floor.cpp @@ -47,8 +47,8 @@ struct EulerNoSrc { static constexpr int n_vars = Euler::n_vars; Euler e{}; Real gamma = Real(1.4); - POPS_HD State flux(const State& u, const Aux& a, int dir) const { return e.flux(u, a, dir); } - POPS_HD Real max_wave_speed(const State& u, const Aux& a, int dir) const { + POPS_HD State flux(const State& u, const auto& a, int dir) const { return e.flux(u, a, dir); } + POPS_HD Real max_wave_speed(const State& u, const auto& a, int dir) const { return e.max_wave_speed(u, a, dir); } POPS_HD State source(const State&, const Aux&) const { return State{}; } diff --git a/tests/cpp/unit/numerics/test_prepared_cartesian_nd.cpp b/tests/cpp/unit/numerics/test_prepared_cartesian_nd.cpp new file mode 100644 index 000000000..649fc42e9 --- /dev/null +++ b/tests/cpp/unit/numerics/test_prepared_cartesian_nd.cpp @@ -0,0 +1,178 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace pops; + +namespace { + +template +struct LinearTransport { + using State = StateVec<1>; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + std::array velocity{}; + + template + POPS_HD State flux(const State& state, const Providers&, int axis) const { + return State{velocity[axis] * state[0]}; + } + + template + POPS_HD Real max_wave_speed(const State&, const Providers&, int axis) const { + return velocity[axis] < Real(0) ? -velocity[axis] : velocity[axis]; + } +}; + +template +std::size_t linear_index(const std::array& index, + const std::array& extents) { + std::size_t result = 0; + std::size_t stride = 1; + for (std::size_t axis = 0; axis < Dimension; ++axis) { + result += static_cast(index[axis]) * stride; + stride *= static_cast(extents[axis]); + } + return result; +} + +template +void for_each_index(const std::array& extents, Function&& function) { + std::size_t cells = 1; + for (const int extent : extents) + cells *= static_cast(extent); + for (std::size_t linear = 0; linear < cells; ++linear) { + std::size_t remaining = linear; + std::array index{}; + for (std::size_t axis = 0; axis < Dimension; ++axis) { + index[axis] = static_cast(remaining % static_cast(extents[axis])); + remaining /= static_cast(extents[axis]); + } + function(index); + } +} + +} // namespace + +TEST(test_prepared_cartesian_nd, one_dimensional_kernel_preserves_constant_state_and_conservation) { + constexpr int dimension = 1; + const std::array extents{32}; + const std::array lower{Real(-1)}; + const std::array upper{Real(2)}; + const PreparedPeriodicCartesianResidual, VanLeer, + RusanovFlux> + residual(extents, lower, upper, LinearTransport{{Real(0.7)}}); + + EXPECT_TRUE(residual.capabilities().supports( + {1, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual})); + EXPECT_FALSE(residual.capabilities().supports( + {2, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual})); + + std::vector constant(residual.scalar_count(), Real(2.5)); + std::vector output(residual.scalar_count(), Real(99)); + residual.execute(constant, output); + EXPECT_TRUE( + std::all_of(output.begin(), output.end(), [](Real value) { return value == Real(0); })); + + constexpr Real two_pi = Real(6.283185307179586476925286766559); + std::vector wave(residual.scalar_count()); + for (int i = 0; i < extents[0]; ++i) + wave[static_cast(i)] = + Real(1) + Real(0.2) * std::sin(two_pi * (Real(i) + Real(0.5)) / Real(extents[0])); + residual.execute(wave, output); + EXPECT_TRUE(std::any_of(output.begin(), output.end(), + [](Real value) { return std::abs(value) > Real(1e-8); })); + const Real integral = + std::accumulate(output.begin(), output.end(), Real(0)) * residual.metric().cell_measure; + EXPECT_NEAR(integral, Real(0), Real(2e-14)); +} + +TEST(test_prepared_cartesian_nd, + three_dimensional_kernel_is_axis_permutation_invariant_and_conservative) { + constexpr int dimension = 3; + constexpr std::array permutation{2, 0, 1}; + const std::array extents{8, 7, 6}; + const std::array lower{Real(-1), Real(2), Real(0.5)}; + const std::array upper{Real(3), Real(5), Real(2.5)}; + const std::array velocity{Real(0.7), Real(-0.4), Real(0.25)}; + const PreparedPeriodicCartesianResidual, VanLeer, + RusanovFlux> + original(extents, lower, upper, LinearTransport{velocity}); + + std::array permuted_extents{}; + std::array permuted_lower{}; + std::array permuted_upper{}; + std::array permuted_velocity{}; + for (int axis = 0; axis < dimension; ++axis) { + permuted_extents[axis] = extents[permutation[axis]]; + permuted_lower[axis] = lower[permutation[axis]]; + permuted_upper[axis] = upper[permutation[axis]]; + permuted_velocity[axis] = velocity[permutation[axis]]; + } + const PreparedPeriodicCartesianResidual, VanLeer, + RusanovFlux> + permuted(permuted_extents, permuted_lower, permuted_upper, + LinearTransport{permuted_velocity}); + + std::vector state(original.scalar_count()); + std::vector permuted_state(permuted.scalar_count()); + constexpr Real two_pi = Real(6.283185307179586476925286766559); + for_each_index(extents, [&](const auto& index) { + Real value = Real(0.75); + for (int axis = 0; axis < dimension; ++axis) + value += (Real(0.1) + Real(0.05) * Real(axis)) * + std::sin(two_pi * (Real(index[axis]) + Real(0.5)) / Real(extents[axis])); + state[linear_index(index, extents)] = value; + std::array mapped{}; + for (int axis = 0; axis < dimension; ++axis) + mapped[axis] = index[permutation[axis]]; + permuted_state[linear_index(mapped, permuted_extents)] = value; + }); + + std::vector output(original.scalar_count()); + std::vector permuted_output(permuted.scalar_count()); + original.execute(state, output); + permuted.execute(permuted_state, permuted_output); + for_each_index(extents, [&](const auto& index) { + std::array mapped{}; + for (int axis = 0; axis < dimension; ++axis) + mapped[axis] = index[permutation[axis]]; + EXPECT_NEAR(output[linear_index(index, extents)], + permuted_output[linear_index(mapped, permuted_extents)], Real(3e-13)); + }); + + const Real integral = + std::accumulate(output.begin(), output.end(), Real(0)) * original.metric().cell_measure; + EXPECT_NEAR(integral, Real(0), Real(2e-13)); + + std::fill(state.begin(), state.end(), Real(1.25)); + original.execute(state, output); + EXPECT_TRUE( + std::all_of(output.begin(), output.end(), [](Real value) { return value == Real(0); })); +} + +TEST(test_prepared_cartesian_nd, preparation_refuses_invalid_metric_and_buffer_contracts) { + using Residual = PreparedPeriodicCartesianResidual<3, LinearTransport<3>, VanLeer, RusanovFlux>; + EXPECT_THROW((Residual({2, 4, 4}, {Real(0), Real(0), Real(0)}, {Real(1), Real(1), Real(1)}, + LinearTransport<3>{{Real(1), Real(1), Real(1)}})), + std::invalid_argument); + EXPECT_THROW((Residual({4, 4, 4}, {Real(0), Real(0), Real(0)}, {Real(1), Real(0), Real(1)}, + LinearTransport<3>{{Real(1), Real(1), Real(1)}})), + std::invalid_argument); + + Residual residual({4, 4, 4}, {Real(0), Real(0), Real(0)}, {Real(1), Real(1), Real(1)}, + LinearTransport<3>{{Real(1), Real(1), Real(1)}}); + std::vector state(residual.scalar_count(), Real(1)); + EXPECT_THROW(residual.execute(state, std::span(state.data(), state.size())), + std::invalid_argument); + std::vector short_output(residual.scalar_count() - 1); + EXPECT_THROW(residual.execute(state, short_output), std::invalid_argument); +} diff --git a/tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp b/tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp new file mode 100644 index 000000000..d262457f7 --- /dev/null +++ b/tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp @@ -0,0 +1,276 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include +#endif + +namespace { + +std::atomic g_heap_allocations{0}; + +void* counted_allocate(std::size_t size) { + void* pointer = std::malloc(size == 0 ? 1 : size); + if (pointer == nullptr) + throw std::bad_alloc(); + g_heap_allocations.fetch_add(1, std::memory_order_relaxed); + return pointer; +} + +void* counted_aligned_allocate(std::size_t size, std::size_t alignment) { + void* pointer = nullptr; +#if defined(_MSC_VER) + pointer = _aligned_malloc(size == 0 ? 1 : size, alignment); +#else + if (posix_memalign(&pointer, alignment, size == 0 ? 1 : size) != 0) + pointer = nullptr; +#endif + if (pointer == nullptr) + throw std::bad_alloc(); + g_heap_allocations.fetch_add(1, std::memory_order_relaxed); + return pointer; +} + +void counted_aligned_free(void* pointer) noexcept { +#if defined(_MSC_VER) + _aligned_free(pointer); +#else + std::free(pointer); +#endif +} + +using pops::Real; + +struct PositiveCandidate { + POPS_HD bool operator()(const Real (&value)[1], int* component = nullptr) const { + const bool accepted = value[0] > Real(0); + if (component != nullptr) + *component = accepted ? -1 : 0; + return accepted; + } +}; + +struct SquareResidual { + Real target = 0; + + POPS_HD pops::LocalNonlinearEvaluationResult operator()(const Real (&value)[1], + Real (&residual)[1]) const { + residual[0] = value[0] * value[0] - target; + return pops::LocalNonlinearEvaluationResult::ok(); + } +}; + +struct SquareProblemFactory { + POPS_HD auto operator()(const Real (&conserved)[1]) const { + pops::PreparedLocalNonlinearControls controls; + controls.max_iterations = 16; + controls.absolute_tolerance = Real(1e-13); + return pops::prepare_local_nonlinear_problem<1>(SquareResidual{conserved[0]}, + pops::FiniteDifferenceLocalJacobian<1>{}, + PositiveCandidate{}, controls); + } +}; + +struct RejectingResidual { + POPS_HD pops::LocalNonlinearEvaluationResult operator()(const Real (&)[1], Real (&)[1]) const { + return pops::LocalNonlinearEvaluationResult::reject(731); + } +}; + +struct RejectingProblemFactory { + POPS_HD auto operator()(const Real (&)[1]) const { + pops::PreparedLocalNonlinearControls controls; + return pops::prepare_local_nonlinear_problem<1>(RejectingResidual{}, + pops::FiniteDifferenceLocalJacobian<1>{}, + PositiveCandidate{}, controls); + } +}; + +} // namespace + +void* operator new(std::size_t size) { + return counted_allocate(size); +} + +void* operator new[](std::size_t size) { + return counted_allocate(size); +} + +void operator delete(void* pointer) noexcept { + std::free(pointer); +} + +void operator delete[](void* pointer) noexcept { + std::free(pointer); +} + +void operator delete(void* pointer, std::size_t) noexcept { + std::free(pointer); +} + +void operator delete[](void* pointer, std::size_t) noexcept { + std::free(pointer); +} + +void* operator new(std::size_t size, const std::nothrow_t&) noexcept { + try { + return counted_allocate(size); + } catch (...) { + return nullptr; + } +} + +void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { + try { + return counted_allocate(size); + } catch (...) { + return nullptr; + } +} + +void operator delete(void* pointer, const std::nothrow_t&) noexcept { + std::free(pointer); +} + +void operator delete[](void* pointer, const std::nothrow_t&) noexcept { + std::free(pointer); +} + +void* operator new(std::size_t size, std::align_val_t alignment) { + return counted_aligned_allocate(size, static_cast(alignment)); +} + +void* operator new[](std::size_t size, std::align_val_t alignment) { + return counted_aligned_allocate(size, static_cast(alignment)); +} + +void operator delete(void* pointer, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void operator delete[](void* pointer, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void operator delete(void* pointer, std::size_t, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void operator delete[](void* pointer, std::size_t, std::align_val_t) noexcept { + counted_aligned_free(pointer); +} + +void* operator new(std::size_t size, std::align_val_t alignment, const std::nothrow_t&) noexcept { + try { + return counted_aligned_allocate(size, static_cast(alignment)); + } catch (...) { + return nullptr; + } +} + +void* operator new[](std::size_t size, std::align_val_t alignment, const std::nothrow_t&) noexcept { + try { + return counted_aligned_allocate(size, static_cast(alignment)); + } catch (...) { + return nullptr; + } +} + +void operator delete(void* pointer, std::align_val_t, const std::nothrow_t&) noexcept { + counted_aligned_free(pointer); +} + +void operator delete[](void* pointer, std::align_val_t, const std::nothrow_t&) noexcept { + counted_aligned_free(pointer); +} + +TEST(PreparedNumericsGate, AllocationProbeDetectsControlHeapTraffic) { + const std::uint64_t before = g_heap_allocations.load(std::memory_order_relaxed); + void* ordinary = ::operator new(32); + void* aligned = ::operator new(64, std::align_val_t{64}); + const std::uint64_t after = g_heap_allocations.load(std::memory_order_relaxed); + ::operator delete(ordinary); + ::operator delete(aligned, std::align_val_t{64}); + + EXPECT_EQ(after - before, std::uint64_t{2}); +} + +TEST(PreparedNumericsGate, ConvergedPreparedPathAllocatesNothingAndRollsBack) { + const Real conserved[1] = {Real(4)}; + const Real initial[1] = {Real(1)}; + const auto problem = SquareProblemFactory{}(conserved); + const auto methods = + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(PositiveCandidate{}, methods); + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_trivially_copyable_v); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + + const std::uint64_t before = g_heap_allocations.load(std::memory_order_relaxed); + const auto local = pops::solve_prepared_local_nonlinear(problem, initial); + const auto recovered = pops::recover_prepared_variable(plan, conserved, initial); + const bool published = transaction.publish_tentative(recovered, 4, 8); + const bool rolled_back = transaction.rollback(); + const std::uint64_t after = g_heap_allocations.load(std::memory_order_relaxed); + + ASSERT_TRUE(local.solved()); + ASSERT_TRUE(recovered.recovered()); + EXPECT_TRUE(published); + EXPECT_TRUE(rolled_back); + EXPECT_EQ(after, before); + EXPECT_NEAR(local.value[0], Real(2), Real(1e-10)); + EXPECT_NEAR(recovered.value[0], Real(2), Real(1e-10)); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); +} + +TEST(PreparedNumericsGate, FatalEvaluationIsTypedAllocationFreeAndCannotPublish) { + const Real conserved[1] = {Real(4)}; + const Real initial[1] = {Real(1)}; + const auto problem = RejectingProblemFactory{}(conserved); + const auto methods = + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(RejectingProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(PositiveCandidate{}, methods); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + + const std::uint64_t before = g_heap_allocations.load(std::memory_order_relaxed); + const auto local = pops::solve_prepared_local_nonlinear(problem, initial); + const auto recovered = pops::recover_prepared_variable(plan, conserved, initial); + const bool published = transaction.publish_tentative(recovered, 4, 8); + const bool rolled_back = transaction.rollback(); + const std::uint64_t after = g_heap_allocations.load(std::memory_order_relaxed); + + EXPECT_EQ(local.status, pops::LocalNonlinearStatus::kEvaluationReject); + EXPECT_EQ(local.reason_code, std::uint32_t{731}); + EXPECT_EQ(local.value[0], initial[0]); + EXPECT_EQ(recovered.status, pops::RecoveryStatus::kRejected); + EXPECT_EQ(recovered.cause, pops::RecoveryCause::kEvaluationReject); + EXPECT_EQ(recovered.reason_code, std::uint32_t{731}); + EXPECT_FALSE(published); + EXPECT_TRUE(rolled_back); + EXPECT_EQ(after, before); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); +} diff --git a/tests/cpp/unit/numerics/test_riemann_capabilities.cpp b/tests/cpp/unit/numerics/test_riemann_capabilities.cpp index 6068d5f55..76d3cdf57 100644 --- a/tests/cpp/unit/numerics/test_riemann_capabilities.cpp +++ b/tests/cpp/unit/numerics/test_riemann_capabilities.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -48,7 +49,7 @@ struct HookedEuler : pops::Euler { Us[3] = fac * (U[3] / r + (sStar - un) * (sStar + p / (r * (s - un)))); return Us; } - POPS_HD State roe_dissipation(const State& UL, const Aux&, const State& UR, const Aux&, + POPS_HD State roe_dissipation(const State& UL, const auto&, const State& UR, const auto&, int dir) const { const int in = (dir == 0) ? 1 : 2; const int it = (dir == 0) ? 2 : 1; @@ -101,13 +102,13 @@ struct PermutedEuler { return State{value[3], value[2], value[0], value[1]}; } POPS_HD Real pressure(const State& value) const { return canonical.pressure(unpack(value)); } - POPS_HD State flux(const State& value, const Aux& aux, int axis) const { + POPS_HD State flux(const State& value, const auto& aux, int axis) const { return pack(canonical.flux(unpack(value), aux, axis)); } - POPS_HD Real max_wave_speed(const State& value, const Aux& aux, int axis) const { + POPS_HD Real max_wave_speed(const State& value, const auto& aux, int axis) const { return canonical.max_wave_speed(unpack(value), aux, axis); } - POPS_HD void wave_speeds(const State& value, const Aux& aux, int axis, Real& lower, + POPS_HD void wave_speeds(const State& value, const auto& aux, int axis, Real& lower, Real& upper) const { canonical.wave_speeds(unpack(value), aux, axis, lower, upper); } @@ -120,8 +121,8 @@ struct PermutedEuler { int axis) const { return pack(canonical.hllc_star_state(unpack(value), pressure_value, speed, contact, axis)); } - POPS_HD State roe_dissipation(const State& left, const Aux& left_aux, const State& right, - const Aux& right_aux, int axis) const { + POPS_HD State roe_dissipation(const State& left, const auto& left_aux, const State& right, + const auto& right_aux, int axis) const { return pack(canonical.roe_dissipation(unpack(left), left_aux, unpack(right), right_aux, axis)); } }; @@ -136,7 +137,7 @@ struct IsoHLLC { static constexpr int n_vars = 5; Real cs2 = 0.5; - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { const int in = (dir == 0) ? 1 : 2; const int it = (dir == 0) ? 2 : 1; const Real un = u[in] / u[0]; @@ -148,14 +149,14 @@ struct IsoHLLC { F[4] = u[4] * un; return F; } - POPS_HD Real max_wave_speed(const State& u, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const State& u, const auto&, int dir) const { const int in = (dir == 0) ? 1 : 2; const Real un = u[in] / u[0]; const Real c = std::sqrt(cs2); const Real a = un < 0 ? -un : un; return a + c; } - POPS_HD void wave_speeds(const State& u, const Aux&, int dir, Real& smin, Real& smax) const { + POPS_HD void wave_speeds(const State& u, const auto&, int dir, Real& smin, Real& smax) const { const int in = (dir == 0) ? 1 : 2; const Real un = u[in] / u[0]; const Real c = std::sqrt(cs2); @@ -197,7 +198,7 @@ struct DimensionalIsoHLLC { static constexpr int tracer_component = Dimension + 1; Real cs2 = Real(0.5); - POPS_HD State flux(const State& value, const Aux&, int axis) const { + POPS_HD State flux(const State& value, const auto&, int axis) const { const int normal = axis + 1; const Real normal_velocity = value[normal] / value[0]; State result{}; @@ -209,13 +210,13 @@ struct DimensionalIsoHLLC { return result; } - POPS_HD Real max_wave_speed(const State& value, const Aux&, int axis) const { + POPS_HD Real max_wave_speed(const State& value, const auto&, int axis) const { const Real normal_velocity = value[axis + 1] / value[0]; const Real absolute_velocity = normal_velocity < Real(0) ? -normal_velocity : normal_velocity; return absolute_velocity + std::sqrt(cs2); } - POPS_HD void wave_speeds(const State& value, const Aux&, int axis, Real& lower, + POPS_HD void wave_speeds(const State& value, const auto&, int axis, Real& lower, Real& upper) const { const Real normal_velocity = value[axis + 1] / value[0]; const Real sound_speed = std::sqrt(cs2); @@ -310,6 +311,10 @@ TEST(test_riemann_capabilities, compile_time_detection) { static_assert(pops::HasHLLCStructure); static_assert(pops::HasRoeDissipation); static_assert(pops::HasHLLCStructure, "IsoHLLC doit satisfaire HasHLLCStructure"); + static_assert(pops::HasHLLCStructure); + static_assert(pops::HasRoeDissipation); + static_assert(pops::HasHLLCStructure); + static_assert(pops::HasRoeDissipation); static_assert(pops::HasHLLCStructure>); static_assert(pops::HasHLLCStructure>); SUCCEED() << "detection des capabilities (Euler a-capabilites, Hooked/Iso capability)"; @@ -410,6 +415,40 @@ TEST(test_riemann_capabilities, non_euler_isothermal_hllc_consistency) { } } +TEST(test_riemann_capabilities, native_isothermal_provider_serves_hllc_and_roe) { + pops::IsothermalFlux model; + model.cs2 = Real(0.5); + const Aux providers{}; + const pops::IsothermalFlux::State value{Real(1.3), Real(0.4), Real(-0.7)}; + + for (int axis = 0; axis < 2; ++axis) { + const auto physical = model.flux(value, providers, axis); + const auto hllc = face_density(pops::HLLCFlux{}, model, value, providers, value, providers, + axis); + const auto roe = + face_density(pops::RoeFlux{}, model, value, providers, value, providers, axis); + EXPECT_LE(maxdiff(hllc, physical), 1e-13); + EXPECT_LE(maxdiff(roe, physical), 1e-13); + } +} + +TEST(test_riemann_capabilities, native_isothermal_contact_is_not_replaced_by_hll) { + pops::IsothermalFlux model; + model.cs2 = Real(0.5); + const Aux providers{}; + pops::IsothermalFlux::State left{Real(1), Real(0), Real(2)}; + pops::IsothermalFlux::State right{Real(1), Real(0), Real(-3)}; + + const auto hllc = + face_density(pops::HLLCFlux{}, model, left, providers, right, providers, 0); + const auto roe = face_density(pops::RoeFlux{}, model, left, providers, right, providers, 0); + const auto hll = face_density(pops::HLLFlux{}, model, left, providers, right, providers, 0); + EXPECT_LE(std::fabs(hllc[2]), 1e-14); + EXPECT_LE(std::fabs(roe[2]), 1e-14); + EXPECT_GE(std::fabs(hll[2]), 1e-2) + << "a hidden HLL fallback would make the contact-resolving providers diffusive"; +} + TEST(test_riemann_capabilities, hllc_provider_contract_is_dimension_independent) { const auto assert_consistency = []() { DimensionalIsoHLLC model; diff --git a/tests/cpp/unit/numerics/test_spatial_provider_matrix.cpp b/tests/cpp/unit/numerics/test_spatial_provider_matrix.cpp new file mode 100644 index 000000000..d71d02132 --- /dev/null +++ b/tests/cpp/unit/numerics/test_spatial_provider_matrix.cpp @@ -0,0 +1,81 @@ +#include + +#include + +using namespace pops; + +TEST(test_spatial_provider_matrix, native_cartesian_provider_qualifies_exact_operations) { + constexpr auto provider = make_cartesian_spatial_provider(2, /*characteristic_no_inflow=*/true, + /*boundary_linearization=*/true); + + EXPECT_TRUE(provider.supports( + {2, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual})); + EXPECT_TRUE(provider.supports( + {2, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::CharacteristicNoInflow})); + EXPECT_TRUE(provider.supports( + {2, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::BoundaryLinearization})); + EXPECT_FALSE( + provider.supports({2, SpatialProviderGeometry::CutCell, SpatialProviderOperation::Residual})); +} + +TEST(test_spatial_provider_matrix, native_runtime_dimension_refuses_unproved_3d_execution) { + constexpr auto provider = make_cartesian_spatial_provider(2); + constexpr auto refusal = qualify_spatial_provider( + provider, {3, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual}); + + static_assert(!refusal.executable); + static_assert(refusal.refusal == SpatialProviderRefusal::UnsupportedDimension); + EXPECT_FALSE(refusal.executable); +} + +TEST(test_spatial_provider_matrix, independent_axes_do_not_form_false_cross_product_capabilities) { + SpatialProviderCapabilities provider; + provider.enable(1, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual); + provider.enable(3, SpatialProviderGeometry::Polar, SpatialProviderOperation::Residual); + + EXPECT_TRUE(provider.supports( + {1, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual})); + EXPECT_TRUE( + provider.supports({3, SpatialProviderGeometry::Polar, SpatialProviderOperation::Residual})); + EXPECT_FALSE(provider.supports( + {3, SpatialProviderGeometry::Cartesian, SpatialProviderOperation::Residual})); + EXPECT_FALSE( + provider.supports({1, SpatialProviderGeometry::Polar, SpatialProviderOperation::Residual})); + EXPECT_EQ(qualify_spatial_provider(provider, {3, SpatialProviderGeometry::Cartesian, + SpatialProviderOperation::Residual}) + .refusal, + SpatialProviderRefusal::UnsupportedGeometry); +} + +TEST(test_spatial_provider_matrix, + embedded_metric_residuals_do_not_claim_characteristic_or_linearization) { + constexpr auto provider = with_embedded_boundary_residuals( + make_cartesian_spatial_provider(2, /*characteristic_no_inflow=*/true, + /*boundary_linearization=*/true)); + + for (const auto geometry : + {SpatialProviderGeometry::Staircase, SpatialProviderGeometry::CutCell}) { + EXPECT_TRUE(provider.supports({2, geometry, SpatialProviderOperation::Residual})); + const auto characteristic = qualify_spatial_provider( + provider, {2, geometry, SpatialProviderOperation::CharacteristicNoInflow}); + const auto linearization = qualify_spatial_provider( + provider, {2, geometry, SpatialProviderOperation::BoundaryLinearization}); + EXPECT_EQ(characteristic.refusal, SpatialProviderRefusal::UnsupportedOperation); + EXPECT_EQ(linearization.refusal, SpatialProviderRefusal::UnsupportedOperation); + } +} + +TEST(test_spatial_provider_matrix, polar_metric_provider_is_residual_only) { + constexpr auto provider = make_polar_spatial_provider(2); + + EXPECT_TRUE( + provider.supports({2, SpatialProviderGeometry::Polar, SpatialProviderOperation::Residual})); + EXPECT_EQ(qualify_spatial_provider(provider, {2, SpatialProviderGeometry::Cartesian, + SpatialProviderOperation::Residual}) + .refusal, + SpatialProviderRefusal::UnsupportedGeometry); + EXPECT_EQ(qualify_spatial_provider(provider, {2, SpatialProviderGeometry::Polar, + SpatialProviderOperation::CharacteristicNoInflow}) + .refusal, + SpatialProviderRefusal::UnsupportedOperation); +} diff --git a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp new file mode 100644 index 000000000..6cbcb4a89 --- /dev/null +++ b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp @@ -0,0 +1,413 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +using pops::Real; + +template +struct AcceptPositive { + POPS_HD bool operator()(const Real (&value)[N], int* component = nullptr) const { + for (int index = 0; index < N; ++index) + if (!(value[index] > Real(0))) { + if (component != nullptr) + *component = index; + return false; + } + if (component != nullptr) + *component = -1; + return true; + } +}; + +struct UnavailableClosedForm { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kClosedForm; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + return pops::RecoveryMethodResult<1>::continue_chain( + pops::RecoveryCause::kClosedFormUnavailable); + } +}; + +struct SquareResidual { + Real target = 0; + + POPS_HD pops::LocalNonlinearEvaluationResult operator()(const Real (&value)[1], + Real (&residual)[1]) const { + residual[0] = value[0] * value[0] - target; + return pops::LocalNonlinearEvaluationResult::ok(); + } +}; + +struct SquareProblemFactory { + POPS_HD auto operator()(const Real (&conserved)[1]) const { + pops::PreparedLocalNonlinearControls controls; + controls.max_iterations = 16; + controls.absolute_tolerance = Real(1e-13); + return pops::prepare_local_nonlinear_problem<1>(SquareResidual{conserved[0]}, + pops::FiniteDifferenceLocalJacobian<1>{}, + AcceptPositive<1>{}, controls); + } +}; + +struct NegativeCandidate { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kClosedForm; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + const Real value[1] = {Real(-2)}; + return pops::RecoveryMethodResult<1>::candidate(value); + } +}; + +struct ExplicitReject { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kCustom; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + return pops::RecoveryMethodResult<1>::reject(pops::RecoveryCause::kExplicitRejection); + } +}; + +struct InitialGuessOrReject { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kCustom; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&conserved)[1], + const Real (&initial_guess)[1]) const { + if (conserved[0] < Real(0)) + return pops::RecoveryMethodResult<1>::reject(pops::RecoveryCause::kExplicitRejection); + return pops::RecoveryMethodResult<1>::candidate(initial_guess); + } +}; + +struct NonFiniteCandidate { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kCustom; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + const Real value[1] = {std::numeric_limits::quiet_NaN()}; + return pops::RecoveryMethodResult<1>::candidate(value); + } +}; + +struct RepairCandidate { + static constexpr pops::RecoveryMethodKind kind = pops::RecoveryMethodKind::kRepair; + + POPS_HD pops::RecoveryMethodResult<1> operator()(const Real (&)[1], const Real (&)[1]) const { + const Real value[1] = {Real(1)}; + return pops::RecoveryMethodResult<1>::candidate(value); + } +}; + +struct GuardedScalarHyperbolic { + using State = pops::StateVec<1>; + using Prim = pops::StateVec<1>; + using Aux = pops::Aux; + static constexpr int n_vars = 1; + + POPS_HD State flux(const State& value, const Aux&, int) const { return value; } + POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(1); } + POPS_HD Prim to_primitive(const State& value) const { return value; } + POPS_HD State to_conservative(const Prim& value) const { return value; } + POPS_HD bool recovery_admissible(const Prim& value, int* failing_component) const { + if (!(value[0] > Real(0))) { + if (failing_component != nullptr) + *failing_component = 0; + return false; + } + if (failing_component != nullptr) + *failing_component = -1; + return true; + } + static pops::VariableSet conservative_vars() { + return {pops::VariableKind::Conservative, {"q"}, 1, {pops::VariableRole::Scalar}}; + } + static pops::VariableSet primitive_vars() { + return {pops::VariableKind::Primitive, {"q"}, 1, {pops::VariableRole::Scalar}}; + } +}; + +using GuardedScalarModel = + pops::CompositeModel; + +static_assert(pops::HyperbolicPhysicalModel); +static_assert(pops::HasRecoveryAdmissibility); + +TEST(PreparedVariableRecovery, ordered_chain_uses_common_prepared_solver) { + const auto methods = pops::recovery_methods( + UnavailableClosedForm{}, pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(AcceptPositive<1>{}, methods); + + static_assert(decltype(methods)::size == 2); + EXPECT_EQ(plan.method_kind(0), pops::RecoveryMethodKind::kClosedForm); + EXPECT_EQ(plan.method_kind(1), pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_EQ(plan.method_kind(2), pops::RecoveryMethodKind::kUnknown); + + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + + ASSERT_TRUE(outcome.recovered()); + EXPECT_TRUE(outcome.publication_permitted()); + EXPECT_EQ(outcome.attempted_methods, 2); + EXPECT_EQ(outcome.selected_method, 1); + EXPECT_EQ(outcome.last_method, 1); + EXPECT_EQ(outcome.selected_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_EQ(outcome.last_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_GT(outcome.total_iterations, 0); + EXPECT_NEAR(outcome.value[0], Real(2), Real(1e-10)); +} + +TEST(PreparedVariableRecovery, type_erased_report_preserves_selected_method_kind) { + const auto methods = pops::recovery_methods( + UnavailableClosedForm{}, pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{})); + const auto plan = pops::prepare_variable_recovery<1>(AcceptPositive<1>{}, methods); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + + const auto report = + pops::recovery_report(pops::recover_prepared_variable(plan, conserved, initial_guess)); + + ASSERT_TRUE(report.publication_permitted()); + EXPECT_EQ(report.selected_method, 1); + EXPECT_EQ(report.last_method, 1); + EXPECT_EQ(report.selected_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_EQ(report.last_method_kind, pops::RecoveryMethodKind::kPreparedLocalNonlinear); + EXPECT_STREQ(pops::recovery_method_kind_name(report.selected_method_kind), + "prepared_local_nonlinear"); +} + +TEST(PreparedVariableRecovery, rejected_chain_never_changes_solution_or_cache) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(NegativeCandidate{}, ExplicitReject{})); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + + EXPECT_EQ(outcome.status, pops::RecoveryStatus::kRejected); + EXPECT_EQ(outcome.cause, pops::RecoveryCause::kExplicitRejection); + EXPECT_EQ(outcome.attempted_methods, 2); + EXPECT_EQ(outcome.selected_method, -1); + EXPECT_EQ(outcome.selected_method_kind, pops::RecoveryMethodKind::kUnknown); + EXPECT_EQ(outcome.last_method, 1); + EXPECT_EQ(outcome.last_method_kind, pops::RecoveryMethodKind::kCustom); + EXPECT_FALSE(outcome.publication_permitted()); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + + EXPECT_FALSE(transaction.publish_tentative(outcome, 4, 8)); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); + EXPECT_TRUE(transaction.rollback()); + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); +} + +TEST(PreparedVariableRecovery, rejected_report_names_last_method_without_forging_selection) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(NegativeCandidate{}, ExplicitReject{})); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + + const auto report = + pops::recovery_report(pops::recover_prepared_variable(plan, conserved, initial_guess)); + + EXPECT_EQ(report.status, pops::RecoveryStatus::kRejected); + EXPECT_EQ(report.selected_method, -1); + EXPECT_EQ(report.selected_method_kind, pops::RecoveryMethodKind::kUnknown); + EXPECT_EQ(report.last_method, 1); + EXPECT_EQ(report.last_method_kind, pops::RecoveryMethodKind::kCustom); + EXPECT_STREQ(pops::recovery_method_kind_name(report.last_method_kind), "custom"); +} + +TEST(PreparedVariableRecovery, tentative_publication_rolls_back_solution_and_warm_start) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{}))); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + ASSERT_TRUE(outcome.recovered()); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + { + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + ASSERT_TRUE(transaction.publish_tentative(outcome, 4, 8)); + EXPECT_NEAR(accepted[0], Real(2), Real(1e-10)); + EXPECT_NEAR(cache.value[0], Real(2), Real(1e-10)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{4}); + EXPECT_EQ(cache.state_generation, std::uint64_t{8}); + ASSERT_TRUE(transaction.rollback()); + } + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); + + pops::RecoveryPublicationTransaction<1> committed(accepted, cache); + ASSERT_TRUE(committed.publish_tentative(outcome, 4, 8)); + ASSERT_TRUE(committed.commit()); + EXPECT_FALSE(committed.rollback()); + EXPECT_NEAR(accepted[0], Real(2), Real(1e-10)); + EXPECT_NEAR(cache.value[0], Real(2), Real(1e-10)); +} + +TEST(PreparedVariableRecovery, scope_exit_rolls_back_an_uncommitted_publication) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{}))); + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + const auto outcome = pops::recover_prepared_variable(plan, conserved, initial_guess); + ASSERT_TRUE(outcome.recovered()); + + Real accepted[1] = {Real(9)}; + pops::RecoveryWarmStartSlot<1> cache; + const Real cached[1] = {Real(8)}; + cache.store(cached, 3, 7); + { + pops::RecoveryPublicationTransaction<1> transaction(accepted, cache); + ASSERT_TRUE(transaction.publish_tentative(outcome, 4, 8)); + EXPECT_NEAR(accepted[0], Real(2), Real(1e-10)); + } + EXPECT_EQ(accepted[0], Real(9)); + EXPECT_EQ(cache.value[0], Real(8)); + EXPECT_EQ(cache.topology_generation, std::uint64_t{3}); + EXPECT_EQ(cache.state_generation, std::uint64_t{7}); +} + +TEST(PreparedVariableRecovery, stale_warm_start_is_an_explicit_non_mutating_miss) { + pops::RecoveryWarmStartSlot<2> cache; + const Real cached[2] = {Real(3), Real(4)}; + cache.store(cached, 5, 9); + Real destination[2] = {Real(11), Real(12)}; + + EXPECT_FALSE(cache.load_if_current(6, 9, destination)); + EXPECT_EQ(destination[0], Real(11)); + EXPECT_EQ(destination[1], Real(12)); + EXPECT_EQ(cache.value[0], Real(3)); + EXPECT_EQ(cache.value[1], Real(4)); + EXPECT_TRUE(cache.valid); + + EXPECT_TRUE(cache.load_if_current(5, 9, destination)); + EXPECT_EQ(destination[0], Real(3)); + EXPECT_EQ(destination[1], Real(4)); +} + +TEST(PreparedVariableRecovery, uniform_consumer_reuses_only_exact_generation_qualified_cells) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, + pops::recovery_methods(pops::prepared_local_nonlinear_recovery<1>(SquareProblemFactory{}))); + pops::PreparedUniformRecoveryConsumer<1, decltype(plan)> consumer(plan); + + const std::vector first_conserved{4.0, 9.0}; + std::vector primitive{77.0}; + const auto first = consumer.recover(first_conserved, primitive); + ASSERT_TRUE(first.publication_permitted()); + EXPECT_EQ(first.cache_hits, std::size_t{0}); + EXPECT_EQ(first.topology_generation, std::uint64_t{1}); + EXPECT_EQ(first.state_generation, std::uint64_t{1}); + ASSERT_EQ(primitive.size(), std::size_t{2}); + EXPECT_NEAR(primitive[0], 2.0, 1e-10); + EXPECT_NEAR(primitive[1], 3.0, 1e-10); + + const auto repeated = consumer.recover(first_conserved, primitive); + ASSERT_TRUE(repeated.publication_permitted()); + EXPECT_EQ(repeated.cache_hits, std::size_t{2}); + EXPECT_EQ(repeated.topology_generation, std::uint64_t{1}); + EXPECT_EQ(repeated.state_generation, std::uint64_t{2}); + + const std::vector one_changed{16.0, 9.0}; + const auto changed = consumer.recover(one_changed, primitive); + ASSERT_TRUE(changed.publication_permitted()); + EXPECT_EQ(changed.cache_hits, std::size_t{1}); + EXPECT_NEAR(primitive[0], 4.0, 1e-10); + EXPECT_NEAR(primitive[1], 3.0, 1e-10); +} + +TEST(PreparedVariableRecovery, uniform_consumer_failure_keeps_output_and_invalidates_all_slots) { + const auto plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(InitialGuessOrReject{})); + pops::PreparedUniformRecoveryConsumer<1, decltype(plan)> consumer(plan); + + const std::vector accepted{4.0, 9.0}; + std::vector primitive; + ASSERT_TRUE(consumer.recover(accepted, primitive).publication_permitted()); + + const std::vector rejected{4.0, -1.0}; + const std::vector sentinel{31.0, 41.0}; + primitive = sentinel; + const auto failed = consumer.recover(rejected, primitive); + EXPECT_FALSE(failed.publication_permitted()); + EXPECT_EQ(failed.failed_cell, std::size_t{1}); + EXPECT_EQ(failed.cache_hits, std::size_t{1}); + EXPECT_EQ(failed.recovery.status, pops::RecoveryStatus::kRejected); + EXPECT_EQ(primitive, sentinel); + + const auto retry = consumer.recover(accepted, primitive); + ASSERT_TRUE(retry.publication_permitted()); + EXPECT_EQ(retry.cache_hits, std::size_t{0}) + << "a failed batch must invalidate slots committed earlier in that batch"; +} + +TEST(PreparedVariableRecovery, malformed_and_repair_candidates_fail_closed) { + const Real conserved[1] = {Real(4)}; + const Real initial_guess[1] = {Real(1)}; + + const auto malformed_plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(NonFiniteCandidate{}, ExplicitReject{})); + const auto malformed = pops::recover_prepared_variable(malformed_plan, conserved, initial_guess); + EXPECT_EQ(malformed.status, pops::RecoveryStatus::kInvalidContract); + EXPECT_EQ(malformed.cause, pops::RecoveryCause::kNonFiniteCandidate); + EXPECT_EQ(malformed.attempted_methods, 1); + EXPECT_FALSE(malformed.publication_permitted()); + + const auto repair_plan = pops::prepare_variable_recovery<1>( + AcceptPositive<1>{}, pops::recovery_methods(RepairCandidate{})); + const auto repair = pops::recover_prepared_variable(repair_plan, conserved, initial_guess); + EXPECT_EQ(repair.status, pops::RecoveryStatus::kInvalidContract); + EXPECT_EQ(repair.cause, pops::RecoveryCause::kRepairPublicationForbidden); + EXPECT_FALSE(repair.publication_permitted()); +} + +TEST(PreparedVariableRecovery, model_declared_admissibility_blocks_publication) { + const GuardedScalarModel model{}; + const auto plan = pops::prepare_model_variable_recovery(model); + EXPECT_EQ(plan.method_kind(0), pops::RecoveryMethodKind::kClosedForm); + + const Real negative[1] = {Real(-1)}; + const Real negative_guess[1] = {Real(2)}; + const auto rejected = pops::recover_prepared_variable(plan, negative, negative_guess); + EXPECT_EQ(rejected.status, pops::RecoveryStatus::kExhausted); + EXPECT_EQ(rejected.cause, pops::RecoveryCause::kInadmissibleCandidate); + EXPECT_EQ(rejected.failing_component, 0); + EXPECT_FALSE(rejected.publication_permitted()); +} + +TEST(PreparedVariableRecovery, model_declared_admissibility_permits_valid_candidate) { + const GuardedScalarModel model{}; + const auto plan = pops::prepare_model_variable_recovery(model); + const Real positive[1] = {Real(3)}; + const Real positive_guess[1] = {Real(1)}; + const auto recovered = pops::recover_prepared_variable(plan, positive, positive_guess); + ASSERT_TRUE(recovered.publication_permitted()); + EXPECT_EQ(recovered.failing_component, -1); + EXPECT_EQ(recovered.value[0], Real(3)); +} + +} // namespace diff --git a/tests/cpp/unit/numerics/test_weno_convergence.cpp b/tests/cpp/unit/numerics/test_weno_convergence.cpp index d10b0d853..4f49f79d0 100644 --- a/tests/cpp/unit/numerics/test_weno_convergence.cpp +++ b/tests/cpp/unit/numerics/test_weno_convergence.cpp @@ -8,8 +8,11 @@ #include #include +#include #include #include +#include +#include using namespace pops; @@ -53,6 +56,78 @@ struct AmbiguousPolicy { struct MissingPolicy {}; +/// Minimal conservative model used only to exercise the production face-state reconstruction +/// protocol. The qualification below never calls a limiter formula directly. +struct ScalarReconstructionModel { + using State = StateVec<1>; + static constexpr int n_vars = 1; +}; + +struct PeriodicFaceStates { + std::vector left; + std::vector right; + bool publication_permitted = true; +}; + +double favg(double a, double b); + +int periodic_index(int index, int size) { + const int remainder = index % size; + return remainder < 0 ? remainder + size : remainder; +} + +template +PeriodicFaceStates reconstruct_periodic_faces(const std::vector& cell_averages) { + const int size = static_cast(cell_averages.size()); + Fab2D values(Box2D::from_extents(size, 1), ScalarReconstructionModel::n_vars, Limiter::n_ghost); + for (int i = values.grown_box().lo[0]; i <= values.grown_box().hi[0]; ++i) + values(i, 0, 0) = cell_averages[periodic_index(i, size)]; + + const ScalarReconstructionModel model{}; + const Limiter limiter{}; + PeriodicFaceStates result{std::vector(size), std::vector(size), true}; + for (int i = 0; i < size; ++i) { + const auto left = + reconstruct_recovered(model, values.const_array(), i, 0, 0, Real(-1), limiter, false); + const auto right = + reconstruct_recovered(model, values.const_array(), i, 0, 0, Real(1), limiter, false); + result.publication_permitted = result.publication_permitted && left.publication_permitted() && + right.publication_permitted(); + result.left[i] = left.value[0]; + result.right[i] = right.value[0]; + } + return result; +} + +struct SmoothFaceError { + double l1 = 0; + bool publication_permitted = false; +}; + +template +SmoothFaceError smooth_periodic_face_error(int size) { + const double dx = 1.0 / static_cast(size); + std::vector cell_averages(size); + for (int i = 0; i < size; ++i) + cell_averages[i] = Real(favg(i * dx, (i + 1) * dx)); + + const auto reconstructed = reconstruct_periodic_faces(cell_averages); + double error = 0; + for (int i = 0; i < size; ++i) { + const double exact = std::sin(Real(2) * kPi * (i + 1) * dx); + error += std::fabs(static_cast(reconstructed.right[i]) - exact); + } + return {error / static_cast(size), reconstructed.publication_permitted}; +} + +double interface_jump_budget(const PeriodicFaceStates& states) { + double result = 0; + const int size = static_cast(states.left.size()); + for (int i = 0; i < size; ++i) + result += std::fabs(static_cast(states.left[(i + 1) % size] - states.right[i])); + return result; +} + /// A nonlinear conservative/primitive conversion makes the two reconstruction paths observably /// different while remaining exactly invertible for the positive test data. struct PrimitiveTestModel { @@ -62,8 +137,8 @@ struct PrimitiveTestModel { static constexpr int n_vars = 2; int* primitive_calls = nullptr; - POPS_HD State flux(const State& state, const Aux&, int) const { return state; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(1); } + POPS_HD State flux(const State& state, const auto&, int) const { return state; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(1); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State&) const { return Real(0); } @@ -79,6 +154,10 @@ struct PrimitiveTestModel { }; static_assert(SlopeReconstruction); +static_assert(ReconstructionPolicy); +static_assert(ReconstructionPolicy); +static_assert(MC::formal_order == 2 && MC::n_ghost == 2); +static_assert(Superbee::formal_order == 2 && Superbee::n_ghost == 2); static_assert(!StencilReconstruction); static_assert(StencilReconstruction); static_assert(ReconstructionPolicy); @@ -104,6 +183,136 @@ TEST(test_weno_convergence, reconstruction_protocol_is_independent_of_storage_ra EXPECT_EQ(policy.limited_slope(Real(2), Real(4)), Real(3)); } +TEST(test_muscl_limiters, mc_and_superbee_match_reference_formulas) { + const MC mc{}; + const Superbee superbee{}; + + EXPECT_EQ(mc.limited_slope(Real(1), Real(3)), Real(2)); + EXPECT_EQ(mc.limited_slope(Real(2), Real(4)), Real(3)); + EXPECT_EQ(mc.limited_slope(Real(3), Real(1)), Real(2)); + EXPECT_EQ(superbee.limited_slope(Real(1), Real(3)), Real(2)); + EXPECT_EQ(superbee.limited_slope(Real(2), Real(4)), Real(4)); + EXPECT_EQ(superbee.limited_slope(Real(3), Real(1)), Real(2)); +} + +TEST(test_muscl_limiters, zero_opposite_sign_symmetry_and_homogeneity) { + const MC mc{}; + const Superbee superbee{}; + for (const auto limiter : {0, 1}) { + const auto slope = [&](Real a, Real b) { + return limiter == 0 ? mc.limited_slope(a, b) : superbee.limited_slope(a, b); + }; + EXPECT_EQ(slope(Real(0), Real(4)), Real(0)); + EXPECT_EQ(slope(Real(4), Real(0)), Real(0)); + EXPECT_EQ(slope(Real(-2), Real(3)), Real(0)); + EXPECT_EQ(slope(Real(2), Real(-3)), Real(0)); + EXPECT_EQ(slope(Real(-2), Real(-4)), -slope(Real(2), Real(4))); + EXPECT_EQ(slope(Real(6), Real(12)), Real(3) * slope(Real(2), Real(4))); + } +} + +TEST(test_muscl_limiters, sweby_tvd_bounds_and_finite_extremes) { + const MC mc{}; + const Superbee superbee{}; + for (const Real backward : {Real(0.25), Real(1), Real(2), Real(8)}) { + for (const Real forward : {Real(0.5), Real(1), Real(4), Real(16)}) { + const Real tvd_bound = Real(2) * std::min(backward, forward); + for (const Real slope : + {mc.limited_slope(backward, forward), superbee.limited_slope(backward, forward)}) { + EXPECT_GE(slope, Real(0)); + EXPECT_LE(slope, tvd_bound); + } + } + } + + const Real maximum = std::numeric_limits::max(); + EXPECT_TRUE(std::isfinite(mc.limited_slope(maximum, maximum))); + EXPECT_TRUE(std::isfinite(superbee.limited_slope(maximum, maximum))); + EXPECT_EQ(mc.limited_slope(maximum, maximum), maximum); + EXPECT_EQ(superbee.limited_slope(maximum, maximum), maximum); + EXPECT_EQ(mc.limited_slope(-maximum, -maximum), -maximum); + EXPECT_EQ(superbee.limited_slope(-maximum, -maximum), -maximum); +} + +TEST(test_muscl_limiter_qualification, + mc_and_superbee_are_second_order_on_smooth_periodic_cell_averages) { + const auto mc_128 = smooth_periodic_face_error(128); + const auto mc_256 = smooth_periodic_face_error(256); + const auto superbee_128 = smooth_periodic_face_error(128); + const auto superbee_256 = smooth_periodic_face_error(256); + const auto minmod_256 = smooth_periodic_face_error(256); + const auto vanleer_256 = smooth_periodic_face_error(256); + + EXPECT_TRUE(mc_128.publication_permitted && mc_256.publication_permitted); + EXPECT_TRUE(superbee_128.publication_permitted && superbee_256.publication_permitted); + EXPECT_TRUE(minmod_256.publication_permitted && vanleer_256.publication_permitted); + + const double mc_order = std::log(mc_128.l1 / mc_256.l1) / std::log(2.0); + const double superbee_order = std::log(superbee_128.l1 / superbee_256.l1) / std::log(2.0); + EXPECT_GT(mc_order, 1.85); + EXPECT_LT(mc_order, 2.20); + EXPECT_GT(superbee_order, 1.85); + EXPECT_LT(superbee_order, 2.20); + + // Fixed-resolution characterization, not a universal ranking: MC tracks the smoother Van Leer + // reconstruction on this wave, while Superbee remains more accurate than Minmod but less + // accurate than Van Leer around smooth extrema. + EXPECT_LT(mc_256.l1, minmod_256.l1); + EXPECT_LT(superbee_256.l1, minmod_256.l1); + EXPECT_LT(vanleer_256.l1, superbee_256.l1); +} + +TEST(test_muscl_limiter_qualification, + discontinuities_create_no_extremum_and_expose_interface_dissipation_budget) { + const auto assert_locally_bounded = [](const std::vector& averages, + const PeriodicFaceStates& states) { + ASSERT_TRUE(states.publication_permitted); + const int size = static_cast(averages.size()); + const Real tolerance = Real(32) * std::numeric_limits::epsilon(); + for (int i = 0; i < size; ++i) { + const Real left_min = std::min(averages[periodic_index(i - 1, size)], averages[i]); + const Real left_max = std::max(averages[periodic_index(i - 1, size)], averages[i]); + const Real right_min = std::min(averages[i], averages[(i + 1) % size]); + const Real right_max = std::max(averages[i], averages[(i + 1) % size]); + EXPECT_GE(states.left[i], left_min - tolerance); + EXPECT_LE(states.left[i], left_max + tolerance); + EXPECT_GE(states.right[i], right_min - tolerance); + EXPECT_LE(states.right[i], right_max + tolerance); + } + }; + + const std::vector discontinuity = {Real(0), Real(0), Real(0), Real(0), + Real(1), Real(1), Real(1), Real(1)}; + assert_locally_bounded(discontinuity, reconstruct_periodic_faces(discontinuity)); + assert_locally_bounded(discontinuity, reconstruct_periodic_faces(discontinuity)); + + // Binary fractions keep this steep periodic shoulder deterministic in float and double. For a + // scalar Rusanov flux at fixed wave speed, sum |U_R-U_L| is proportional to the absolute + // dissipative interface penalty. The ordering is deliberately fixture-specific and records the + // actual trade-off instead of claiming that one limiter is universally least dissipative. + const std::vector shoulder = { + Real(0), Real(0), Real(1) / 16, Real(3) / 16, Real(6) / 16, Real(10) / 16, + Real(13) / 16, Real(15) / 16, Real(1), Real(1), Real(15) / 16, Real(13) / 16, + Real(10) / 16, Real(6) / 16, Real(3) / 16, Real(1) / 16, + }; + const auto minmod = reconstruct_periodic_faces(shoulder); + const auto vanleer = reconstruct_periodic_faces(shoulder); + const auto mc = reconstruct_periodic_faces(shoulder); + const auto superbee = reconstruct_periodic_faces(shoulder); + assert_locally_bounded(shoulder, minmod); + assert_locally_bounded(shoulder, vanleer); + assert_locally_bounded(shoulder, mc); + assert_locally_bounded(shoulder, superbee); + + const double minmod_jump = interface_jump_budget(minmod); + const double vanleer_jump = interface_jump_budget(vanleer); + const double mc_jump = interface_jump_budget(mc); + const double superbee_jump = interface_jump_budget(superbee); + EXPECT_LT(mc_jump, vanleer_jump); + EXPECT_LT(vanleer_jump, superbee_jump); + EXPECT_LT(superbee_jump, minmod_jump); +} + TEST(test_weno_convergence, external_sampled_policy_controls_offsets_and_orientation) { const Box2D valid = Box2D::from_extents(11, 1); Fab2D values(valid, PrimitiveTestModel::n_vars, ExternalFourSamplePolicy::n_ghost); diff --git a/tests/cpp/unit/parallel/test_world_communicator.cpp b/tests/cpp/unit/parallel/test_world_communicator.cpp index 7c6028cc8..eca1caed8 100644 --- a/tests/cpp/unit/parallel/test_world_communicator.cpp +++ b/tests/cpp/unit/parallel/test_world_communicator.cpp @@ -111,12 +111,12 @@ TEST(WorldCommunicator, TransfersEmptyNullAndVariableSizedBytes) { } TEST(WorldCommunicator, GathersOutputPiecesOnlyOnRoot) { - pops::WorldCommunicator& world = pops::WorldCommunicator::world(); + auto lane = pops::ObserverMpiLane::duplicate_world_collectively("test/output-piece/gather"); #ifdef POPS_HAS_MPI - const int rank = world.rank(); - const int size = world.size(); + const int rank = lane.rank(); + const int size = lane.size(); std::vector result = pops::output_pieces_to_root( - world, pops::detail::output_collective_identity("test", "state", "tracer", 0), [rank] { + lane, pops::detail::output_collective_identity("test", "state", "tracer", 0), [rank] { pops::OutputPiece piece; piece.box = pops::PatchBox{0, rank, 0, rank, 0}; piece.global_box_index = rank; @@ -141,18 +141,19 @@ TEST(WorldCommunicator, GathersOutputPiecesOnlyOnRoot) { } #else EXPECT_THROW((void)pops::output_pieces_to_root( - world, pops::detail::output_collective_identity("test", "state", "tracer", 0), + lane, pops::detail::output_collective_identity("test", "state", "tracer", 0), [] { return std::vector{}; }), std::runtime_error); #endif + lane.close_collectively(); } TEST(WorldCommunicator, SelectsOneCanonicalReplicatedOutputContributor) { - pops::WorldCommunicator& world = pops::WorldCommunicator::world(); + auto lane = pops::ObserverMpiLane::duplicate_world_collectively("test/output-piece/replicated"); #ifdef POPS_HAS_MPI - const int rank = world.rank(); + const int rank = lane.rank(); std::vector result = pops::output_pieces_to_root( - world, pops::detail::output_collective_identity("test", "state", "replicated", 0), [rank] { + lane, pops::detail::output_collective_identity("test", "state", "replicated", 0), [rank] { pops::OutputPiece piece; piece.box = pops::PatchBox{0, 0, 0, 0, 0}; piece.global_box_index = 0; @@ -173,10 +174,10 @@ TEST(WorldCommunicator, SelectsOneCanonicalReplicatedOutputContributor) { EXPECT_TRUE(result.empty()); } #else - EXPECT_THROW( - (void)pops::output_pieces_to_root( - world, pops::detail::output_collective_identity("test", "state", "replicated", 0), - [] { return std::vector{}; }), - std::runtime_error); + EXPECT_THROW((void)pops::output_pieces_to_root( + lane, pops::detail::output_collective_identity("test", "state", "replicated", 0), + [] { return std::vector{}; }), + std::runtime_error); #endif + lane.close_collectively(); } diff --git a/tests/cpp/unit/physics/test_adaptive_multirate.cpp b/tests/cpp/unit/physics/test_adaptive_multirate.cpp index b82f8e733..14673e9ff 100644 --- a/tests/cpp/unit/physics/test_adaptive_multirate.cpp +++ b/tests/cpp/unit/physics/test_adaptive_multirate.cpp @@ -28,10 +28,10 @@ struct AdvectProduce { using Aux = pops::Aux; static constexpr int n_vars = 1; Real a = Real(1), rate = Real(1); - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{dir == 0 ? a * u[0] : Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return a < 0 ? -a : a; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return a < 0 ? -a : a; } POPS_HD State source(const State&, const Aux&) const { return State{rate}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/unit/physics/test_aux_coupler_bz.cpp b/tests/cpp/unit/physics/test_aux_coupler_bz.cpp index 7bfc36cd4..01c540776 100644 --- a/tests/cpp/unit/physics/test_aux_coupler_bz.cpp +++ b/tests/cpp/unit/physics/test_aux_coupler_bz.cpp @@ -29,8 +29,8 @@ struct BzGrow { using Aux = pops::Aux; static constexpr int n_vars = 1; static constexpr int n_aux = 4; // phi, grad_x, grad_y, B_z - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux& a) const { State s{}; s[0] = a.B_z * u[0]; diff --git a/tests/cpp/unit/physics/test_aux_extra.cpp b/tests/cpp/unit/physics/test_aux_extra.cpp index 080f77a59..c3a65f8eb 100644 --- a/tests/cpp/unit/physics/test_aux_extra.cpp +++ b/tests/cpp/unit/physics/test_aux_extra.cpp @@ -34,8 +34,8 @@ struct MagSource { using Aux = pops::Aux; static constexpr int n_vars = 1; static constexpr int n_aux = 4; // phi, grad_x, grad_y, B_z - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux& a) const { State s{}; s[0] = a.B_z * u[0]; @@ -49,8 +49,8 @@ struct GradSource { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux& a) const { State s{}; s[0] = a.grad_x * u[0]; diff --git a/tests/cpp/unit/physics/test_multirate_stride.cpp b/tests/cpp/unit/physics/test_multirate_stride.cpp index d6962c7b5..34323edf1 100644 --- a/tests/cpp/unit/physics/test_multirate_stride.cpp +++ b/tests/cpp/unit/physics/test_multirate_stride.cpp @@ -26,8 +26,8 @@ struct Production { using Aux = pops::Aux; static constexpr int n_vars = 1; Real rate = Real(1); - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{rate}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/unit/physics/test_polar_fluid_transport.cpp b/tests/cpp/unit/physics/test_polar_fluid_transport.cpp index 6042de03e..59e31d59a 100644 --- a/tests/cpp/unit/physics/test_polar_fluid_transport.cpp +++ b/tests/cpp/unit/physics/test_polar_fluid_transport.cpp @@ -23,7 +23,7 @@ // test_polar_transport_mms). Confirme que le transport RADIAL + AZIMUTAL des 3 variables, // metrique 1/r ET terme geometrique compris, converge proprement. // -// (C) CONSERVATION DE LA MASSE : sur une avance SSPRK3 avec PAROI radiale (wall_radial), la masse +// (C) CONSERVATION DE LA MASSE : sur une avance SSPRK3 avec des faces radiales NoFlux, la masse // Sum_ij rho_ij r_i dr dtheta est conservee a ~machine (le terme geometrique n'agit QUE sur // la quantite de mouvement, sa composante 0 est nulle -> il ne cree ni ne detruit de masse). // @@ -46,6 +46,8 @@ #include #include +#include "polar_boundary_plan.hpp" + #include #include @@ -104,11 +106,13 @@ static double equilibrium_residual_radial(int nr, int nth, const Model& model) { U.set_val(0.0); aux.set_val(0.0); fill_equilibrium(U, g); + const auto boundary_plan = + test_support::polar_boundary_plan(Model::n_vars, false, Weno5::n_ghost); - // recon_prim=true : reconstruction en (rho, v_r, v_theta) (positivite). wall_radial=false : on veut - // le residu interieur PUR (pas de paroi qui annulerait le flux de bord et masquerait la troncature). - assemble_rhs_polar(model, U, aux, g, R, /*recon_prim=*/true, - /*wall_radial=*/false); + // recon_prim=true : reconstruction en (rho, v_r, v_theta) (positivite). Les faces radiales + // extrapolees conservent le flux de Riemann : on mesure le residu interieur pur. + assemble_rhs_polar(model, U, aux, g, R, *boundary_plan, + /*recon_prim=*/true); sync_host(); const ConstArray4 r = R.fab(0).const_array(); double linf = 0.0; @@ -315,13 +319,16 @@ static double run_mms_fluid(int nr, int nth) { const double dt = 0.25 * ds_min / vmax; const int nsteps = static_cast(std::ceil(kTfinal / dt)); const double dt_eff = kTfinal / nsteps; + const auto boundary_plan = + test_support::polar_boundary_plan(MmsFluidPolar::n_vars, false, Limiter::n_ghost); for (int s = 0; s < nsteps; ++s) { SSPRK3Step{}.take_step( [&](MultiFab& stage, MultiFab& R) { fill_ghosts(stage, dom, bc); fill_mms_radial_ghosts(stage, g, dom); - assemble_rhs_polar(model, stage, aux, g, R, /*recon_prim=*/true); + assemble_rhs_polar(model, stage, aux, g, R, *boundary_plan, + /*recon_prim=*/true); }, U, static_cast(dt_eff)); } @@ -360,7 +367,7 @@ static double run_mass_conservation() { aux.set_val(0.0); // Etat non trivial : densite modulee en r et theta, v_r != 0 (poussee vers les parois -> teste que - // wall_radial annule le flux radial de bord et conserve la masse), v_theta != 0. + // le plan NoFlux annule le flux radial de bord et conserve la masse), v_theta != 0. { Array4 u = U.fab(0).array(); const Box2D gb = U.fab(0).box(); @@ -384,14 +391,16 @@ static double run_mass_conservation() { const double vmax = (0.3 * kRmax + 0.2) + std::sqrt(kCs2); const double dt = 0.2 * ds_min / vmax; const int nsteps = 30; + const auto boundary_plan = + test_support::polar_boundary_plan(IsothermalFluxPolar::n_vars, true, Weno5::n_ghost); for (int s = 0; s < nsteps; ++s) { SSPRK3Step{}.take_step( [&](MultiFab& stage, MultiFab& R) { fill_ghosts(stage, dom, bc); - // wall_radial=true : paroi solide aux 2 bords -> flux radial nul -> masse conservee a la machine. - assemble_rhs_polar(model, stage, aux, g, R, /*recon_prim=*/true, - /*wall_radial=*/true); + // Les lois NoFlux preparees ferment les deux bords radiaux et conservent la masse. + assemble_rhs_polar(model, stage, aux, g, R, *boundary_plan, + /*recon_prim=*/true); }, U, dt); } diff --git a/tests/cpp/unit/physics/test_polar_lorentz_source.cpp b/tests/cpp/unit/physics/test_polar_lorentz_source.cpp index 6281bc776..c94b15c24 100644 --- a/tests/cpp/unit/physics/test_polar_lorentz_source.cpp +++ b/tests/cpp/unit/physics/test_polar_lorentz_source.cpp @@ -47,6 +47,8 @@ #include #include // CompositeModel + briques source/hyperbolique/elliptique +#include "polar_boundary_plan.hpp" + #include #include @@ -309,6 +311,8 @@ static DiocoResult run_diocotron(double Bz) { const double vmax = 1.5 + std::sqrt(kCs2); // borne large (la qdm grandit) const double dt = 0.15 * ds_min / vmax; const int nsteps = 60; + const auto boundary_plan = + test_support::polar_boundary_plan(DiocotronModel::n_vars, true, Weno5::n_ghost); DiocoResult res{}; // Amplitude apres un court transitoire (laisse la force etablir une reponse), puis a la fin. @@ -317,10 +321,9 @@ static DiocoResult run_diocotron(double Bz) { SSPRK3Step{}.take_step( [&](MultiFab& stage, MultiFab& R) { fill_ghosts(stage, dom, bc); - // wall_radial=true : paroi solide -> masse conservee a la machine (la force de Lorentz - // n'agit que sur la qdm, composante 0 nulle). - assemble_rhs_polar(model, stage, aux, g, R, /*recon_prim=*/true, - /*wall_radial=*/true); + // Les faces radiales NoFlux conservent la masse ; la force de Lorentz n'agit que sur la qdm. + assemble_rhs_polar(model, stage, aux, g, R, *boundary_plan, + /*recon_prim=*/true); }, U, dt); if (s + 1 == probe0) diff --git a/tests/cpp/unit/physics/test_polar_mms_vr.cpp b/tests/cpp/unit/physics/test_polar_mms_vr.cpp index da3d726bc..dabc3a013 100644 --- a/tests/cpp/unit/physics/test_polar_mms_vr.cpp +++ b/tests/cpp/unit/physics/test_polar_mms_vr.cpp @@ -68,6 +68,8 @@ #include #include +#include "polar_boundary_plan.hpp" + #include #include @@ -130,15 +132,17 @@ struct MmsTransportPolar { static constexpr int n_aux = 4; // lit phi, grad_r, grad_theta (0..2) + S au canal extra 3 (B_z) using State = StateVec<1>; Real B0 = 1; - POPS_HD Real velocity(const Aux& a, int dir) const { - return (dir == 0) ? (-a.grad_y / B0) : (a.grad_x / B0); + POPS_HD Real velocity(const auto& providers, int dir) const { + const Real grad_x = providers.template flux_provider<1>(); + const Real grad_y = providers.template flux_provider<2>(); + return (dir == 0) ? (-grad_y / B0) : (grad_x / B0); } - POPS_HD StateVec<1> flux(const StateVec<1>& u, const Aux& a, int dir) const { + POPS_HD StateVec<1> flux(const StateVec<1>& u, const auto& a, int dir) const { StateVec<1> f{}; f[0] = u[0] * velocity(a, dir); return f; } - POPS_HD Real max_wave_speed(const StateVec<1>&, const Aux& a, int dir) const { + POPS_HD Real max_wave_speed(const StateVec<1>&, const auto& a, int dir) const { const Real d = velocity(a, dir); return d < 0 ? -d : d; } @@ -252,6 +256,8 @@ static double run_mms(int nr, int nth) { const double dt = 0.3 * ds_min / v_max; const int nsteps = static_cast(std::ceil(kTfinal / dt)); const double dt_eff = kTfinal / nsteps; + const auto boundary_plan = + test_support::polar_boundary_plan(MmsTransportPolar::n_vars, false, Limiter::n_ghost); for (int s = 0; s < nsteps; ++s) { SSPRK3Step{}.take_step( @@ -259,7 +265,7 @@ static double run_mms(int nr, int nth) { fill_ghosts(stage, dom, bc); // ghosts azimutaux periodiques fill_radial_ghosts_exact(stage, g, dom); // ghosts radiaux Dirichlet-MMS (exact, stationnaire) - assemble_rhs_polar(model, stage, aux, g, R); + assemble_rhs_polar(model, stage, aux, g, R, *boundary_plan); }, U, static_cast(dt_eff)); } diff --git a/tests/cpp/unit/physics/test_polar_transport_mms.cpp b/tests/cpp/unit/physics/test_polar_transport_mms.cpp index b9204196f..ad0c92cda 100644 --- a/tests/cpp/unit/physics/test_polar_transport_mms.cpp +++ b/tests/cpp/unit/physics/test_polar_transport_mms.cpp @@ -43,7 +43,10 @@ #include #include +#include "polar_boundary_plan.hpp" + #include +#include #include using namespace pops; @@ -153,7 +156,9 @@ static ErrNorms mms_error(int nr, int nth, bool cv) { ExBVelocityPolar model; model.B0 = kB0; - assemble_rhs_polar(model, U, aux, g, R); + const auto boundary_plan = + test_support::polar_boundary_plan(ExBVelocityPolar::n_vars, false, Limiter::n_ghost); + assemble_rhs_polar(model, U, aux, g, R, *boundary_plan); // R vient d'etre ecrit par un kernel device : rendre la residence HOTE valide avant la lecture // directe ci-dessous (sous Kokkos::Cuda = device_fence ; no-op en serie/OpenMP). Sans cela on lit @@ -240,12 +245,14 @@ static double run_conservation() { const double ds_min = kRmin * g.dtheta(); const double dt = 0.4 * ds_min / v_th; const int nsteps = 40; + const auto boundary_plan = + test_support::polar_boundary_plan(ExBVelocityPolar::n_vars, false, Weno5::n_ghost); for (int s = 0; s < nsteps; ++s) { SSPRK3Step{}.take_step( [&](MultiFab& stage, MultiFab& Rr) { fill_ghosts(stage, dom, bc); - assemble_rhs_polar(model, stage, aux, g, Rr); + assemble_rhs_polar(model, stage, aux, g, Rr, *boundary_plan); }, U, dt); } @@ -307,3 +314,49 @@ TEST(test_polar_transport_mms, MassConservedWithPureAzimuthalField) { const double rel = run_conservation(); EXPECT_TRUE(rel <= 1e-12) << "ecart de masse relatif = " << rel << " > 1e-12"; } + +TEST(test_polar_transport_mms, RejectsPreparedPlanWithoutPolarTopology) { + const Box2D dom = Box2D::from_extents(8, 16); + const PolarGeometry geometry{dom, kRmin, kRmax}; + const BoxArray boxes(std::vector{dom}); + const DistributionMapping distribution(1, n_ranks()); + MultiFab state(boxes, distribution, ExBVelocityPolar::n_vars, Weno5::n_ghost); + MultiFab auxiliary(boxes, distribution, kAuxBaseComps, Weno5::n_ghost); + MultiFab residual(boxes, distribution, ExBVelocityPolar::n_vars, 0); + state.set_val(Real(1)); + auxiliary.set_val(Real(0)); + + const BCRec all_periodic; + const auto invalid_plan = + detail::prepare_builtin_boundary_plan("test-polar-invalid-topology", {}, Weno5::n_ghost, + ExBVelocityPolar::conservative_vars(), all_periodic); + try { + assemble_rhs_polar(ExBVelocityPolar{}, state, auxiliary, geometry, residual, + *invalid_plan); + FAIL() << "an all-periodic plan must not execute on the annular transport path"; + } catch (const std::invalid_argument& error) { + EXPECT_NE(std::string(error.what()).find("non-periodic radial and periodic azimuthal"), + std::string::npos); + } +} + +TEST(test_polar_transport_mms, RejectsSharedInterfaceFaceOmission) { + const Box2D dom = Box2D::from_extents(8, 16); + const PolarGeometry geometry{dom, kRmin, kRmax}; + const BoxArray boxes(std::vector{dom}); + const DistributionMapping distribution(1, n_ranks()); + MultiFab state(boxes, distribution, ExBVelocityPolar::n_vars, Weno5::n_ghost); + MultiFab auxiliary(boxes, distribution, kAuxBaseComps, Weno5::n_ghost); + MultiFab residual(boxes, distribution, ExBVelocityPolar::n_vars, 0); + state.set_val(Real(1)); + auxiliary.set_val(Real(0)); + + auto hyperbolic = prepare_hyperbolic_boundary<2>( + {"foextrap", "foextrap", "periodic", "periodic"}, std::vector(4, 0.0), + {"test-polar-xlo", "test-polar-xhi", "test-polar-ylo", "test-polar-yhi"}, {"Scalar"}); + PreparedBoundaryPlan omitted("test-polar-omitted-face", Weno5::n_ghost, std::move(hyperbolic), + {0}); + EXPECT_THROW((assemble_rhs_polar(ExBVelocityPolar{}, state, auxiliary, + geometry, residual, omitted)), + std::invalid_argument); +} diff --git a/tests/cpp/unit/physics/test_two_species_minimal.cpp b/tests/cpp/unit/physics/test_two_species_minimal.cpp index 828846ae2..472883b4f 100644 --- a/tests/cpp/unit/physics/test_two_species_minimal.cpp +++ b/tests/cpp/unit/physics/test_two_species_minimal.cpp @@ -37,8 +37,8 @@ struct ElectronRelax { Real k = Real(1000); // raideur Real neq = Real(1); // densite d'equilibre - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux&) const { return State{-k * (u[0] - neq)}; } POPS_HD Real elliptic_rhs(const State& u) const { return -u[0]; } }; @@ -51,8 +51,8 @@ struct IonProduction { Real rate = Real(3); - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{rate}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/unit/physics/test_user_time_integrator.cpp b/tests/cpp/unit/physics/test_user_time_integrator.cpp index 1cc5122ca..016e035d0 100644 --- a/tests/cpp/unit/physics/test_user_time_integrator.cpp +++ b/tests/cpp/unit/physics/test_user_time_integrator.cpp @@ -25,8 +25,8 @@ struct Production { using Aux = pops::Aux; static constexpr int n_vars = 1; Real rate = Real(3); - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{rate}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/unit/runtime/test_assembler_driver.cpp b/tests/cpp/unit/runtime/test_assembler_driver.cpp index a51e52f46..81abfcd9b 100644 --- a/tests/cpp/unit/runtime/test_assembler_driver.cpp +++ b/tests/cpp/unit/runtime/test_assembler_driver.cpp @@ -32,8 +32,8 @@ struct Scalar { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; @@ -312,9 +312,9 @@ TEST(AssemblerDriver, ExactMappingReachesUniformAndAmrFactories) { levels.push_back(AmrLevelMP{std::move(coarse), nullptr, geom.dx(), geom.dy()}); FactoryProbe amr_probe; const auto load_balance = test::prepare_test_space_filling_curve_load_balance(); - AmrCouplerMP amr(Scalar{}, geom, ba, bc, std::move(levels), {}, - /*replicated_coarse=*/false, load_balance, - FactoryOnlyEllipticBuilder{&amr_probe}); + AmrCouplerMP amr( + Scalar{}, geom, ba, bc, Periodicity{true, true}, std::move(levels), {}, + /*replicated_coarse=*/false, load_balance, FactoryOnlyEllipticBuilder{&amr_probe}); EXPECT_EQ(amr_probe.mapping, mapping.ranks()); EXPECT_EQ(amr_probe.distribution, FieldDistribution::Distributed); @@ -326,7 +326,7 @@ TEST(AssemblerDriver, ExactMappingReachesUniformAndAmrFactories) { AmrLevelMP{std::move(replicated_coarse), nullptr, geom.dx(), geom.dy()}); FactoryProbe replicated_probe; AmrCouplerMP replicated_amr( - Scalar{}, geom, ba, bc, std::move(replicated_levels), {}, + Scalar{}, geom, ba, bc, Periodicity{true, true}, std::move(replicated_levels), {}, /*replicated_coarse=*/true, load_balance, FactoryOnlyEllipticBuilder{&replicated_probe}); EXPECT_EQ(replicated_probe.mapping, replicated_mapping.ranks()); EXPECT_EQ(replicated_probe.distribution, FieldDistribution::Replicated); diff --git a/tests/cpp/unit/runtime/test_component_interfaces.cpp b/tests/cpp/unit/runtime/test_component_interfaces.cpp index 155514c4f..68a10afc4 100644 --- a/tests/cpp/unit/runtime/test_component_interfaces.cpp +++ b/tests/cpp/unit/runtime/test_component_interfaces.cpp @@ -54,6 +54,12 @@ struct TransferComponent { std::string restart() const { return "stateless"; } }; +struct RefluxComponent { + int stencil() const { return 1; } + std::string lower(Context&) const { return "integrated-interface-correction"; } + std::vector effects() const { return {"local-correction"}; } +}; + struct SolverComponent { pops::component::EvaluationOutcome evaluate(Context&) const { return pops::component::EvaluationOutcome::reject("non-converged"); @@ -90,6 +96,9 @@ static_assert(pops::component::Lowering); static_assert(pops::component::Effects); static_assert(pops::component::Stencil); static_assert(pops::component::Restart); +static_assert(pops::component::Stencil); +static_assert(pops::component::Lowering); +static_assert(pops::component::Effects); static_assert(pops::component::FallibleEvaluation); static_assert(pops::component::Restart); static_assert(pops::component::Format); @@ -580,6 +589,66 @@ TEST(ComponentInterfaces, ExactAbiConsumersExecuteEveryClosedScientificFamily) { EXPECT_THROW(pops::component::apply_ghost_boundary(ghost_api, nullptr, invalid_region, status), std::invalid_argument); + std::array transformed_outward_flux{}; + const std::array lower_outward_normal{-1.0, 0.0}; + const std::array face_measure{0.5}; + PopsComponentActionV1 boundary_flux_action = POPS_COMPONENT_ABORT_RUN_V1; + PopsBoundaryFluxApiV1 boundary_flux_api{ + abi_header(sizeof(PopsBoundaryFluxApiV1), POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1), + +[](void*, const PopsBoundaryFluxRequestV1* request, PopsBoundaryFluxResultV1* result) { + if (request->region.kind != POPS_BOUNDARY_FACE_V1 || + request->outward_normals.component_count != 2 || + values(request->outward_normals)[0] != -1.0 || request->face_measures[0] != 0.5) + return 12; + const auto* base = values(request->base_outward_normal_flux); + auto* output = values(result->outward_normal_flux); + for (std::size_t component = 0; + component < request->base_outward_normal_flux.component_count; ++component) + output[component] = base[component] + 3.0; + result->actions[0] = POPS_COMPONENT_CONTINUE_V1; + result->status = ok_status(); + return 0; + }}; + auto base_outward_flux_view = abi::const_field_view(left.data(), 1, 1, 2); + base_outward_flux_view.centering = POPS_FIELD_CENTERING_FACE_V1; + base_outward_flux_view.centering_axes = 1u; + auto transformed_outward_flux_view = abi::field_view(transformed_outward_flux.data(), 1, 1, 2); + transformed_outward_flux_view.centering = POPS_FIELD_CENTERING_FACE_V1; + transformed_outward_flux_view.centering_axes = 1u; + PopsBoundaryFluxRequestV1 boundary_flux_request{ + sizeof(PopsBoundaryFluxRequestV1), + "case::boundary-flux-provider", + "case::state", + base_outward_flux_view, + abi::const_field_view(normal.data(), 1, 1, 2), + abi::const_field_view(lower_outward_normal.data(), 1, 1, 2), + face_measure.data(), + face_region, + 0, + nullptr, + 0, + nullptr, + abi::logical_time(), + execution}; + PopsBoundaryFluxResultV1 boundary_flux_result{ + sizeof(PopsBoundaryFluxResultV1), transformed_outward_flux_view, &boundary_flux_action, {}}; + EXPECT_EQ(pops::component::transform_boundary_flux(boundary_flux_api, nullptr, + boundary_flux_request, boundary_flux_result), + 0); + EXPECT_EQ(transformed_outward_flux, (std::array{5.0, 7.0})); + EXPECT_EQ(boundary_flux_action, POPS_COMPONENT_CONTINUE_V1); + const std::array wrong_lower_normal{1.0, 0.0}; + auto wrong_orientation = boundary_flux_request; + wrong_orientation.outward_normals = abi::const_field_view(wrong_lower_normal.data(), 1, 1, 2); + EXPECT_THROW(pops::component::transform_boundary_flux(boundary_flux_api, nullptr, + wrong_orientation, boundary_flux_result), + std::invalid_argument); + auto mismatched_flux_output = boundary_flux_result; + mismatched_flux_output.outward_normal_flux.component_count = 1; + EXPECT_THROW(pops::component::transform_boundary_flux( + boundary_flux_api, nullptr, boundary_flux_request, mismatched_flux_output), + std::invalid_argument); + std::array direction{1.0, 2.0}, boundary_output{}; const auto field_eval = +[](void*, const PopsFieldBoundaryRequestV1* request, PopsComponentStatusV1* result) { @@ -777,6 +846,78 @@ TEST(ComponentInterfaces, ExactAbiConsumersExecuteEveryClosedScientificFamily) { EXPECT_THROW(pops::component::apply_transfer(transfer_api, nullptr, wrong_transfer_shape, status), std::invalid_argument); + std::array coarse_integrated_flux{1.0, 2.0}; + std::array fine_integrated_flux{3.0, 6.0}; + std::array reflux_correction{}; + PopsRefluxApiV1 reflux_api{ + abi_header(sizeof(PopsRefluxApiV1), POPS_NATIVE_INTERFACE_REFLUX_V1), + +[](void*, const PopsRefluxRequestV1* request, PopsComponentStatusV1* result) { + for (std::size_t face_index = 0; face_index < request->face_count; ++face_index) { + const auto& face = request->faces[face_index]; + const auto* coarse = static_cast(face.coarse_integrated_flux.data); + const auto* fine = static_cast(face.fine_integrated_flux.data); + auto* correction = static_cast(face.correction.data); + const std::size_t points = + pops::component::field_point_count(face.coarse_integrated_flux); + for (std::size_t point = 0; point < points; ++point) + correction[point] = static_cast(face.side) * (fine[point] - coarse[point]) * + face.inverse_coarse_cell_spacing; + } + *result = ok_status(); + return 0; + }}; + auto coarse_face = abi::const_field_view(coarse_integrated_flux.data(), 1, 2, 1, "parent::layout", + "parent::patch"); + coarse_face.centering = POPS_FIELD_CENTERING_FACE_V1; + coarse_face.centering_axes = 1u; + auto fine_face = + abi::const_field_view(fine_integrated_flux.data(), 1, 2, 1, "child::layout", "child::patch"); + fine_face.centering = POPS_FIELD_CENTERING_FACE_V1; + fine_face.centering_axes = 1u; + PopsRefluxFaceV1 reflux_face{ + sizeof(PopsRefluxFaceV1), + "transition::0-to-1/x-low", + 0, + POPS_REFLUX_FACE_LOW_V1, + 2.0, + coarse_face, + fine_face, + abi::field_view(reflux_correction.data(), 1, 2, 1, "parent::layout", "parent::patch")}; + PopsRefluxRequestV1 reflux_request{sizeof(PopsRefluxRequestV1), + "transition::0-to-1", + 0, + 1, + 1, + &reflux_face, + abi::logical_time(), + abi::noncollective_host_execution_context()}; + EXPECT_TRUE(pops::component::generated_native_interface_table_is_complete( + POPS_NATIVE_INTERFACE_REFLUX_V1, &reflux_api, sizeof(reflux_api))); + EXPECT_EQ( + pops::component::apply_reflux_interface_batch(reflux_api, nullptr, reflux_request, status), + 0); + EXPECT_EQ(reflux_correction, (std::array{-4.0, -8.0})); + + auto incomplete_reflux_api = reflux_api; + incomplete_reflux_api.apply_interface_batch = nullptr; + EXPECT_FALSE(pops::component::generated_native_interface_table_is_complete( + POPS_NATIVE_INTERFACE_REFLUX_V1, &incomplete_reflux_api, sizeof(incomplete_reflux_api))); + EXPECT_THROW(pops::component::apply_reflux_interface_batch(incomplete_reflux_api, nullptr, + reflux_request, status), + std::runtime_error); + auto collective_reflux = reflux_request; + collective_reflux.execution = execution; + EXPECT_THROW( + pops::component::apply_reflux_interface_batch(reflux_api, nullptr, collective_reflux, status), + std::invalid_argument); + auto malformed_reflux = reflux_request; + auto malformed_face = reflux_face; + malformed_face.correction.layout_identity = "other::parent-layout"; + malformed_reflux.faces = &malformed_face; + EXPECT_THROW( + pops::component::apply_reflux_interface_batch(reflux_api, nullptr, malformed_reflux, status), + std::invalid_argument); + auto overflowing_ghosts = abi::const_field_view(tag_values.data(), 2, 2); overflowing_ghosts.ghost_lower[0] = std::numeric_limits::max(); overflowing_ghosts.ghost_upper[0] = 1; @@ -1288,6 +1429,145 @@ TEST(ComponentInterfaces, ExactAbiConsumersExecuteEveryClosedScientificFamily) { EXPECT_EQ(writer_state.publish_count, 1); } +TEST(ComponentInterfaces, FieldSolverV2CarriesOneBinaryCoverageMultilevelBatch) { + const PopsExecutionContextV1 execution = abi::host_execution_context(); + static constexpr PopsTopologyLabelV2 labels[] = { + {sizeof(PopsTopologyLabelV2), 1, "composite-material", "multilevel-test"}}; + std::array patch_identities{"coarse-patch", "fine-patch"}; + std::array metadata{}; + for (std::size_t index = 0; index < metadata.size(); ++index) { + metadata[index] = {sizeof(PopsFieldPatchMetadataV1), + index, + 0, + static_cast(index), + 2, + {}, + {}, + {}, + {}, + POPS_FIELD_CENTERING_CELL_V1, + 0, + "multilevel-layout", + patch_identities[index].c_str()}; + metadata[index].lower[0] = static_cast(2 * index); + metadata[index].upper[0] = static_cast(2 * index + 1); + metadata[index].lower[1] = metadata[index].upper[1] = 0; + metadata[index].cell_spacing[0] = metadata[index].cell_spacing[1] = index == 0 ? 1.0 : 0.5; + } + PopsFieldGlobalTopologyV1 global{sizeof(PopsFieldGlobalTopologyV1), + "multilevel-recipe", + "multilevel-layout", + "multilevel-materialization", + 2, + {}, + {}, + 0, + metadata.size(), + metadata.data()}; + global.domain_upper[0] = 3; + std::array coarse_coverage{1, 0}; + std::array fine_coverage{1, 1}; + const std::vector inputs{ + {0, + POPS_FIELD_MATERIAL_BINARY_COVERAGE_V1, + {sizeof(PopsConstByteViewV1), coarse_coverage.data(), coarse_coverage.size()}, + {}, + {}}, + {1, + POPS_FIELD_MATERIAL_BINARY_COVERAGE_V1, + {sizeof(PopsConstByteViewV1), fine_coverage.data(), fine_coverage.size()}, + {}, + {}}, + }; + struct Calls { + int topology = 0; + int solver = 0; + } calls; + PopsFieldTopologyApiV2 topology_api{ + abi_header(sizeof(PopsFieldTopologyApiV2), POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2, 2), + +[](void* raw, const PopsFieldTopologyRequestV2* request, PopsFieldTopologyResultV2* result) { + auto& state = *static_cast(raw); + ++state.topology; + if (request->topology.patch_count != 2 || request->local_patch_count != 2 || + request->topology.patches[0].level != 0 || request->topology.patches[1].level != 1) + return 7; + for (std::size_t index = 0; index < request->local_patch_count; ++index) { + const auto& patch = request->local_patches[index]; + if (patch.material_representation != POPS_FIELD_MATERIAL_BINARY_COVERAGE_V1 || + patch.material_coverage.size != 2) + return 8; + std::copy(patch.material_coverage.data, + patch.material_coverage.data + patch.material_coverage.size, + patch.material_mask.data); + for (std::size_t point = 0; point < patch.component_labels.size; ++point) + patch.component_labels.data[point] = patch.material_mask.data[point] == 1 ? 1 : 0; + } + result->label_count = 1; + result->labels = labels; + result->provenance = "multilevel-test"; + result->topology_digest = "multilevel-topology-digest"; + result->status = ok_status(); + return 0; + }}; + const auto topology = + pops::component::prepare_field_topology(topology_api, &calls, global, inputs, execution); + ASSERT_EQ(topology.local_patches().size(), 2u); + EXPECT_EQ(topology.local_patches()[0].material_mask, (std::vector{1, 0})); + EXPECT_EQ(topology.local_patches()[1].material_mask, (std::vector{1, 1})); + + std::array coarse_rhs{2.0, 99.0}, fine_rhs{3.0, 4.0}; + std::array coarse_solution{}, fine_solution{}; + const auto& owned = topology.global_patches(); + const std::vector bindings{ + {0, + abi::const_field_view(coarse_rhs.data(), 2, 1, 1, owned[0].layout_identity, + owned[0].patch_identity), + abi::field_view(coarse_solution.data(), 2, 1, 1, owned[0].layout_identity, + owned[0].patch_identity), + {}}, + {1, + abi::const_field_view(fine_rhs.data(), 2, 1, 1, owned[1].layout_identity, + owned[1].patch_identity), + abi::field_view(fine_solution.data(), 2, 1, 1, owned[1].layout_identity, + owned[1].patch_identity), + {}}, + }; + const auto request = pops::component::bind_field_solver_request( + topology, bindings, execution, "{\"identity\":\"multilevel-boundary\"}", 1e-8, 0.0, 10); + PopsFieldSolverApiV2 solver_api{ + abi_header(sizeof(PopsFieldSolverApiV2), POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2, 2), + +[](void* raw, const PopsFieldSolverRequestV2* request, PopsSolveReportV2* report) { + auto& state = *static_cast(raw); + ++state.solver; + if (request->topology.patch_count != 2 || request->local_patch_count != 2 || + request->topology.patches[0].level != 0 || request->topology.patches[1].level != 1 || + request->local_patches[0].material_mask.data[1] != 0 || + request->local_patches[1].material_mask.data[1] != 1) + return 9; + for (std::size_t patch = 0; patch < request->local_patch_count; ++patch) { + const auto* rhs = static_cast(request->local_patches[patch].rhs.data); + auto* solution = static_cast(request->local_patches[patch].solution.data); + for (std::size_t point = 0; point < 2; ++point) + if (request->local_patches[patch].material_mask.data[point] == 1) + solution[point] = rhs[point]; + } + report->status = POPS_SOLVE_SOLVED_V2; + report->action = POPS_SOLVE_ACTION_NONE_V2; + report->iterations = 1; + report->relative_residual = 0.0; + report->reference_residual_norm = 1.0; + report->residual_norm = 0.0; + report->reason = "multilevel batch solved"; + return 0; + }}; + PopsSolveReportV2 report{}; + EXPECT_EQ(pops::component::solve_field(solver_api, &calls, request, report), 0); + EXPECT_EQ(calls.topology, 1); + EXPECT_EQ(calls.solver, 1); + EXPECT_EQ(coarse_solution, (std::array{2.0, 0.0})); + EXPECT_EQ(fine_solution, fine_rhs); +} + TEST(ComponentInterfaces, PreparedExecutionContextBindsExactExecutionLaneAuthority) { const PopsExecutionContextV1 execution = abi::host_execution_context(); const pops::component::PreparedExecutionContextV1 prepared( diff --git a/tests/cpp/unit/runtime/test_coupled_source.cpp b/tests/cpp/unit/runtime/test_coupled_source.cpp index 391299ddd..98ca6777c 100644 --- a/tests/cpp/unit/runtime/test_coupled_source.cpp +++ b/tests/cpp/unit/runtime/test_coupled_source.cpp @@ -30,8 +30,8 @@ struct Inert { using State = StateVec<1>; using Aux = pops::Aux; static constexpr int n_vars = 1; - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } POPS_HD Real elliptic_rhs(const State& u) const { return u[0]; } }; diff --git a/tests/cpp/unit/runtime/test_dense_eig.cpp b/tests/cpp/unit/runtime/test_dense_eig.cpp index fcf2fb06f..0558d8fb3 100644 --- a/tests/cpp/unit/runtime/test_dense_eig.cpp +++ b/tests/cpp/unit/runtime/test_dense_eig.cpp @@ -43,6 +43,30 @@ static void companion(const Real (&roots)[N], Real (&A)[N][N]) { A[i][i - 1] = Real(1); } +TEST(DenseEig, characteristic_incoming_projector_is_oriented_and_sonic_neutral) { + const Real A[3][3] = {{Real(-2), 0, 0}, {0, Real(0), 0}, {0, 0, Real(3)}}; + const Real jump[3] = {Real(4), Real(5), Real(6)}; + Real lower[3] = {Real(9), Real(9), Real(9)}; + ASSERT_TRUE(pops::characteristic_incoming_apply(A, jump, lower, 1)); + EXPECT_NEAR(lower[0], Real(4), Real(1e-12)); + EXPECT_NEAR(lower[1], Real(0), Real(1e-12)); + EXPECT_NEAR(lower[2], Real(0), Real(1e-12)); + + Real upper[3] = {}; + ASSERT_TRUE(pops::characteristic_incoming_apply(A, jump, upper, -1)); + EXPECT_NEAR(upper[0], Real(0), Real(1e-12)); + EXPECT_NEAR(upper[1], Real(0), Real(1e-12)); + EXPECT_NEAR(upper[2], Real(6), Real(1e-12)); + + const Real complex_A[2][2] = {{Real(0), Real(-1)}, {Real(1), Real(0)}}; + const Real complex_jump[2] = {Real(1), Real(2)}; + Real untouched[2] = {Real(7), Real(8)}; + EXPECT_FALSE(pops::characteristic_incoming_apply(complex_A, complex_jump, untouched, 1)); + EXPECT_EQ(untouched[0], Real(7)); + EXPECT_EQ(untouched[1], Real(8)); + EXPECT_FALSE(pops::characteristic_incoming_apply(A, jump, lower, 0)); +} + /// Consommateur DEVICE-SAFE (pile uniquement, ni NumPy ni MATLAB) : tient lieu du projecteur /// HyQMOM15 qui classe un bloc 3x3 de moments puis choisit une action. Le switch est EXHAUSTIF sur /// pops::Spectrum -- kUnknown (non-convergence) y est traite explicitement, jamais confondu avec kReal. diff --git a/tests/cpp/unit/runtime/test_disc_domain_mask.cpp b/tests/cpp/unit/runtime/test_disc_domain_mask.cpp index 184919f46..aed5ed3be 100644 --- a/tests/cpp/unit/runtime/test_disc_domain_mask.cpp +++ b/tests/cpp/unit/runtime/test_disc_domain_mask.cpp @@ -49,10 +49,10 @@ struct Advect { using Aux = pops::Aux; static constexpr int n_vars = 1; Real vx = 0.0, vy = 0.0; - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{(dir == 0 ? vx : vy) * u[0]}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const State&, const auto&, int dir) const { return std::fabs(dir == 0 ? vx : vy); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } @@ -62,7 +62,7 @@ struct Advect { // Device-side Roe providers cannot throw. This model is the exact failure carrier emitted by a // dense-Jacobian Roe provider when its eigensolve reports a complex or unresolved spectrum. struct FailedRoeAdvect : Advect { - POPS_HD State roe_dissipation(const State&, const Aux&, const State&, const Aux&, int) const { + POPS_HD State roe_dissipation(const State&, const auto&, const State&, const auto&, int) const { return State{std::numeric_limits::quiet_NaN()}; } }; diff --git a/tests/cpp/unit/runtime/test_eb_transport.cpp b/tests/cpp/unit/runtime/test_eb_transport.cpp index cc15b334c..297111112 100644 --- a/tests/cpp/unit/runtime/test_eb_transport.cpp +++ b/tests/cpp/unit/runtime/test_eb_transport.cpp @@ -63,10 +63,10 @@ struct Advect { using Aux = pops::Aux; static constexpr int n_vars = 1; Real vx = 0.0, vy = 0.0; - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{(dir == 0 ? vx : vy) * u[0]}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const State&, const auto&, int dir) const { return std::fabs(dir == 0 ? vx : vy); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } @@ -74,7 +74,7 @@ struct Advect { }; struct FailedRoeAdvect : Advect { - POPS_HD State roe_dissipation(const State&, const Aux&, const State&, const Aux&, int) const { + POPS_HD State roe_dissipation(const State&, const auto&, const State&, const auto&, int) const { return State{std::numeric_limits::quiet_NaN()}; } }; diff --git a/tests/cpp/unit/runtime/test_embedded_boundary_generic.cpp b/tests/cpp/unit/runtime/test_embedded_boundary_generic.cpp index 993616965..85c941686 100644 --- a/tests/cpp/unit/runtime/test_embedded_boundary_generic.cpp +++ b/tests/cpp/unit/runtime/test_embedded_boundary_generic.cpp @@ -68,10 +68,10 @@ struct Advect { using Aux = pops::Aux; static constexpr int n_vars = 1; Real vx = 0.0, vy = 0.0; - POPS_HD State flux(const State& u, const Aux&, int dir) const { + POPS_HD State flux(const State& u, const auto&, int dir) const { return State{(dir == 0 ? vx : vy) * u[0]}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int dir) const { + POPS_HD Real max_wave_speed(const State&, const auto&, int dir) const { return std::fabs(dir == 0 ? vx : vy); } POPS_HD State source(const State&, const Aux&) const { return State{Real(0)}; } diff --git a/tests/cpp/unit/runtime/test_platform_manifest.cpp b/tests/cpp/unit/runtime/test_platform_manifest.cpp index 01afeb06f..4882afb61 100644 --- a/tests/cpp/unit/runtime/test_platform_manifest.cpp +++ b/tests/cpp/unit/runtime/test_platform_manifest.cpp @@ -68,13 +68,26 @@ TEST(PlatformManifest, UnknownIsMissingProofAndThreeDimensionsRemainRepresentabl pops::platform::ContractError); } +TEST(PlatformManifest, UnknownCapabilityRefusesBeforeKernel) { + auto missing = platform(); + missing.device = pops::platform::CapabilityProof::unknown(); + int launches = 0; + EXPECT_THROW(pops::platform::launch_checked(missing, context(), {field()}, + [&](const auto&, const auto&) { + ++launches; + return 0; + }, + {field()}), + pops::platform::ContractError); + EXPECT_EQ(launches, 0); +} + TEST(PlatformManifest, FieldAndCommunicatorMismatchesRefuseBeforeKernel) { int launches = 0; auto kernel = [&](const auto&, const auto&) { return ++launches; }; const auto required = field(); - for (int variant = 0; variant < 5; ++variant) { + for (int variant = 0; variant < 9; ++variant) { auto actual = field(); - auto execution = context(); if (variant == 0) actual.centering = "node"; else if (variant == 1) @@ -83,13 +96,71 @@ TEST(PlatformManifest, FieldAndCommunicatorMismatchesRefuseBeforeKernel) { actual.extents = {15, 12}; else if (variant == 3) actual.memory_space = "device"; + else if (variant == 4) + actual.strides = {1, 16}; + else if (variant == 5) + actual.ghosts = {{1, 0}, {0, 0}}; + else if (variant == 6) + actual.patch = "patch-1"; + else if (variant == 7) + actual.layout = "left"; else - execution.communicator.identity = "comm:wrong"; + actual.ownership = "owned"; EXPECT_THROW( - pops::platform::launch_checked(platform(), execution, {actual}, kernel, {required}), + pops::platform::launch_checked(platform(), context(), {actual}, kernel, {required}), pops::platform::ContractError); EXPECT_EQ(launches, 0); } + auto execution = context(); + execution.communicator.identity = "comm:wrong"; + EXPECT_THROW(pops::platform::launch_checked(platform(), execution, {field()}, kernel, {required}), + pops::platform::ContractError); + EXPECT_EQ(launches, 0); +} + +TEST(PlatformManifest, FieldCapabilitiesAndNamesFailClosed) { + int launches = 0; + auto kernel = [&](const auto&, const auto&) { return ++launches; }; + + auto missing = platform(); + missing.capabilities.erase("ownership"); + EXPECT_THROW(pops::platform::launch_checked(missing, context(), {field()}, kernel), + pops::platform::ContractError); + + auto unsupported = platform(); + unsupported.capabilities["layouts"] = pops::platform::prove_text_set({"left"}, "test"); + auto unsupported_context = context(); + unsupported_context.backend.capabilities["layouts"] = + pops::platform::prove_text_set({"left"}, "test"); + EXPECT_THROW(pops::platform::launch_checked(unsupported, unsupported_context, {field()}, kernel), + pops::platform::ContractError); + + auto disabled = platform(); + disabled.capabilities["generic_field_view"] = pops::platform::prove_bool(false, "test"); + auto disabled_context = context(); + disabled_context.backend.capabilities["generic_field_view"] = + pops::platform::prove_bool(false, "test"); + EXPECT_THROW(pops::platform::launch_checked(disabled, disabled_context, {field()}, kernel), + pops::platform::ContractError); + + EXPECT_THROW(pops::platform::launch_checked(platform(), context(), {field(), field()}, kernel), + pops::platform::ContractError); + EXPECT_THROW( + pops::platform::launch_checked(platform(), context(), {field()}, kernel, {field(), field()}), + pops::platform::ContractError); + EXPECT_EQ(launches, 0); +} + +TEST(PlatformManifest, FieldGhostsMustLeavePositiveInterior) { + auto hidden = field(); + hidden.ghosts = {{16, 0}, {0, 0}}; + EXPECT_THROW(pops::platform::validate_launch(platform(), context(), {hidden}), + pops::platform::ContractError); + + hidden = field(); + hidden.ghosts = {{8, 8}, {0, 0}}; + EXPECT_THROW(pops::platform::validate_launch(platform(), context(), {hidden}), + pops::platform::ContractError); } TEST(PlatformManifest, GenericTwoDimensionalDoubleRouteLaunches) { diff --git a/tests/cpp/unit/runtime/test_prepared_stream_executor.cpp b/tests/cpp/unit/runtime/test_prepared_stream_executor.cpp new file mode 100644 index 000000000..9f4ac8ecb --- /dev/null +++ b/tests/cpp/unit/runtime/test_prepared_stream_executor.cpp @@ -0,0 +1,89 @@ +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using pops::runtime::accelerator::PreparedAcceleratorStreamExecutor; +using pops::runtime::accelerator::PreparedStreamPartitionError; + +namespace { + +using Executor = PreparedAcceleratorStreamExecutor; + +TEST(PreparedStreamExecutor, InvalidPreparationIsRejectedBeforeBackendSelection) { + EXPECT_THROW((void)Executor::prepare(1, 64), std::invalid_argument); + EXPECT_THROW((void)Executor::prepare(2, 0), std::invalid_argument); + EXPECT_THROW((void)Executor::prepare(2, 64, {1.0}), std::invalid_argument); + EXPECT_THROW((void)Executor::prepare(2, 64, {1.0, 0.0}), std::invalid_argument); + EXPECT_THROW((void)Executor::prepare(2, 64, {1.0, std::numeric_limits::quiet_NaN()}), + std::invalid_argument); +} + +TEST(PreparedStreamExecutor, CpuBackendsCannotClaimIndependentAcceleratorStreams) { + if constexpr (!Executor::backend_can_partition_authentic_streams()) { + EXPECT_THROW((void)Executor::prepare(2, 64), PreparedStreamPartitionError); + } else { + EXPECT_TRUE(Executor::backend_can_partition_authentic_streams()); + } +} + +TEST(PreparedStreamExecutor, AcceleratorInstancesLaunchOnExplicitDisjointLanes) { + if constexpr (!Executor::backend_can_partition_authentic_streams()) { + EXPECT_FALSE(Executor::backend_can_partition_authentic_streams()); + } else { + constexpr std::int64_t values = 4096; + Executor executor = Executor::prepare(2, static_cast(values)); + + ASSERT_EQ(executor.size(), 2u); + EXPECT_EQ(executor.workspace_values_per_stream(), static_cast(values)); + EXPECT_TRUE(executor.evidence().independent_streams); + EXPECT_TRUE(executor.evidence().disjoint_workspaces); + EXPECT_TRUE(executor.evidence().partition_mechanism == + "Kokkos::Experimental::partition_space" || + executor.evidence().partition_mechanism == "Kokkos-native-stream-wrapper"); + EXPECT_NE(executor.workspace_address(0), executor.workspace_address(1)); + EXPECT_EQ(std::set(executor.evidence().stream_identities.begin(), + executor.evidence().stream_identities.end()) + .size(), + 2u); + + double* lane_zero = executor.workspace_data(0); + double* lane_one = executor.workspace_data(1); + executor.launch_for( + 0, "pops_test_prepared_stream_lane_zero", values, KOKKOS_LAMBDA(std::int64_t index) { + lane_zero[index] = 2.0 * static_cast(index) + 1.0; + }); + executor.launch_for( + 1, "pops_test_prepared_stream_lane_one", values, KOKKOS_LAMBDA(std::int64_t index) { + lane_one[index] = 3.0 * static_cast(index) - 2.0; + }); + executor.fence_all(); + + const auto zero_host = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(0)); + const auto one_host = + Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace{}, executor.workspace(1)); + for (std::int64_t index = 0; index < values; ++index) { + EXPECT_DOUBLE_EQ(zero_host(index), 2.0 * static_cast(index) + 1.0); + EXPECT_DOUBLE_EQ(one_host(index), 3.0 * static_cast(index) - 2.0); + } + + EXPECT_THROW((void)executor.workspace(2), std::out_of_range); + EXPECT_THROW(executor.launch_for(0, "", 1, KOKKOS_LAMBDA(std::int64_t){}), + std::invalid_argument); + EXPECT_THROW(executor.launch_for(0, "negative", -1, KOKKOS_LAMBDA(std::int64_t){}), + std::invalid_argument); + } +} + +} // namespace diff --git a/tests/cpp/unit/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index b992666a7..8c4e3dc84 100644 --- a/tests/cpp/unit/runtime/test_program_context_contract.cpp +++ b/tests/cpp/unit/runtime/test_program_context_contract.cpp @@ -24,8 +24,10 @@ #include +#include #include #include +#include #include // NoSource #include // CompositeModel #include // Euler @@ -124,6 +126,62 @@ TEST(ProgramContextContract, ProjectionReportSurvivesScientificRollbackUntilCons EXPECT_THROW(sim.note_step_projection(""), std::invalid_argument); } +TEST(ProgramContextContract, AcceptedBalanceEvidenceIsCurrentAttemptExactAndFailClosed) { + ensure_kokkos(); + SystemConfig cfg; + cfg.n = 2; + cfg.L = 1.0; + System sim(cfg); + ProgramContext context(&sim); + const std::string route = "pops.balance-ledger-route.v1:sha256:" + std::string(64, '1'); + const std::array, 5> terms{{ + {"storage_change", 11.0}, + {"outward_boundary_flux", 2.0}, + {"sources", 5.0}, + {"reflux", 3.0}, + {"projection", 1.0}, + }}; + + sim.begin_step_transaction(); + sim.begin_step_projection_report(); + for (const auto& [name, value] : terms) + context.record_balance_term(route, name, 0.25 * value); + for (const auto& [name, value] : terms) + context.record_balance_term(route, name, 0.75 * value); + const auto accepted = sim.accepted_balance_terms(route); + EXPECT_EQ(accepted.size(), terms.size()); + for (const auto& [name, value] : terms) + EXPECT_DOUBLE_EQ(accepted.at(name), value); + // Reserved balance evidence is deliberately attempt-local and therefore absent + // from the persistent/checkpointed inspection-diagnostic registry. + EXPECT_EQ(sim.program_diagnostics().count("pops.balance-term.v1:" + route + ":storage_change"), + 0u); + sim.rollback_step_transaction(); + EXPECT_THROW((void)sim.accepted_balance_terms(route), std::runtime_error); + + sim.begin_step_transaction(); + sim.begin_step_projection_report(); + for (std::size_t index = 0; index + 1 < terms.size(); ++index) + context.record_balance_term(route, terms[index].first, terms[index].second); + EXPECT_THROW((void)sim.accepted_balance_terms(route), std::runtime_error); + sim.rollback_step_transaction(); + + sim.begin_step_transaction(); + sim.begin_step_projection_report(); + for (const std::string& forged : + {"pops.balance-term", "pops.balance-term.v1", "pops.balance-term.v1:forged"}) { + EXPECT_THROW(sim.record_program_diagnostic(forged, 1.0), std::invalid_argument); + EXPECT_EQ(sim.program_diagnostics().count(forged), 0u); + } + EXPECT_THROW((void)sim.accepted_balance_terms(route), std::runtime_error); + EXPECT_THROW( + context.record_balance_term("pops.balance-ledger-route.v1:sha256:bad", "storage_change", 1.0), + std::invalid_argument); + EXPECT_THROW(context.record_balance_term(route, "unknown", 1.0), std::invalid_argument); + EXPECT_THROW((void)sim.accepted_balance_terms(route), std::runtime_error); + sim.rollback_step_transaction(); +} + double max_abs_diff(const std::vector& a, const std::vector& b) { double d = 0; for (std::size_t k = 0; k < a.size(); ++k) { @@ -288,9 +346,14 @@ TEST(ProgramContextContract, GroupedBoundaryRegistryUsesEveryProvisionalStageSta sim.install_block_state_route("b", b_state); const std::vector faces(4, "periodic"); const std::vector values(4, 0.0); - sim.install_boundary_plan("a", "case::block::a::boundary", 1, faces, values, 1, {}, a_state, - PreparedBoundaryReadDependencies{{b_state}, {}}); - sim.install_boundary_plan("b", "case::block::b::boundary", 1, faces, values, 1, {}, b_state); + const std::vector a_faces = {"case::block::a::xlo", "case::block::a::xhi", + "case::block::a::ylo", "case::block::a::yhi"}; + const std::vector b_faces = {"case::block::b::xlo", "case::block::b::xhi", + "case::block::b::ylo", "case::block::b::yhi"}; + sim.install_boundary_plan("a", "case::block::a::boundary", 1, faces, values, a_faces, {"Scalar"}, + {}, a_state, PreparedBoundaryReadDependencies{{b_state}, {}}); + sim.install_boundary_plan("b", "case::block::b::boundary", 1, faces, values, b_faces, {"Scalar"}, + {}, b_state); const auto a_plan = sim.grid_context("a").boundary_plan; ASSERT_NE(a_plan, nullptr); const auto b_read = a_plan->prepare_state_read(b_state); @@ -351,6 +414,150 @@ TEST(ProgramContextContract, GroupedBoundaryRegistryUsesEveryProvisionalStageSta << "an atomic group identity must never alias one of its member rate nodes"; } +TEST(ProgramContextContract, SystemPreparedSlipWallFillsDeepPhysicalGhosts) { + ensure_kokkos(); + SystemConfig cfg; + cfg.n = 4; + cfg.L = 1.0; + cfg.periodicity = {false, false}; + System sim(cfg); + const std::string state_identity = "case::block::fluid::state::U"; + sim.install_block_state_route("fluid", state_identity); + sim.install_boundary_plan( + "fluid", "case::block::fluid::boundary", 2, + {"slip_wall", "slip_wall", "slip_wall", "slip_wall"}, std::vector(20, 0.0), + {"case::block::fluid::xlo", "case::block::fluid::xhi", "case::block::fluid::ylo", + "case::block::fluid::yhi"}, + {"Density", "MomentumX", "MomentumX", "MomentumY", "AxialZ"}, {}, state_identity); + sim.install_block("fluid", 5, VariableSet{}, VariableSet{}, 1.0, BlockClosures{}, {}, {}, 1, true, + 1); + + MultiFab& state = sim.block_state(0); + ASSERT_GE(state.n_grow(), 2); + state.set_val(Real(-99)); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(5); + values(i, j, 3) = Real(3); + values(i, j, 4) = Real(4); + }); + } + device_fence(); + const auto lane = ExecutionLane::world("test.system.deep-slip-wall"); + const runtime::multiblock::BoundaryEvaluationPoint point{"clock.system-slip", 0, 0, 0, 0, + amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession boundary(sim.grid_context("fluid"), lane, state, point); + boundary.fill(state, point); + device_fence(); + + if (state.local_size() > 0) { + const ConstArray4 values = state.fab(0).const_array(); + EXPECT_EQ(values(-2, 2, 1), Real(-2)); + EXPECT_EQ(values(-2, 2, 2), Real(-5)); + EXPECT_EQ(values(-2, 2, 4), Real(-4)); + EXPECT_EQ(values(2, -2, 3), Real(-3)); + EXPECT_EQ(values(2, -2, 4), Real(-4)); + } +} + +TEST(ProgramContextContract, SystemPreparesPrimitiveFixedStateWithExactCompiledModelConversion) { + ensure_kokkos(); + SystemConfig cfg; + cfg.n = 4; + cfg.L = 1.0; + cfg.periodicity = {false, false}; + System sim(cfg); + const std::string state_identity = "case::block::fluid::state::U"; + sim.install_block_state_route("fluid", state_identity); + std::vector face_values; + for (const double primitive : {2.0, 3.0, -1.0, 4.0}) + face_values.insert(face_values.end(), {0.0, primitive, 0.0, 0.0}); + sim.install_boundary_plan("fluid", "case::block::fluid::boundary", 2, + {"foextrap", "dirichlet", "foextrap", "foextrap"}, face_values, + {"case::block::fluid::xlo", "case::block::fluid::xhi", + "case::block::fluid::ylo", "case::block::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY", "Energy"}, {}, state_identity, {}, + {}, {"conservative", "primitive", "conservative", "conservative"}, + {"", "case::block::fluid::model-p2c", "", ""}); + ASSERT_TRUE(sim.grid_context("fluid").boundary_plan->requires_fixed_state_conversion()); + + add_compiled_model(sim, "fluid", GasModel{Euler{kGamma}, NoSource{}, NoEll{}}, "minmod", + "rusanov", "conservative", "explicit", kGamma); + ASSERT_FALSE(sim.grid_context("fluid").boundary_plan->requires_fixed_state_conversion()); + + MultiFab& state = sim.block_state(0); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { + for (int component = 0; component < kNcomp; ++component) + values(i, j, component) = Real(1); + }); + } + device_fence(); + const auto lane = ExecutionLane::world("test.system.primitive-fixed-state"); + const runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.system-primitive-inflow", 0, 0, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession boundary(sim.grid_context("fluid"), lane, state, point); + boundary.fill(state, point); + device_fence(); + + if (state.local_size() > 0) { + const ConstArray4 values = state.fab(0).const_array(); + EXPECT_EQ(values(4, 2, 0), Real(3)); + EXPECT_EQ(values(4, 2, 1), Real(11)); + EXPECT_EQ(values(4, 2, 2), Real(-5)); + EXPECT_NEAR(values(4, 2, 3), Real(39), Real(1e-12)); + } +} + +TEST(ProgramContextContract, SystemExecutesScalarAxisPermutedPeriodicPlan) { + ensure_kokkos(); + SystemConfig cfg; + cfg.n = 6; + cfg.L = 1.0; + cfg.periodicity = {false, false}; + System sim(cfg); + const std::string state_identity = "case::block::scalar::state::U"; + sim.install_block_state_route("scalar", state_identity); + const PeriodicIdentification2D xlo_to_yhi{0, 3, std::array{{1, 0}}, + std::array{{1, 1}}}; + sim.install_boundary_plan( + "scalar", "case::block::scalar::boundary", 1, + {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(4, 0.0), + {"case::block::scalar::xlo", "case::block::scalar::xhi", "case::block::scalar::ylo", + "case::block::scalar::yhi"}, + {"Scalar"}, {}, state_identity, PreparedBoundaryReadDependencies{}, {xlo_to_yhi}); + sim.install_block("scalar", 1, VariableSet{}, VariableSet{}, 1.0, BlockClosures{}, {}, {}, 1, + true, 1); + sim.mark_bound(); + + MultiFab& state = sim.block_state(0); + for (int local = 0; local < state.local_size(); ++local) { + const Array4 values = state.fab(local).array(); + for_each_cell(state.box(local), [=](int i, int j) { values(i, j, 0) = Real(i + 100 * j); }); + } + const auto lane = ExecutionLane::world("test.system.axis-permuted-periodic"); + const runtime::multiblock::BoundaryEvaluationPoint point{ + "clock.system-axis-permuted", 0, 0, 0, 0, amr::Rational(0, 1), 0.1, 0.0}; + PreparedGridBoundarySession boundary(sim.grid_context("scalar"), lane, state, point); + boundary.fill(state, point); + device_fence(); + + for (int local = 0; local < state.local_size(); ++local) { + const Fab2D& field = state.fab(local); + const Box2D grown = field.grown_box(); + for (int j = 0; j < cfg.n; ++j) + if (grown.contains(-1, j)) + EXPECT_EQ(field(-1, j, 0), Real(j + 100 * (cfg.n - 1))); + for (int i = 0; i < cfg.n; ++i) + if (grown.contains(i, cfg.n)) + EXPECT_EQ(field(i, cfg.n, 0), Real(100 * i)); + } +} + TEST(ProgramContextContract, CommitManySnapshotsSourcesThatAreAlsoTargets) { ensure_kokkos(); SystemConfig cfg; @@ -444,6 +651,9 @@ TEST(ProgramContextContract, sim.set_poisson("charge_density", "geometric_mg"); sim.set_program_block_map({0, 1}); ProgramContext ctx(&sim); + ctx.configure_primary_clock("clock.main"); + ctx.begin_step(0.01); + const auto point = [&](int stage) { return ctx.boundary_evaluation_point(stage); }; MultiFab& live_a = ctx.state(0); MultiFab& live_b = ctx.state(1); @@ -458,8 +668,16 @@ TEST(ProgramContextContract, Real* const live_a_storage = live_a.fab(0).array().p; Real* const live_b_storage = live_b.fab(0).array().p; + auto incomplete_point = point(500); + incomplete_point.clock.clear(); + EXPECT_THROW((void)ctx.solve_fields_from_blocks_at(incomplete_point, 500, "missing-provider", + {{0, &stage_a}, {1, &stage_b}}), + std::invalid_argument) + << "the generated route must retain its complete BoundaryEvaluationPoint"; + auto missing_field_solve = [&]() { - return ctx.solve_fields_from_blocks(501, "missing-provider", {{0, &stage_a}, {1, &stage_b}}); + return ctx.solve_fields_from_blocks_at(point(501), 501, "missing-provider", + {{0, &stage_a}, {1, &stage_b}}); }; EXPECT_THROW((void)missing_field_solve(), std::runtime_error); EXPECT_EQ(live_a.fab(0).array().p, live_a_storage); @@ -479,12 +697,12 @@ TEST(ProgramContextContract, // The complete request is validated before the first substitution: neither a cross-owner live // alias nor one wrong ghost footprint may expose a provisional state. - EXPECT_THROW( - (void)ctx.solve_fields_from_blocks(502, "missing-provider", {{0, &live_b}, {1, &stage_b}}), - std::invalid_argument); + EXPECT_THROW((void)ctx.solve_fields_from_blocks_at(point(502), 502, "missing-provider", + {{0, &live_b}, {1, &stage_b}}), + std::invalid_argument); MultiFab wrong_layout(stage_b.box_array(), stage_b.dmap(), stage_b.ncomp(), stage_b.n_grow() + 1); - EXPECT_THROW((void)ctx.solve_fields_from_blocks(503, "missing-provider", - {{0, &stage_a}, {1, &wrong_layout}}), + EXPECT_THROW((void)ctx.solve_fields_from_blocks_at(point(503), 503, "missing-provider", + {{0, &stage_a}, {1, &wrong_layout}}), std::invalid_argument); EXPECT_EQ(live_a.fab(0).array().p, live_a_storage); EXPECT_EQ(live_b.fab(0).array().p, live_b_storage); @@ -498,11 +716,17 @@ TEST(ProgramContextContract, MultiFab subset_stage(subset_live.box_array(), subset_live.dmap(), subset_live.ncomp(), subset_live.n_grow()); subset_stage.set_val(Real(11)); - EXPECT_THROW((void)ctx.solve_fields_from_blocks(505, "missing-subset-provider", {{0, &live_a}}), + EXPECT_THROW((void)ctx.solve_fields_from_blocks_at(point(501), 501, "missing-provider", + {{0, &subset_stage}}), + std::logic_error) + << "a runtime block-map rematerialization must not teach an existing IR value a new pack"; + EXPECT_THROW((void)ctx.solve_fields_from_blocks_at(point(505), 505, "missing-subset-provider", + {{0, &live_a}}), std::invalid_argument) << "a subset Program must not borrow an unlisted System block's live state as its stage"; auto subset_solve = [&]() { - return ctx.solve_fields_from_blocks(504, "missing-subset-provider", {{0, &subset_stage}}); + return ctx.solve_fields_from_blocks_at(point(504), 504, "missing-subset-provider", + {{0, &subset_stage}}); }; EXPECT_THROW((void)subset_solve(), std::runtime_error); const AllocationEventStats before_subset_retry = allocation_event_stats(); @@ -520,16 +744,16 @@ TEST(ProgramContextContract, subset_live.n_grow()); rebound_stage.set_val(Real(13)); const AllocationEventStats before_layout_change = allocation_event_stats(); - EXPECT_THROW( - (void)ctx.solve_fields_from_blocks(504, "missing-subset-provider", {{0, &rebound_stage}}), - std::runtime_error); + EXPECT_THROW((void)ctx.solve_fields_from_blocks_at(point(504), 504, "missing-subset-provider", + {{0, &rebound_stage}}), + std::runtime_error); const AllocationEventStats after_layout_change = allocation_event_stats(); EXPECT_EQ(after_layout_change.fab_calls, before_layout_change.fab_calls); EXPECT_EQ(after_layout_change.communication_calls, before_layout_change.communication_calls); const AllocationEventStats before_rebound_retry = allocation_event_stats(); - EXPECT_THROW( - (void)ctx.solve_fields_from_blocks(504, "missing-subset-provider", {{0, &rebound_stage}}), - std::runtime_error); + EXPECT_THROW((void)ctx.solve_fields_from_blocks_at(point(504), 504, "missing-subset-provider", + {{0, &rebound_stage}}), + std::runtime_error); const AllocationEventStats after_rebound_retry = allocation_event_stats(); EXPECT_EQ(after_rebound_retry.fab_calls, before_rebound_retry.fab_calls); EXPECT_EQ(after_rebound_retry.communication_calls, before_rebound_retry.communication_calls); diff --git a/tests/cpp/unit/runtime/test_program_context_schur_free.cpp b/tests/cpp/unit/runtime/test_program_context_schur_free.cpp index b4b1e51c7..ff0f8f060 100644 --- a/tests/cpp/unit/runtime/test_program_context_schur_free.cpp +++ b/tests/cpp/unit/runtime/test_program_context_schur_free.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -55,6 +56,94 @@ TEST(ProgramContextSchurFree, HeaderIsSelfContainedAndBuilds) { SUCCEED() << "program_context.hpp builds without any coupling/schur/** dependency"; } +TEST(ProgramRuntimeStateCadence, SharedDispatcherOwnsHoldSubstepAndCursorCommit) { + pops::runtime::program::ProgramRuntimeState state; + struct Dispatch { + double start = 0.0; + double dt = 0.0; + int macro_step = -1; + }; + std::vector dispatches; + double physical_time = 2.0; + int macro_step = 4; + state.install_unverified_step( + [&](double dt) { dispatches.push_back({physical_time, dt, macro_step}); }); + state.set_cadence(/*substeps=*/2, /*stride=*/2, "Fixture"); + + state.dispatch_cadence_step(physical_time, macro_step, 0.1, "Fixture"); + EXPECT_TRUE(dispatches.empty()); + EXPECT_DOUBLE_EQ(physical_time, 2.1); + EXPECT_EQ(macro_step, 5); + EXPECT_DOUBLE_EQ(state.cadence_window_dt_, 0.1); + EXPECT_EQ(state.cadence_window_steps_, 1); + EXPECT_DOUBLE_EQ(state.cadence_window_start_time_, 2.0); + + state.dispatch_cadence_step(physical_time, macro_step, 0.3, "Fixture"); + ASSERT_EQ(dispatches.size(), 2); + EXPECT_DOUBLE_EQ(dispatches[0].start, 2.0); + EXPECT_EQ(dispatches[0].macro_step, 4); + EXPECT_DOUBLE_EQ(dispatches[1].start, dispatches[0].start + dispatches[0].dt); + EXPECT_EQ(dispatches[1].macro_step, 4); + EXPECT_DOUBLE_EQ(physical_time, dispatches[1].start + dispatches[1].dt); + EXPECT_EQ(macro_step, 6); + EXPECT_DOUBLE_EQ(state.last_dt_, dispatches[1].dt); + EXPECT_DOUBLE_EQ(state.cadence_window_dt_, 0.0); + EXPECT_EQ(state.cadence_window_steps_, 0); + EXPECT_DOUBLE_EQ(state.cadence_window_start_time_, 0.0); +} + +TEST(ProgramRuntimeStateCadence, DispatchFailureRestoresCursorWindowAndReentrancyLease) { + pops::runtime::program::ProgramRuntimeState state; + double physical_time = 1.0; + int macro_step = 0; + int calls = 0; + bool fail_second_substep = true; + state.install_unverified_step([&](double) { + ++calls; + if (fail_second_substep && calls == 2) + throw std::runtime_error("injected cadence substep failure"); + }); + state.set_cadence(/*substeps=*/2, /*stride=*/1, "Fixture"); + + EXPECT_THROW(state.dispatch_cadence_step(physical_time, macro_step, 0.4, "Fixture"), + std::runtime_error); + EXPECT_DOUBLE_EQ(physical_time, 1.0); + EXPECT_EQ(macro_step, 0); + EXPECT_DOUBLE_EQ(state.cadence_window_dt_, 0.0); + EXPECT_EQ(state.cadence_window_steps_, 0); + EXPECT_FALSE(state.cadence_dispatch_active_); + + calls = 0; + fail_second_substep = false; + EXPECT_NO_THROW(state.dispatch_cadence_step(physical_time, macro_step, 0.4, "Fixture")); + EXPECT_EQ(calls, 2); + EXPECT_DOUBLE_EQ(physical_time, 1.4); + EXPECT_EQ(macro_step, 1); + + state.install_unverified_step( + [&](double) { state.dispatch_cadence_step(physical_time, macro_step, 0.1, "Fixture"); }); + EXPECT_THROW(state.dispatch_cadence_step(physical_time, macro_step, 0.1, "Fixture"), + std::logic_error); + EXPECT_DOUBLE_EQ(physical_time, 1.4); + EXPECT_EQ(macro_step, 1); + EXPECT_FALSE(state.cadence_dispatch_active_); +} + +TEST(ProgramRuntimeStateCadence, MacroStepOverflowFailsBeforeProgramDispatch) { + pops::runtime::program::ProgramRuntimeState state; + double physical_time = 0.0; + int macro_step = std::numeric_limits::max(); + int calls = 0; + state.install_unverified_step([&](double) { ++calls; }); + + EXPECT_THROW(state.dispatch_cadence_step(physical_time, macro_step, 0.1, "Fixture"), + std::overflow_error); + EXPECT_EQ(calls, 0); + EXPECT_DOUBLE_EQ(physical_time, 0.0); + EXPECT_EQ(macro_step, std::numeric_limits::max()); + EXPECT_FALSE(state.cadence_dispatch_active_); +} + namespace { template @@ -74,6 +163,7 @@ class ExecutionServicesFixture return program_runtime_state_.diagnostic(name, "ExecutionServicesFixture"); } double logical_dt() const { return logical_dt_; } + void set_untracked_logical_dt(double value) { logical_dt_ = value; } void fail_next_logical_apply() { fail_logical_apply_ = true; } int rhs_group_identity() const { return rhs_group_identity_; } const std::vector& rhs_group_program_blocks() const { return rhs_group_program_blocks_; } @@ -91,6 +181,16 @@ class ExecutionServicesFixture bool history_initialized() const { return history_initialized_; } pops::Real history_outgoing_dt() const { return history_outgoing_dt_; } const std::string& history_rotation_clock() const { return history_rotation_clock_; } + int resource_level() const { return resource_level_; } + int resource_level_query_count() const { return resource_level_query_count_; } + int resource_levels() const { return resource_levels_; } + void set_scratch_resource_identity(std::uint64_t epoch, std::uint64_t generation, int levels, + int level) { + resource_topology_epoch_ = epoch; + resource_materialization_generation_ = generation; + resource_levels_ = levels; + resource_level_ = level; + } int boundary_program_block() const { return boundary_program_block_; } int assembly_target_count() const { return assembly_target_count_; } int assembly_source_count() const { return assembly_source_count_; } @@ -110,6 +210,22 @@ class ExecutionServicesFixture return static_cast(field_solve_dispatches_.size()); } const std::vector& field_solve_dispatches() const { return field_solve_dispatches_; } + int generated_field_dispatch_count() const { return generated_field_dispatch_count_; } + const std::string& generated_field_identity() const { return generated_field_identity_; } + const std::vector& generated_runtime_stages() const { + return generated_runtime_stages_; + } + pops::MultiFab& runtime_state(int runtime_block) const { + return runtime_states_.at(static_cast(runtime_block)); + } + void set_program_block_map(std::vector block_map) { + program_runtime_state_.block_map_ = std::move(block_map); + } + void fail_next_generated_field_dispatch() { fail_generated_field_dispatch_ = true; } + void reenter_next_generated_field_dispatch(std::int64_t value_id) { + reenter_generated_field_dispatch_ = true; + reentrant_generated_value_id_ = value_id; + } void run_installed_step(double dt) const { if (!installed_step_) throw std::logic_error("fixture has no installed Program step"); @@ -132,6 +248,21 @@ class ExecutionServicesFixture double dt = 0.0; }; + struct FieldFacade { + int* update_count = nullptr; + + void set_field_logical_timepoint(const std::string&, const pops::FieldLogicalTimePoint&) const { + ++*update_count; + } + void set_field_boundary_parameters(const std::string&, const std::vector&) const { + ++*update_count; + } + void set_field_boundary_kernel(const std::string&, + const pops::CompiledFieldBoundaryKernel&) const { + ++*update_count; + } + }; + double program_execution_logical_parent_dt_() const noexcept { return logical_dt_; } void program_execution_install_(std::function step) const { ++install_count_; @@ -156,21 +287,26 @@ class ExecutionServicesFixture program_execution_field_solve_from_state_at_outcome_(point, provider_slot, block, state); return outcome.consume(pops::SolveConsumption::kAccept); } - pops::SolveOutcome program_execution_solve_named_field_from_state_outcome_( - const std::string&, int, pops::MultiFab&) const { - return solved_field_outcome_("named-state"); - } pops::SolveOutcome program_execution_solve_fields_from_blocks_outcome_( const std::vector&) const { return solved_field_outcome_("default-blocks"); } - pops::SolveOutcome program_execution_solve_named_field_from_blocks_outcome_( - const std::string&, const std::vector&) const { - return solved_field_outcome_("named-blocks"); - } pops::SolveOutcome program_execution_solve_generated_field_from_blocks_outcome_( - std::int64_t, std::string_view, - std::initializer_list) const { + const pops::runtime::multiblock::BoundaryEvaluationPoint& point, const std::string& field, + const std::vector& runtime_stages) const { + ++generated_field_dispatch_count_; + generated_field_identity_ = field; + generated_runtime_stages_ = runtime_stages; + if (fail_generated_field_dispatch_) { + fail_generated_field_dispatch_ = false; + throw std::runtime_error("injected generated field provider failure"); + } + if (reenter_generated_field_dispatch_) { + reenter_generated_field_dispatch_ = false; + this->solve_fields_from_blocks_at( + point, reentrant_generated_value_id_, field, + {{0, runtime_stages.at(static_cast(this->sys_block(0)))}}); + } return solved_field_outcome_("generated-blocks"); } LogicalRollback program_execution_capture_logical_evaluation_() const noexcept { @@ -305,21 +441,13 @@ class ExecutionServicesFixture pops::runtime::program::ProgramRuntimeState& program_execution_runtime_state_() const { return program_runtime_state_; } + pops::MultiFab& program_execution_state_(int runtime_block) const { + return runtime_states_.at(static_cast(runtime_block)); + } typename SharedServices::ProgramClockCoordinate program_execution_clock_coordinate_() const { return {pops::Real(3.5), 4, active_level_}; } - void program_execution_set_field_timepoint_(const std::string&, - const pops::FieldLogicalTimePoint&) const { - ++field_update_count_; - } - void program_execution_set_field_parameters_(const std::string&, - const std::vector&) const { - ++field_update_count_; - } - void program_execution_set_field_kernel_(const std::string&, - const pops::CompiledFieldBoundaryKernel&) const { - ++field_update_count_; - } + FieldFacade& program_execution_field_facade_() const { return field_facade_; } void program_execution_register_history_storage_( const typename SharedServices::HistoryRegistration& registration) const { ++history_register_count_; @@ -365,9 +493,12 @@ class ExecutionServicesFixture } typename SharedServices::ProgramResourceTopology program_execution_resource_topology_() const noexcept { - return {11, 17, Amr ? 3 : 1, 2}; + return {resource_topology_epoch_, resource_materialization_generation_, resource_levels_, 2}; + } + int program_execution_resource_level_() const noexcept { + ++resource_level_query_count_; + return resource_level_; } - int program_execution_resource_level_() const noexcept { return resource_level_; } void program_execution_select_resource_level_(int selected) const noexcept { resource_level_ = selected; } @@ -389,8 +520,14 @@ class ExecutionServicesFixture int active_level_ = -1; mutable int resource_level_ = Amr ? 1 : 0; + mutable int resource_level_query_count_ = 0; + mutable std::uint64_t resource_topology_epoch_ = 11; + mutable std::uint64_t resource_materialization_generation_ = 17; + mutable int resource_levels_ = Amr ? 3 : 1; mutable pops::runtime::program::ProgramRuntimeState program_runtime_state_; + mutable std::vector runtime_states_ = std::vector(2); mutable int field_update_count_ = 0; + mutable FieldFacade field_facade_{&field_update_count_}; mutable int history_register_count_ = 0; mutable int history_read_count_ = 0; mutable int history_store_count_ = 0; @@ -427,6 +564,12 @@ class ExecutionServicesFixture mutable int install_count_ = 0; mutable std::function installed_step_; mutable std::vector field_solve_dispatches_; + mutable int generated_field_dispatch_count_ = 0; + mutable std::string generated_field_identity_; + mutable std::vector generated_runtime_stages_; + mutable bool fail_generated_field_dispatch_ = false; + mutable bool reenter_generated_field_dispatch_ = false; + mutable std::int64_t reentrant_generated_value_id_ = -1; mutable bool exclusive_workspace_in_use_ = false; }; @@ -570,9 +713,10 @@ void expect_shared_install_and_field_services(Context& context) { EXPECT_DOUBLE_EQ(installed_dt, 0.125); pops::MultiFab state; + pops::MultiFab state_b; const std::vector states{&state}; - pops::runtime::multiblock::BoundaryEvaluationPoint point{}; - point.level = context.level(); + const pops::runtime::multiblock::BoundaryEvaluationPoint point{ + "fixture.clock", 4, context.level(), 0, 3, pops::amr::Rational(1, 2), 0.125, 3.5}; auto accept = [](pops::SolveOutcome outcome) { return outcome.consume(pops::SolveConsumption::kAccept); }; @@ -580,14 +724,135 @@ void expect_shared_install_and_field_services(Context& context) { EXPECT_TRUE(accept(context.solve_fields()).solved()); EXPECT_TRUE(accept(context.solve_fields_from_state(0, state)).solved()); EXPECT_TRUE(accept(context.solve_fields_from_state_at(point, "field", 0, state)).solved()); - EXPECT_TRUE(accept(context.solve_fields_from_state("field", 0, state)).solved()); EXPECT_TRUE(accept(context.solve_fields_from_blocks(states)).solved()); - EXPECT_TRUE(accept(context.solve_fields_from_blocks("field", states)).solved()); - EXPECT_TRUE(accept(context.solve_fields_from_blocks(17, "field", {{0, &state}})).solved()); + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 17, "field", {{0, &state}})).solved()); + EXPECT_EQ(context.generated_field_identity(), "field"); + ASSERT_EQ(context.generated_runtime_stages().size(), 2); + EXPECT_EQ(context.generated_runtime_stages()[0], nullptr); + EXPECT_EQ(context.generated_runtime_stages()[1], &state) + << "Program block 0 must be materialized once into runtime slot 1"; + EXPECT_EQ(context.field_solve_dispatches(), + std::vector({"default", "default-state", "qualified-state-at", + "default-blocks", "generated-blocks"})); + + int evaluated_bodies = 0; + context.evaluate_with_field_state_at(point, "field", 0, state, state, + [&]() { ++evaluated_bodies; }); + EXPECT_EQ(evaluated_bodies, 1); EXPECT_EQ( context.field_solve_dispatches(), - std::vector({"default", "default-state", "qualified-state-at", "named-state", - "default-blocks", "named-blocks", "generated-blocks"})); + std::vector({"default", "default-state", "qualified-state-at", "default-blocks", + "generated-blocks", "qualified-state-at", "qualified-state-at"})); + + int generated_calls = context.generated_field_dispatch_count(); + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, -1, "field", {{0, &state}}), + std::invalid_argument); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "invalid generated IR identity must fail before provider dispatch"; + + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, 17, "other-field", {{0, &state}}), + std::logic_error); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "a generated value cannot silently drift to another field"; + + EXPECT_THROW( + (void)context.solve_fields_from_blocks_at(point, 17, "field", {{0, &state}, {1, &state_b}}), + std::logic_error); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "the compiled block pack must be checked before provider dispatch"; + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 17, "field", {{0, &state}})).solved()); + ++generated_calls; + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "a failed preparation must release the persistent workspace"; + + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 18, "field", {{1, &state_b}})).solved()); + ++generated_calls; + ASSERT_EQ(context.generated_runtime_stages().size(), 2); + EXPECT_EQ(context.generated_runtime_stages()[0], &state_b); + EXPECT_EQ(context.generated_runtime_stages()[1], nullptr) + << "a distinct generated value owns an independent ordered block pack"; + + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 19, "field", {{0, &state}, {1, &state_b}})) + .solved()); + ++generated_calls; + EXPECT_THROW( + (void)context.solve_fields_from_blocks_at(point, 19, "field", {{1, &state_b}, {0, &state}}), + std::logic_error); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "the ordered block pack is part of the generated value identity"; + + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, 20, "field", + {{0, &context.runtime_state(0)}}), + std::invalid_argument); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "a stage cannot alias another runtime block's live state"; + + const pops::Box2D wrong_domain = pops::Box2D::from_extents(2, 2); + const pops::BoxArray wrong_boxes(std::vector{wrong_domain}); + const pops::DistributionMapping wrong_mapping(std::vector{0}); + pops::MultiFab wrong_layout(wrong_boxes, wrong_mapping, 1, 0); + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, 21, "field", {{0, &wrong_layout}}), + std::invalid_argument); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "layout validation must precede provider dispatch"; + + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, 22, "field", {{0, nullptr}}), + std::invalid_argument); + EXPECT_THROW( + (void)context.solve_fields_from_blocks_at(point, 23, "field", {{0, &state}, {0, &state_b}}), + std::invalid_argument); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "null and duplicate overrides must fail before provider dispatch"; + + context.fail_next_generated_field_dispatch(); + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, 17, "field", {{0, &state}}), + std::runtime_error); + ++generated_calls; + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls); + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 17, "field", {{0, &state}})).solved()); + ++generated_calls; + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "a provider exception must release the generated workspace"; + + context.reenter_next_generated_field_dispatch(17); + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, 17, "field", {{0, &state}}), + std::logic_error); + ++generated_calls; + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "nested use must be rejected before a second provider dispatch"; + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 17, "field", {{0, &state}})).solved()); + ++generated_calls; + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "a nested-use rejection must release the outer workspace"; + + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 24, "field", {{0, &state}})).solved()); + ++generated_calls; + context.set_program_block_map({0, 1}); + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 24, "field", {{0, &state}})).solved()); + ++generated_calls; + ASSERT_EQ(context.generated_runtime_stages().size(), 2); + EXPECT_EQ(context.generated_runtime_stages()[0], &state); + EXPECT_EQ(context.generated_runtime_stages()[1], nullptr); + EXPECT_THROW((void)context.solve_fields_from_blocks_at(point, 24, "field", {{1, &state_b}}), + std::logic_error); + EXPECT_EQ(context.generated_field_dispatch_count(), generated_calls) + << "runtime re-slotting must not reteach an existing value its Program block pack"; + context.set_program_block_map({1, 0}); + EXPECT_TRUE( + accept(context.solve_fields_from_blocks_at(point, 24, "field", {{0, &state}})).solved()); + ++generated_calls; + ASSERT_EQ(context.generated_runtime_stages().size(), 2); + EXPECT_EQ(context.generated_runtime_stages()[0], nullptr); + EXPECT_EQ(context.generated_runtime_stages()[1], &state) + << "the same immutable Program pack rematerializes after a topology map change"; auto mismatched_point = point; ++mismatched_point.level; @@ -608,6 +873,32 @@ void expect_shared_install_and_field_services(Context& context) { EXPECT_EQ(context.field_solve_dispatch_count(), calls_before_invalid_provider) << "shared provider identity validation must run before topology dispatch"; + auto expect_invalid_point_before_dispatch = [&](const auto& invalid_point) { + const int calls_before_invalid_point = context.field_solve_dispatch_count(); + const int level_queries_before_invalid_point = context.resource_level_query_count(); + EXPECT_THROW((void)context.solve_fields_from_state_at(invalid_point, "field", 0, state), + std::invalid_argument); + EXPECT_THROW( + (void)context.solve_fields_from_blocks_at(invalid_point, 17, "field", {{0, &state}}), + std::invalid_argument); + EXPECT_EQ(context.field_solve_dispatch_count(), calls_before_invalid_point) + << "shared point validation must run before every topology provider hook"; + EXPECT_EQ(context.resource_level_query_count(), level_queries_before_invalid_point) + << "invalid topology-independent point data must fail before querying the provider level"; + }; + auto invalid_point = point; + invalid_point.clock.clear(); + expect_invalid_point_before_dispatch(invalid_point); + invalid_point = point; + invalid_point.dt = 0.0; + expect_invalid_point_before_dispatch(invalid_point); + invalid_point = point; + invalid_point.stage = -1; + expect_invalid_point_before_dispatch(invalid_point); + invalid_point = point; + invalid_point.stage_fraction = pops::amr::Rational(3, 2); + expect_invalid_point_before_dispatch(invalid_point); + EXPECT_THROW(context.exercise_exclusive_workspace(true, false), std::logic_error); EXPECT_FALSE(context.exclusive_workspace_in_use()) << "the outer guard must release after a nested-use rejection"; @@ -828,6 +1119,64 @@ void expect_shared_operator_snapshot_services(Context& context, std::uint64_t to EXPECT_EQ(reminted_parent.revision, 3u); EXPECT_DOUBLE_EQ(std::bit_cast(reminted_parent.dt_bits), 0.4); EXPECT_EQ(context.operator_topology_count(), 3); + + context.set_untracked_logical_dt(0.3); + const auto stale_after_provider_clock_change = context.probe_operator_evaluation( + authority, reminted_parent.topology, resources, reminted_parent.revision); + EXPECT_EQ(stale_after_provider_clock_change.revision, 0u); + EXPECT_FALSE(stale_after_provider_clock_change.valid()) + << "a provider clock transition must invalidate the complete shared capability"; + context.set_untracked_logical_dt(0.4); + EXPECT_EQ(context + .probe_operator_evaluation(authority, reminted_parent.topology, resources, + reminted_parent.revision) + .revision, + 0u) + << "restoring matching scalar coordinates must not resurrect an invalidated capability"; +} + +template +void expect_shared_persistent_scratch_services(Context& context) { + const pops::Box2D domain = pops::Box2D::from_extents(4, 4); + const pops::BoxArray boxes(std::vector{domain}); + const pops::DistributionMapping mapping(std::vector{0}); + pops::MultiFab prototype(boxes, mapping, 2, 1); + + context.profiler().enable(); + pops::MultiFab& first = context.rhs_scratch(41, 0, prototype); + EXPECT_EQ(first.ncomp(), 2); + EXPECT_EQ(first.n_grow(), 1); + first.set_val(pops::Real(9)); + const std::int64_t allocations_after_first = context.profiler().counter("scratch_allocs"); + + pops::MultiFab& reused = context.rhs_scratch(41, 0, prototype); + EXPECT_EQ(&reused, &first); + EXPECT_EQ(context.profiler().counter("scratch_allocs"), allocations_after_first); + if (reused.local_size() > 0) { + const auto cell = reused.box(0).lo; + EXPECT_EQ(reused.fab(0).const_array()(cell[0], cell[1], 0), pops::Real(0)) + << "a shared persistent slot must clear provisional bytes before reuse"; + } + + pops::MultiFab& other_kind = context.scratch_state(41, 0, prototype); + pops::MultiFab& other_subslot = context.rhs_scratch(41, 1, prototype); + EXPECT_NE(&other_kind, &reused); + EXPECT_NE(&other_subslot, &reused); + EXPECT_EQ(context.profiler().counter("scratch_allocs"), allocations_after_first + 2); + + const int level = context.resource_level(); + const int levels = context.resource_levels(); + context.set_scratch_resource_identity(11, 18, levels, level); + (void)context.rhs_scratch(41, 0, prototype); + EXPECT_EQ(context.profiler().counter("scratch_allocs"), allocations_after_first + 3) + << "a process-local materialization change must invalidate every shared scratch slot"; + + EXPECT_THROW((void)context.rhs_scratch(-1, 0, prototype), std::invalid_argument); + EXPECT_THROW((void)context.rhs_scratch(41, -1, prototype), std::invalid_argument); + context.set_scratch_resource_identity(12, 19, 0, 0); + EXPECT_THROW((void)context.rhs_scratch(41, 0, prototype), std::runtime_error); + context.set_scratch_resource_identity(12, 19, levels, levels); + EXPECT_THROW((void)context.rhs_scratch(41, 0, prototype), std::out_of_range); } } // namespace @@ -873,3 +1222,10 @@ TEST(ProgramExecutionServices, UniformAndAmrProvidersRunTheSameOperatorSnapshotF expect_shared_operator_snapshot_services(uniform, 1); expect_shared_operator_snapshot_services(amr, 17); } + +TEST(ProgramExecutionServices, UniformAndAmrProvidersRunTheSamePersistentScratchFixture) { + ExecutionServicesFixture uniform(-1); + ExecutionServicesFixture amr(1); + expect_shared_persistent_scratch_services(uniform); + expect_shared_persistent_scratch_services(amr); +} diff --git a/tests/cpp/unit/runtime/test_scheme_dispatch.cpp b/tests/cpp/unit/runtime/test_scheme_dispatch.cpp index 268bb0cb9..5a9efbd5a 100644 --- a/tests/cpp/unit/runtime/test_scheme_dispatch.cpp +++ b/tests/cpp/unit/runtime/test_scheme_dispatch.cpp @@ -30,17 +30,21 @@ int routed_n_ghost(LimiterRouteId route) { } // namespace TEST(test_scheme_dispatch, routes_each_limiter_to_its_reconstruction_policy) { - // Each route binds the compile-time type whose ::n_ghost matches kLimiters (1/2/2/3) and the type in + // Each route binds the compile-time type whose ::n_ghost matches kLimiters and the type in // reconstruction.hpp -- so the X-macro POPS_FOR_EACH_LIMITER cannot drift from the route table. EXPECT_EQ(routed_n_ghost(LimiterRouteId::kNone), NoSlope::n_ghost); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMinmod), Minmod::n_ghost); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kVanLeer), VanLeer::n_ghost); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kWeno5), Weno5::n_ghost); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMc), MC::n_ghost); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kSuperbee), Superbee::n_ghost); // Cross-check against the route table's ::n_ghost expectation (kLimiters, single source). EXPECT_EQ(routed_n_ghost(LimiterRouteId::kNone), 1); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMinmod), 2); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kVanLeer), 2); EXPECT_EQ(routed_n_ghost(LimiterRouteId::kWeno5), 3); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kMc), 2); + EXPECT_EQ(routed_n_ghost(LimiterRouteId::kSuperbee), 2); } TEST(test_scheme_dispatch, count_lock_matches_the_route_table) { diff --git a/tests/cpp/unit/runtime/test_system_registries.cpp b/tests/cpp/unit/runtime/test_system_registries.cpp index 633211acf..9f0a0821c 100644 --- a/tests/cpp/unit/runtime/test_system_registries.cpp +++ b/tests/cpp/unit/runtime/test_system_registries.cpp @@ -294,6 +294,24 @@ TEST(SystemDomain, LayoutReportReflectsCartesianConstruction) { EXPECT_GE(rep.aux_ncomp, 3) << "the shared aux channel is at least 3 wide"; } +TEST(SystemDomain, PolarLayoutPublishesPhysicalRadialAndPeriodicAzimuthalTopology) { + pops::SystemConfig config; + config.geometry = "polar"; + config.nr = 12; + config.ntheta = 24; + config.r_min = 0.25; + config.r_max = 1.0; + pops::runtime::system::SystemDomain domain(config); + const auto report = domain.layout_report(); + EXPECT_TRUE(report.polar); + EXPECT_FALSE(report.periodic_x); + EXPECT_TRUE(report.periodic_y); + EXPECT_EQ(domain.bc_.xlo, pops::BCType::Foextrap); + EXPECT_EQ(domain.bc_.xhi, pops::BCType::Foextrap); + EXPECT_EQ(domain.bc_.ylo, pops::BCType::Periodic); + EXPECT_EQ(domain.bc_.yhi, pops::BCType::Periodic); +} + TEST(SystemEllipticBackendRegistry, OpaqueCapabilitiesDoNotCloseTheExtensionSet) { EllipticRegistryHarness::EllipticBackendRegistry registry; registry.add("probe", std::make_unique(std::vector{ diff --git a/tests/cpp/unit/runtime/test_variable_role.cpp b/tests/cpp/unit/runtime/test_variable_role.cpp index e30c9195e..a34e7e293 100644 --- a/tests/cpp/unit/runtime/test_variable_role.cpp +++ b/tests/cpp/unit/runtime/test_variable_role.cpp @@ -30,3 +30,29 @@ TEST(VariableRole, IndexOfResolvesEulerIsothermalAndExBRoles) { << "roles isotherme"; EXPECT_EQ(pops::ExBVelocity::conservative_vars().index_of(R::Density), 0) << "role ExB"; } + +TEST(VariableRole, AxialRolesRoundTripThroughStableTextAbi) { + EXPECT_STREQ(pops::role_name(R::AxialX), "axial_x"); + EXPECT_STREQ(pops::role_name(R::AxialY), "axial_y"); + EXPECT_STREQ(pops::role_name(R::AxialZ), "axial_z"); + EXPECT_EQ(pops::role_from_name("axial_x"), R::AxialX); + EXPECT_EQ(pops::role_from_name("axial_y"), R::AxialY); + EXPECT_EQ(pops::role_from_name("axial_z"), R::AxialZ); + + const pops::VariableSet original{ + pops::VariableKind::Conservative, + {"rho", "bx", "by", "bz"}, + 4, + {R::Density, R::AxialX, R::AxialY, R::AxialZ}, + }; + EXPECT_EQ(pops::roles_csv(original), "density,axial_x,axial_y,axial_z"); + + pops::VariableSet restored{ + pops::VariableKind::Conservative, + original.names, + original.size, + }; + pops::parse_roles_into(restored, pops::roles_csv(original)); + EXPECT_TRUE(restored.user_roles.empty()); + EXPECT_EQ(restored.roles, original.roles); +} diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml new file mode 100644 index 000000000..b2bdc3d57 --- /dev/null +++ b/tests/gates/adc757_prepared_numerics.toml @@ -0,0 +1,626 @@ +schema_version = 3 +gate = "adc757-prepared-numerics-slice" +issue = "ADC-757" +evidence_from = ["ADC-682", "ADC-711", "ADC-733", "ADC-737", "ADC-749", "ADC-750", "ADC-751", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] +deferred = [ + "remaining_runtime_nd_metric_eb_characteristic_execution", + "remaining_multirank_multibox_amr_local_time_execution", +] + +[hardware_evidence] +kind = "authenticated_hardware_report" +polarity = "positive" +report_schema = "pops.adc757.heterogeneous-numerics.v1" +verifier = "benchmarks/adc757/verify.py" +requirements = [ + "gpu_backend_execution", + "accelerator_stream_partitioning", + "performance_baselines_and_regression_thresholds", +] + +# This is an executable partial gate, not ADC-757 closure. Every claimed +# software requirement has success and refusal/detector proofs. The three +# hardware positives must be supplied by the single authenticated report route above; +# ordinary CPU CTests may provide only their fail-closed detector proofs. +[[check]] +requirement = "prepared_local_nonlinear" +polarity = "positive" +target = "test_newton_robustness" +test_regex = "^PreparedLocalNonlinear\\.FiniteDifferenceAnalyticAndAdUseOneOutcomeContract$" + +[[check]] +requirement = "prepared_local_nonlinear" +polarity = "refusal" +target = "test_newton_robustness" +test_regex = "^PreparedLocalNonlinear\\.EveryFailureClassIsExplicitAndLeavesTheGuessUntouched$" + +[[check]] +requirement = "typed_fallible_evaluation" +polarity = "positive" +target = "test_newton_robustness" +test_regex = "^PreparedLocalNonlinear\\.FallibleEvaluationStatusAndReasonRemainDistinct$" + +[[check]] +requirement = "typed_fallible_evaluation" +polarity = "refusal" +target = "test_prepared_numerics_gate" +test_regex = "^PreparedNumericsGate\\.FatalEvaluationIsTypedAllocationFreeAndCannotPublish$" + +[[check]] +requirement = "transactional_recovery_publication" +polarity = "positive" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.tentative_publication_rolls_back_solution_and_warm_start$" + +[[check]] +requirement = "transactional_recovery_publication" +polarity = "refusal" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.malformed_and_repair_candidates_fail_closed$" + +[[check]] +requirement = "model_declared_admissibility" +polarity = "positive" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.model_declared_admissibility_permits_valid_candidate$" + +[[check]] +requirement = "model_declared_admissibility" +polarity = "refusal" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.model_declared_admissibility_blocks_publication$" + +[[check]] +requirement = "allocation_aware_cell_hot_path" +polarity = "positive" +target = "test_prepared_numerics_gate" +test_regex = "^PreparedNumericsGate\\.ConvergedPreparedPathAllocatesNothingAndRollsBack$" + +[[check]] +requirement = "allocation_aware_cell_hot_path" +polarity = "refusal" +target = "test_prepared_numerics_gate" +test_regex = "^PreparedNumericsGate\\.AllocationProbeDetectsControlHeapTraffic$" + +[[check]] +requirement = "prepared_boundary_publication" +polarity = "positive" +target = "test_prepared_boundary_plan" +test_regex = "^test_prepared_boundary_plan\\.evaluates_prepared_coordinate_time_inflow_on_device_without_hot_path_allocation$" + +[[check]] +requirement = "prepared_boundary_publication" +polarity = "refusal" +target = "test_prepared_boundary_plan" +test_regex = "^test_prepared_boundary_plan\\.analytic_inflow_preflights_nonfinite_values_before_any_mutation$" + +[[check]] +requirement = "post_riemann_boundary_flux" +polarity = "positive" +target = "test_amr_native_loader" +test_regex = "^test_amr_native_loader\\.PostRiemannBoundaryFluxUsesOutwardOrientationAndPreservesCanonicalFaceStorage$" + +[[check]] +requirement = "post_riemann_boundary_flux" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/mesh/test_boundary_topology_ports.py" +test = "test_post_riemann_flux_refuses_wrong_component_route_or_output" + +[[check]] +requirement = "mpi_collective_execution" +polarity = "positive" +kind = "mpi_ctest" +target = "test_mpi_system_analytic_level_set" +test_regex = "^test_mpi_system_analytic_level_set_np2$" +nproc = 2 + +[[check]] +requirement = "mpi_collective_execution" +polarity = "refusal" +kind = "mpi_ctest" +target = "test_mpi_flux_failure_collective" +test_regex = "^test_mpi_flux_failure_collective_np2$" +nproc = 2 + +[[check]] +requirement = "qualified_flux_provider_pack" +polarity = "positive" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.generated_provider_requirements_own_native_slot_reads$" + +[[check]] +requirement = "qualified_flux_provider_pack" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/codegen/test_compiler_model_provider.py" +test = "test_field_dependent_flux_without_provider_fails_before_native_source" + +[[check]] +requirement = "capability_driven_riemann" +polarity = "positive" +target = "test_riemann_capabilities" +test_regex = "^test_riemann_capabilities\\.state_layout_permutation_is_provider_owned$" + +[[check]] +requirement = "capability_driven_riemann" +polarity = "refusal" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.hllc_rejects_each_nonfinite_provider_stage_with_a_typed_cause$" + +[[check]] +requirement = "capability_driven_riemann" +polarity = "refusal" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.roe_rejects_nonfinite_dissipation_with_a_typed_cause$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "positive" +target = "test_weno_convergence" +test_regex = "^test_muscl_limiters\\.mc_and_superbee_match_reference_formulas$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "positive" +target = "test_weno_convergence" +test_regex = "^test_muscl_limiter_qualification\\.mc_and_superbee_are_second_order_on_smooth_periodic_cell_averages$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "positive" +target = "test_weno_convergence" +test_regex = "^test_muscl_limiter_qualification\\.discontinuities_create_no_extremum_and_expose_interface_dissipation_budget$" + +[[check]] +requirement = "prepared_limiter_provider" +polarity = "refusal" +target = "test_dispatch_tags" +test_regex = "^test_dispatch_tags\\.validate_limiter_accepts_and_rejects$" + +[[check]] +requirement = "typed_flux_recovery_consumption" +polarity = "positive" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.recovery_report_uses_the_flux_failure_reduction_without_type_erasure$" + +[[check]] +requirement = "typed_flux_recovery_consumption" +polarity = "refusal" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.face_recovery_refusal_never_reaches_the_numerical_flux$" + +[[check]] +requirement = "prepared_riemann_recovery_policy" +polarity = "positive" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.prepared_riemann_recovery_is_ordered_typed_and_device_copyable$" + +[[check]] +requirement = "prepared_riemann_recovery_policy" +polarity = "refusal" +target = "test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.prepared_riemann_recovery_exhaustion_is_typed_and_cannot_publish$" + +[[check]] +requirement = "public_prepared_riemann_recovery" +polarity = "positive" +kind = "pytest" +path = "tests/python/unit/descriptors/test_lib_descriptors.py" +test = "test_riemann_recovery_is_the_exact_prepared_native_policy" + +[[check]] +requirement = "public_prepared_riemann_recovery" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/descriptors/test_lib_descriptors.py" +test = "test_riemann_recovery_refuses_external_and_forged_native_candidates" + +[[check]] +requirement = "runtime_recovery_consumer_publication" +polarity = "positive" +target = "test_block_builder" +test_regex = "^test_block_builder\\.cell_primitive_conversion_consumes_prepared_recovery_outcome$" + +[[check]] +requirement = "runtime_recovery_consumer_publication" +polarity = "refusal" +target = "test_facade_routing" +test_regex = "^FacadeRouting\\.PrimitiveMaterializationFailsClosedWithoutMutatingAcceptedState$" + +[[check]] +requirement = "uniform_recovery_warm_start" +polarity = "positive" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.uniform_consumer_reuses_only_exact_generation_qualified_cells$" + +[[check]] +requirement = "uniform_recovery_warm_start" +polarity = "refusal" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.uniform_consumer_failure_keeps_output_and_invalidates_all_slots$" + +[[check]] +requirement = "analytic_initial_recovery_publication" +polarity = "positive" +target = "test_program_runtime" +test_regex = "^ProgramRuntime\\.AnalyticInitialStatePublishesWhenPreparedRecoveryAcceptsEveryCell$" + +[[check]] +requirement = "analytic_initial_recovery_publication" +polarity = "refusal" +target = "test_program_runtime" +test_regex = "^ProgramRuntime\\.AnalyticInitialStatePublishesOnlyAfterPreparedRecoveryAcceptsEveryCell$" + +[[check]] +requirement = "fallible_primitive_to_conservative_publication" +polarity = "positive" +target = "test_block_builder" +test_regex = "^test_block_builder\\.primitive_to_conservative_publication_roundtrips_before_commit$" + +[[check]] +requirement = "fallible_primitive_to_conservative_publication" +polarity = "refusal" +target = "test_facade_routing" +test_regex = "^FacadeRouting\\.PrimitiveInputRequiresPreparedRecoveryBeforeConservativePublication$" + +[[check]] +requirement = "amr_regrid_recovery_publication" +polarity = "positive" +target = "test_amr_transfer_properties" +test_regex = "^test_amr_transfer_properties\\.RegridPublishesOnlyAfterPreparedRecoveryAcceptsEveryCandidateCell$" + +[[check]] +requirement = "amr_regrid_recovery_publication" +polarity = "refusal" +target = "test_amr_transfer_properties" +test_regex = "^test_amr_transfer_properties\\.RegridRecoveryRefusalRollsBackHierarchyStateAndPublicationCounters$" + +[[check]] +requirement = "amr_restriction_recovery_publication" +polarity = "positive" +target = "test_amr_transfer_properties" +test_regex = "^test_amr_transfer_properties\\.RestrictionPublishesOnlyAfterPreparedRecoveryAcceptsEveryCandidateCell$" + +[[check]] +requirement = "amr_restriction_recovery_publication" +polarity = "refusal" +target = "test_amr_transfer_properties" +test_regex = "^test_amr_transfer_properties\\.RestrictionRecoveryRefusalRollsBackEveryLevelAndHierarchyPublication$" + +[[check]] +requirement = "amr_bootstrap_recovery_publication" +polarity = "positive" +target = "test_amr_transfer_properties" +test_regex = "^test_amr_transfer_properties\\.BootstrapCommitPublishesRecoveryAcceptedLevels$" + +[[check]] +requirement = "amr_bootstrap_recovery_publication" +polarity = "refusal" +target = "test_amr_transfer_properties" +test_regex = "^test_amr_transfer_properties\\.BootstrapRecoveryRefusalKeepsPendingLevelRollbackable$" + +[[check]] +requirement = "amr_history_recovery_publication" +polarity = "positive" +target = "test_amr_history_ring" +test_regex = "^test_amr_history_ring\\.RegridRemapKeepsSlotsConsistent$" + +[[check]] +requirement = "amr_history_recovery_publication" +polarity = "refusal" +target = "test_amr_history_ring" +test_regex = "^test_amr_history_ring\\.RegridRecoveryRefusalRollsBackRemappedHistoryAndLiveHierarchy$" + +[[check]] +requirement = "physical_boundary_trace_recovery_publication" +polarity = "positive" +target = "test_prepared_boundary_plan" +test_regex = "^PreparedBoundaryTraceRecovery\\.accepts_admissible_physical_traces_without_hot_path_allocation$" + +[[check]] +requirement = "physical_boundary_trace_recovery_publication" +polarity = "refusal" +target = "test_prepared_boundary_plan" +test_regex = "^PreparedBoundaryTraceRecovery\\.rejects_inadmissible_traces_and_restores_complete_ghost_transaction$" + +[[check]] +requirement = "terminal_source_recovery_publication" +polarity = "positive" +target = "test_program_runtime" +test_regex = "^ProgramRuntime\\.TerminalSourcePublicationAcceptsPreparedRecoveryCandidate$" + +[[check]] +requirement = "terminal_source_recovery_publication" +polarity = "refusal" +target = "test_program_runtime" +test_regex = "^ProgramRuntime\\.TerminalSourceRecoveryRefusalPreventsPartialMultiBlockCommit$" + +[[check]] +requirement = "type_erased_recovery_method_identity" +polarity = "positive" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.type_erased_report_preserves_selected_method_kind$" + +[[check]] +requirement = "type_erased_recovery_method_identity" +polarity = "refusal" +target = "test_variable_recovery_chain" +test_regex = "^PreparedVariableRecovery\\.rejected_report_names_last_method_without_forging_selection$" + +[[check]] +requirement = "cell_local_temporal_partition_authority" +polarity = "positive" +target = "test_cell_temporal_partition_executor" +test_regex = "^test_cell_temporal_partition_executor\\.executes_bounded_rung_batches_and_commits_exact_local_clocks$" + +[[check]] +requirement = "cell_local_temporal_partition_authority" +polarity = "refusal" +target = "test_temporal_partition_restart" +test_regex = "^test_temporal_partition_restart\\.strict_amr_restore_consumes_manifest_and_refuses_global_step_bypass$" + +[[check]] +requirement = "cell_local_temporal_scientific_provider" +polarity = "positive" +target = "test_cell_temporal_partition_executor" +test_regex = "^test_cell_temporal_partition_executor\\.production_same_level_provider_commits_real_state_and_integrated_face_fluxes$" + +[[check]] +requirement = "cell_local_temporal_scientific_provider" +polarity = "refusal" +target = "test_cell_temporal_partition_executor" +test_regex = "^test_cell_temporal_partition_executor\\.production_same_level_provider_rolls_back_and_refuses_unproved_envelopes$" + +[[check]] +requirement = "host_workspace_reentrancy" +polarity = "positive" +target = "test_krylov_workspace_reentrancy" +test_regex = "^test_krylov_workspace_reentrancy\\.distinct_workspaces_run_fresh_operator_and_preconditioner_sessions_concurrently$" + +[[check]] +requirement = "host_workspace_reentrancy" +polarity = "refusal" +target = "test_krylov_workspace_reentrancy" +test_regex = "^test_krylov_workspace_reentrancy\\.workspace_rebind_reserves_mutation_during_blocking_operator_prepare$" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "positive" +kind = "pytest" +path = "tests/python/unit/codegen/test_recovery_admissibility_codegen.py" +test = "test_recovery_admissibility_is_emitted_and_hashed" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/codegen/test_recovery_admissibility_codegen.py" +test = "test_recovery_admissibility_rejects_ambiguous_authoring" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "positive" +kind = "pytest" +path = "tests/python/unit/runtime/test_amr_checkpoint_contract.py" +test = "test_preflight_returns_exact_native_payload_and_counters" + +[[check]] +requirement = "python_ir_generated_abi_and_restart_parity" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/runtime/test_amr_checkpoint_contract.py" +test = "test_historical_version_refusal_happens_before_restart_transaction" + +[[check]] +requirement = "native_spatial_provider_dimension_matrix" +polarity = "positive" +target = "test_prepared_cartesian_nd" +test_regex = "^test_prepared_cartesian_nd\\.one_dimensional_kernel_preserves_constant_state_and_conservation$" + +[[check]] +requirement = "native_spatial_provider_dimension_matrix" +polarity = "positive" +target = "test_prepared_cartesian_nd" +test_regex = "^test_prepared_cartesian_nd\\.three_dimensional_kernel_is_axis_permutation_invariant_and_conservative$" + +[[check]] +requirement = "native_spatial_provider_dimension_matrix" +polarity = "positive" +target = "test_spatial_provider_matrix" +test_regex = "^test_spatial_provider_matrix\\.independent_axes_do_not_form_false_cross_product_capabilities$" + +[[check]] +requirement = "native_spatial_provider_dimension_matrix" +polarity = "refusal" +target = "test_spatial_provider_matrix" +test_regex = "^test_spatial_provider_matrix\\.native_runtime_dimension_refuses_unproved_3d_execution$" + +[[check]] +requirement = "metric_spatial_provider_geometry_matrix" +polarity = "positive" +target = "test_program_runtime" +test_regex = "^ProgramRuntime\\.ForwardEulerProgramContextHonorsEmbeddedBoundaryResidualMetrics$" + +[[check]] +requirement = "metric_spatial_provider_geometry_matrix" +polarity = "refusal" +target = "test_program_runtime" +test_regex = "^ProgramRuntime\\.EmbeddedBoundaryCapabilitiesRejectUnsupportedProvidersBeforePublication$" + +[[check]] +requirement = "characteristic_boundary_geometry_matrix" +polarity = "positive" +target = "test_prepared_boundary_plan" +test_regex = "^test_prepared_boundary_plan\\.executes_prepared_model_characteristics_without_scalar_fallback$" + +[[check]] +requirement = "characteristic_boundary_geometry_matrix" +polarity = "refusal" +target = "test_spatial_provider_matrix" +test_regex = "^test_spatial_provider_matrix\\.embedded_metric_residuals_do_not_claim_characteristic_or_linearization$" + +[[check]] +requirement = "polar_metric_spatial_provider_matrix" +polarity = "positive" +target = "test_polar_transport_mms" +test_regex = "^test_polar_transport_mms\\.DivergenceConvergesAtOrderTwoConstantVelocity$" + +[[check]] +requirement = "polar_metric_spatial_provider_matrix" +polarity = "refusal" +target = "test_spatial_provider_matrix" +test_regex = "^test_spatial_provider_matrix\\.polar_metric_provider_is_residual_only$" + +[[check]] +requirement = "measured_load_balance_decision" +polarity = "positive" +target = "test_load_balance" +test_regex = "^test_load_balance\\.measured_rebalance_accepts_only_net_benefit_after_migration$" + +[[check]] +requirement = "measured_load_balance_decision" +polarity = "refusal" +target = "test_load_balance" +test_regex = "^test_load_balance\\.measured_rebalance_refuses_stale_or_incomplete_evidence$" + +[[check]] +requirement = "measured_load_balance_decision" +polarity = "positive" +kind = "pytest" +path = "tests/python/unit/amr/test_public_amr_resolution.py" +test = "test_measured_knapsack_roundtrips_exact_native_decision_policy" + +[[check]] +requirement = "measured_load_balance_decision" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/amr/test_public_amr_resolution.py" +test = "test_measured_knapsack_rejects_invalid_decision_policy" + +[[check]] +requirement = "amr_rebalance_migration_and_restart_coherence" +polarity = "positive" +kind = "mpi_ctest" +target = "test_mpi_amr_rebalance_migration" +test_regex = "^test_mpi_amr_rebalance_migration_np2$" +nproc = 2 + +[[check]] +requirement = "amr_rebalance_migration_and_restart_coherence" +polarity = "refusal" +kind = "mpi_ctest" +target = "test_mpi_amr_rebalance_migration" +test_regex = "^test_mpi_amr_rebalance_migration_np4$" +nproc = 4 + +[[check]] +requirement = "amr_rebalance_migration_and_restart_coherence" +polarity = "positive" +target = "test_program_reflux_ledger" +test_regex = "^test_program_reflux_ledger\\.accepted_checkpoint_state_rematerializes_payloads_by_explicit_ownership$" + +[[check]] +requirement = "amr_rebalance_migration_and_restart_coherence" +polarity = "refusal" +target = "test_program_reflux_ledger" +test_regex = "^test_program_reflux_ledger\\.accepted_checkpoint_state_rematerialization_refuses_metadata_disagreement$" + +[[check]] +requirement = "bounded_cell_local_program_runtime" +polarity = "positive" +target = "test_cell_temporal_program_route" +test_regex = "^test_cell_temporal_program_route\\.installed_program_commits_exact_ticks_state_and_conservative_face_ledger$" + +[[check]] +requirement = "bounded_cell_local_program_runtime" +polarity = "refusal" +target = "test_cell_temporal_program_route" +test_regex = "^test_cell_temporal_program_route\\.invalid_tick_outer_rollback_and_same_topology_restart_remain_atomic$" + +[[check]] +requirement = "bounded_cell_local_program_runtime" +polarity = "refusal" +kind = "mpi_ctest" +target = "test_mpi_cell_temporal_program_refusal" +test_regex = "^test_mpi_cell_temporal_program_refusal_np2$" +nproc = 2 + +[[check]] +requirement = "bounded_cell_local_program_runtime" +polarity = "positive" +kind = "pytest" +path = "tests/python/unit/codegen/test_cell_local_time_codegen.py" +test = "test_cell_local_time_contract_is_frozen_rebuilt_and_hashed" + +[[check]] +requirement = "bounded_cell_local_program_runtime" +polarity = "refusal" +kind = "pytest" +path = "tests/python/unit/codegen/test_cell_local_time_codegen.py" +test = "test_cell_local_codegen_refuses_non_euler_and_nondefault_cadence" + +[[check]] +requirement = "gpu_backend_execution" +polarity = "refusal" +target = "test_prepared_stream_executor" +test_regex = "^PreparedStreamExecutor\\.CpuBackendsCannotClaimIndependentAcceleratorStreams$" + +[[check]] +requirement = "accelerator_stream_partitioning" +polarity = "refusal" +target = "test_prepared_stream_executor" +test_regex = "^PreparedStreamExecutor\\.InvalidPreparationIsRejectedBeforeBackendSelection$" + +[[check]] +requirement = "performance_baselines_and_regression_thresholds" +polarity = "refusal" +kind = "pytest" +path = "tests/python/architecture/test_adc757_heterogeneous_campaign.py" +test = "test_adc757_hardware_report_refuses_false_closure" + +[[check]] +requirement = "prepared_boundary_plan_only_transport_authority" +polarity = "positive" +kind = "pytest" +path = "tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py" +test = "test_prepared_boundary_plan_is_the_only_native_transport_authority" + +[[check]] +requirement = "prepared_boundary_plan_only_transport_authority" +polarity = "refusal" +kind = "pytest" +path = "tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py" +test = "test_legacy_transport_boundary_authorities_are_deleted" + +[[check]] +requirement = "polar_persistent_prepared_boundary_plan" +polarity = "positive" +target = "test_polar_system_step" +test_regex = "^PolarSystemStep\\.BoundProgramUsesPersistentPreparedBoundaryClosures$" + +[[check]] +requirement = "polar_persistent_prepared_boundary_plan" +polarity = "refusal" +target = "test_polar_transport_mms" +test_regex = "^test_polar_transport_mms\\.RejectsSharedInterfaceFaceOmission$" + +[[check]] +requirement = "prepared_batch_recovery_only_runtime_authority" +polarity = "positive" +kind = "pytest" +path = "tests/python/architecture/test_variable_recovery_consumer_cutover.py" +test = "test_runtime_materialization_consumes_only_prepared_batch_before_publication" + +[[check]] +requirement = "prepared_batch_recovery_only_runtime_authority" +polarity = "refusal" +kind = "pytest" +path = "tests/python/architecture/test_variable_recovery_consumer_cutover.py" +test = "test_runtime_materialization_has_no_pointwise_compatibility_authority" + +[[check]] +requirement = "prepared_batch_recovery_only_runtime_authority" +polarity = "refusal" +target = "test_facade_routing" +test_regex = "^FacadeRouting\\.PrimitiveMaterializationRefusesMissingPreparedBatchAuthority$" diff --git a/tests/gates/m2_temporal_execution.toml b/tests/gates/m2_temporal_execution.toml index e23a75689..ad56d793d 100644 --- a/tests/gates/m2_temporal_execution.toml +++ b/tests/gates/m2_temporal_execution.toml @@ -276,3 +276,43 @@ polarity = "refusal" kind = "pytest" target = "transaction" nodeid = "tests/python/unit/runtime/test_temporal_restart_state.py::test_rejection_preserves_native_cursor_and_makes_checkpoint_ineligible" + +[[check]] +issue = "ADC-667" +requirement = "temporal_restart" +polarity = "positive" +kind = "pytest" +target = "schedule" +nodeid = "tests/python/unit/time/test_multirate_history_contract.py::test_history_interpolation_is_an_explicit_cross_clock_provider" + +[[check]] +issue = "ADC-667" +requirement = "temporal_restart" +polarity = "positive" +kind = "pytest" +target = "restart" +nodeid = "tests/python/unit/runtime/test_temporal_restart_state.py::test_uniform_child_clock_history_owns_exact_slot_ledger_across_restart" + +[[check]] +issue = "ADC-667" +requirement = "temporal_restart" +polarity = "refusal" +kind = "pytest" +target = "schedule" +nodeid = "tests/python/unit/time/test_multirate_history_contract.py::test_cross_clock_extension_without_provider_is_rejected" + +[[check]] +issue = "ADC-667" +requirement = "temporal_restart" +polarity = "positive" +kind = "pytest" +target = "restart" +nodeid = "tests/python/unit/codegen/test_checkpoint_migration.py::test_true_frozen_v2_migrates_and_strict_uniform_restart_accepts" + +[[check]] +issue = "ADC-667" +requirement = "temporal_restart" +polarity = "refusal" +kind = "pytest" +target = "restart" +nodeid = "tests/python/unit/runtime/test_temporal_restart_state.py::test_frozen_release_v2_fixture_is_refused_offline_and_at_runtime_boundary" diff --git a/tests/gates/m3_amr_multilayout.toml b/tests/gates/m3_amr_multilayout.toml index 87d64ad91..7560fe6de 100644 --- a/tests/gates/m3_amr_multilayout.toml +++ b/tests/gates/m3_amr_multilayout.toml @@ -198,6 +198,22 @@ kind = "pytest" target = "accepted_state" nodeid = "tests/python/integration/amr/test_amr_regrid_on_restart.py::test_regrid_on_restart_changes_real_boxes_and_rolls_back_post_regrid_fault" +[[check]] +issue = "ADC-678" +requirement = "accepted_state" +polarity = "positive" +kind = "pytest" +target = "accepted_state" +nodeid = "tests/python/integration/amr/test_amr_composite_field_carrier.py::test_fac_overrides_propagate_through_a_refined_final_root_lifecycle" + +[[check]] +issue = "ADC-678" +requirement = "accepted_state" +polarity = "refusal" +kind = "pytest" +target = "accepted_state" +nodeid = "tests/python/unit/amr/test_external_amr_providers.py::test_external_tagger_requires_exact_candidate_program_capability" + [[check]] issue = "ADC-678" requirement = "accepted_state" diff --git a/tests/gates/m4_runtime_io.toml b/tests/gates/m4_runtime_io.toml new file mode 100644 index 000000000..fc1ab7d91 --- /dev/null +++ b/tests/gates/m4_runtime_io.toml @@ -0,0 +1,462 @@ +schema_version = 1 +gate = "m4-runtime-io" +issues = [ + "ADC-679", + "ADC-680", + "ADC-681", + "ADC-682", + "ADC-683", + "ADC-684", + "ADC-685", + "ADC-686", + "ADC-687", +] + +deferred = [] + +# This is an exact evidence ledger, not a list of nearby suites. Every row names +# one source-registered proof. The runner rejects mock fixtures/imports, +# optional imports, skip/xfail, non-exact CTest selectors, duplicate proofs, +# and missing manifest ownership before it launches anything. + +[[check]] +issue = "ADC-679" +requirement = "component_manifest" +polarity = "positive" +kind = "pytest" +target = "component_manifest" +nodeid = "tests/python/unit/codegen/test_component_manifest_v2.py::test_native_parser_normalizer_matches_python_canonical_bytes" + +[[check]] +issue = "ADC-679" +requirement = "component_manifest" +polarity = "refusal" +kind = "pytest" +target = "component_manifest" +nodeid = "tests/python/unit/codegen/test_component_manifest_v2.py::test_unknown_semantic_top_level_field_is_a_structured_refusal" + +[[check]] +issue = "ADC-679" +requirement = "component_manifest" +polarity = "refusal" +kind = "pytest" +target = "component_manifest" +nodeid = "tests/python/unit/codegen/test_component_manifest_v2.py::test_interface_bindings_are_exact_closed_and_entry_point_checked" + +[[check]] +issue = "ADC-679" +requirement = "generated_registry" +polarity = "positive" +kind = "ctest" +target = "generated_registry@test_brick_catalog" +test_regex = "^BrickCatalog\\.MirrorsRegistryAndRouteTablesRowForRow$" + +[[check]] +issue = "ADC-679" +requirement = "generated_registry" +polarity = "refusal" +kind = "pytest" +target = "generated_registry" +nodeid = "tests/python/architecture/test_route_registry_parity.py::test_no_unknown_fields_can_hide_in_catalog_rows" + +[[check]] +issue = "ADC-680" +requirement = "external_package" +polarity = "positive" +kind = "pytest" +target = "external_package" +nodeid = "tests/python/unit/codegen/test_component_packages.py::test_source_package_verifies_content_before_authoring_registry" + +[[check]] +issue = "ADC-680" +requirement = "external_package" +polarity = "refusal" +kind = "pytest" +target = "external_package" +nodeid = "tests/python/unit/codegen/test_component_packages.py::test_tampered_manifest_and_retained_source_are_rejected_at_phase_boundaries" + +[[check]] +issue = "ADC-680" +requirement = "external_flux" +polarity = "positive" +kind = "pytest" +target = "external_flux" +nodeid = "tests/python/integration/native_loader/test_external_component_package.py::test_source_component_executes_through_generic_native_loader_and_flux_consumer" + +[[check]] +issue = "ADC-680" +requirement = "tamper_capability_abi" +polarity = "refusal" +kind = "pytest" +target = "tamper_capability_abi" +nodeid = "tests/python/unit/codegen/test_component_packages.py::test_fixed_binary_cannot_claim_template_genericity" + +[[check]] +issue = "ADC-681" +requirement = "native_interfaces" +polarity = "positive" +kind = "ctest" +target = "native_interfaces@test_component_interfaces" +test_regex = "^ComponentInterfaces\\.ExactAbiConsumersExecuteEveryClosedScientificFamily$" + +[[check]] +issue = "ADC-681" +requirement = "native_interfaces" +polarity = "refusal" +kind = "pytest" +target = "native_interfaces" +nodeid = "tests/python/unit/codegen/test_component_adapters.py::test_registration_rejects_malformed_interface_and_target_before_mutation" + +[[check]] +issue = "ADC-681" +requirement = "external_boundary" +polarity = "positive" +kind = "ctest" +target = "external_boundary@test_amr_native_loader" +test_regex = "^test_amr_native_loader\\.BoundaryPlanSessionsOwnFreshLaneQualifiedComponentStates$" + +[[check]] +issue = "ADC-681" +requirement = "external_tagger" +polarity = "positive" +kind = "ctest" +target = "external_tagger@test_amr_native_loader" +test_regex = "^test_amr_native_loader\\.PreparedAmrProvidersExecuteExactTablesAndProvenance$" + +[[check]] +issue = "ADC-682" +requirement = "flux_contract" +polarity = "positive" +kind = "ctest" +target = "flux_contract@test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.equal_state_consistency_and_declared_stability$" + +[[check]] +issue = "ADC-682" +requirement = "flux_contract" +polarity = "refusal" +kind = "ctest" +target = "flux_contract@test_flux_interfaces" +test_regex = "^test_flux_interfaces\\.invalid_trace_stability_is_rejected_on_both_orientations$" + +[[check]] +issue = "ADC-683" +requirement = "platform_execution" +polarity = "positive" +kind = "ctest" +target = "platform_execution@test_platform_manifest" +test_regex = "^PlatformManifest\\.GenericTwoDimensionalDoubleRouteLaunches$" + +[[check]] +issue = "ADC-683" +requirement = "platform_execution" +polarity = "refusal" +kind = "ctest" +target = "platform_execution@test_platform_manifest" +test_regex = "^PlatformManifest\\.FieldAndCommunicatorMismatchesRefuseBeforeKernel$" + +[[check]] +issue = "ADC-683" +requirement = "tamper_capability_abi" +polarity = "refusal" +kind = "ctest" +target = "tamper_capability_abi@test_platform_manifest" +test_regex = "^PlatformManifest\\.UnknownCapabilityRefusesBeforeKernel$" + +[[check]] +issue = "ADC-684" +requirement = "runtime_instance" +polarity = "positive" +kind = "pytest" +target = "runtime_instance" +nodeid = "tests/python/integration/runtime/test_shared_interface_runtime.py::test_runtime_instance_executes_one_two_sided_shared_flux" + +[[check]] +issue = "ADC-684" +requirement = "runtime_instance" +polarity = "positive" +kind = "pytest" +target = "runtime_instance" +nodeid = "tests/python/integration/runtime/test_multi_layout_runtime.py::test_uniform_amr_and_multi_layout_share_complete_runtime_instance_contract" + +[[check]] +issue = "ADC-684" +requirement = "runtime_instance" +polarity = "refusal" +kind = "pytest" +target = "runtime_instance" +nodeid = "tests/python/integration/native_loader/test_external_field_solver_runtime.py::test_real_prepared_field_solver_failure_rolls_back_runtime_instance_and_retries" + +[[check]] +issue = "ADC-684" +requirement = "external_transfer" +polarity = "positive" +kind = "pytest" +target = "external_transfer" +nodeid = "tests/python/integration/runtime/test_multi_layout_runtime.py::test_two_native_layouts_execute_sliced_programs_and_exact_transfer" + +[[check]] +issue = "ADC-685" +requirement = "external_writer" +polarity = "positive" +kind = "pytest" +target = "external_writer" +nodeid = "tests/python/integration/native_loader/test_external_component_package.py::test_qualified_writer_runs_through_uniform_and_amr_runtime_transactions" + +[[check]] +issue = "ADC-685" +requirement = "consumer_graph" +polarity = "positive" +kind = "pytest" +target = "consumer_graph" +nodeid = "tests/python/unit/runtime/test_consumer_transactions.py::test_graph_and_plan_are_semantic_and_insertion_order_independent" + +[[check]] +issue = "ADC-685" +requirement = "consumer_graph" +polarity = "refusal" +kind = "pytest" +target = "consumer_graph" +nodeid = "tests/python/integration/native_loader/test_external_component_package.py::test_real_writer_collision_compensates_the_complete_consumer_graph_transaction" + +[[check]] +issue = "ADC-685" +requirement = "accepted_publication" +polarity = "positive" +kind = "pytest" +target = "accepted_publication" +nodeid = "tests/python/examples/final/test_imex_amr_final_example.py::test_example_runs_and_every_scientific_format_reopens" + +[[check]] +issue = "ADC-685" +requirement = "accepted_publication" +polarity = "refusal" +kind = "pytest" +target = "accepted_publication" +nodeid = "tests/python/unit/runtime/test_consumer_transactions.py::test_stale_field_requires_explicit_policy_and_records_recompute_without_solving" + +[[check]] +issue = "ADC-686" +requirement = "exact_npz" +polarity = "positive" +kind = "pytest" +target = "exact_npz" +nodeid = "tests/python/integration/io/m4_native_reopen_proof.py::test_npz_reopens_with_numpy_without_a_pops_reader" + +[[check]] +issue = "ADC-686" +requirement = "exact_npz" +polarity = "refusal" +kind = "pytest" +target = "exact_npz" +nodeid = "tests/python/unit/output/test_exact_writers.py::test_npz_collision_and_discard_never_publish_partial_content" + +[[check]] +issue = "ADC-686" +requirement = "exact_hdf5" +polarity = "positive" +kind = "pytest" +target = "exact_hdf5" +nodeid = "tests/python/integration/io/m4_native_reopen_proof.py::test_hdf5_reopens_with_h5py_without_a_pops_reader" + +[[check]] +issue = "ADC-686" +requirement = "exact_hdf5" +polarity = "refusal" +kind = "pytest" +target = "exact_hdf5" +nodeid = "tests/python/integration/io/m4_native_reopen_proof.py::test_hdf5_authenticated_reader_rejects_native_dataset_tampering" + +[[check]] +issue = "ADC-686" +requirement = "exact_paraview" +polarity = "positive" +kind = "pytest" +target = "exact_paraview" +nodeid = "tests/python/integration/io/m4_native_reopen_proof.py::test_paraview_reopens_with_vtk_without_a_pops_reader" + +[[check]] +issue = "ADC-686" +requirement = "exact_paraview" +polarity = "positive" +kind = "mpi_python" +target = "exact_paraview" +nodeid = "tests/python/integration/mpi/test_scientific_output_mpi.py::_validate_paraview" +nproc = 2 + +[[check]] +issue = "ADC-686" +requirement = "exact_paraview" +polarity = "refusal" +kind = "pytest" +target = "exact_paraview" +nodeid = "tests/python/unit/output/test_exact_writers.py::test_paraview_rejects_inconsistent_logical_field_family_levels" + +[[check]] +issue = "ADC-686" +requirement = "collective_hdf5" +polarity = "positive" +kind = "ctest" +target = "collective_hdf5@test_mpi_hdf5_collective" +test_regex = "^test_mpi_hdf5_collective_np2$" + +[[check]] +issue = "ADC-686" +requirement = "strict_checkpoint" +polarity = "positive" +kind = "pytest" +target = "strict_checkpoint" +nodeid = "tests/python/integration/runtime/test_multi_layout_runtime.py::test_multi_layout_checkpoint_restart_restores_every_layout_and_mapping_count" + +[[check]] +issue = "ADC-686" +requirement = "strict_checkpoint" +polarity = "refusal" +kind = "pytest" +target = "strict_checkpoint" +nodeid = "tests/python/integration/amr/test_amr_regrid_on_restart.py::test_authenticated_amr_contract_refusal_rolls_back_native_restart_transaction" + +[[check]] +issue = "ADC-686" +requirement = "diagnostics" +polarity = "positive" +kind = "ctest" +target = "diagnostics@test_program_context_contract" +test_regex = "^ProgramContextContract\\.AcceptedBalanceEvidenceIsCurrentAttemptExactAndFailClosed$" + +[[check]] +issue = "ADC-686" +requirement = "diagnostics" +polarity = "positive" +kind = "ctest" +target = "diagnostics@test_program_runtime" +test_regex = "^ProgramRuntime\\.SelectedAutomaticBalanceTermsRequireCompleteQualifiedEvidence$" + +[[check]] +issue = "ADC-686" +requirement = "diagnostics" +polarity = "refusal" +kind = "pytest" +target = "diagnostics" +nodeid = "tests/python/unit/output/test_exact_writers.py::test_composite_integrals_refuses_non_cartesian_cell_measure" + +[[check]] +issue = "ADC-687" +requirement = "gate_execution" +polarity = "positive" +kind = "pytest" +target = "gate_execution" +nodeid = "tests/python/architecture/test_m4_runtime_io_gate.py::test_m4_required_ci_lane_executes_the_complete_installed_gate" + +[[check]] +issue = "ADC-687" +requirement = "external_solver" +polarity = "positive" +kind = "pytest" +target = "external_solver" +nodeid = "tests/python/integration/native_loader/test_external_field_solver_runtime.py::test_external_field_pair_executes_and_reports_materialized_topology" + +[[check]] +issue = "ADC-687" +requirement = "external_solver" +polarity = "positive" +kind = "pytest" +target = "external_solver" +nodeid = "tests/python/integration/native_loader/test_external_field_solver_runtime.py::test_external_field_pair_executes_binary_coverage_across_amr_regrid" + +[[check]] +issue = "ADC-687" +requirement = "external_solver" +polarity = "positive" +kind = "mpi_python" +target = "external_solver" +nodeid = "tests/python/integration/mpi/test_external_amr_field_solver_mpi.py::test_external_amr_field_bridge_executes_and_refuses_collectively" +nproc = 2 + +[[check]] +issue = "ADC-687" +requirement = "tamper_capability_abi" +polarity = "refusal" +kind = "ctest" +target = "tamper_capability_abi@test_native_loader_param_overflow" +test_regex = "^test_native_loader_param_overflow\\.Runs$" + +[[check]] +issue = "ADC-687" +requirement = "tamper_capability_abi" +polarity = "refusal" +kind = "ctest" +target = "tamper_capability_abi@test_amr_native_loader" +test_regex = "^test_amr_native_loader\\.RefusesComponentBuiltForAnotherNativeAbi$" + +[[check]] +issue = "ADC-687" +requirement = "tamper_capability_abi" +polarity = "refusal" +kind = "pytest" +target = "tamper_capability_abi" +nodeid = "tests/python/unit/codegen/test_component_packages.py::test_fixed_binary_bytes_are_authenticated_before_package_use" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/architecture/test_no_schur_header_leak.py::test_native_source_stage_headers_are_retired" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/architecture/test_program_only_temporal_facades.py::test_system_temporal_facades_dispatch_only_through_an_installed_program" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/architecture/test_program_only_temporal_facades.py::test_amr_temporal_facades_use_amr_runtime_only_as_the_spatial_engine" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/architecture/test_program_only_temporal_facades.py::test_historical_block_scheduler_is_not_an_installed_temporal_authority" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/architecture/test_program_only_temporal_facades.py::test_production_has_no_second_amr_time_engine" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/architecture/test_component_interface_dispatch.py::test_component_trust_boundary_never_classifies_the_scientific_component_type" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/architecture/test_component_interface_dispatch.py::test_native_registry_has_no_rtti_or_untyped_capability_escape_hatch" + +[[check]] +issue = "ADC-687" +requirement = "legacy_stepper_retirement" +polarity = "positive" +kind = "pytest" +target = "legacy_stepper_retirement" +nodeid = "tests/python/unit/codegen/test_component_adapters.py::test_native_interface_is_declared_and_unbound_never_falls_back" diff --git a/tests/gpu/romeo/amrmpi_integrated.cpp b/tests/gpu/romeo/amrmpi_integrated.cpp index 039a04ec5..859a50a4b 100644 --- a/tests/gpu/romeo/amrmpi_integrated.cpp +++ b/tests/gpu/romeo/amrmpi_integrated.cpp @@ -62,7 +62,8 @@ static void install_forward_euler_program(AmrSystem& system) { context->install([context](double macro_dt) { context->advance_hierarchy(macro_dt, [context](double level_dt) { context->set_stage_time(0, 1); - (void)consume_solve_outcome(context->solve_fields()); + if (context->level() == 0) + (void)consume_solve_outcome(context->solve_default_field_on_coarse_level()); std::vector states; std::vector residuals; diff --git a/tests/gpu/romeo/gpu_aux_validate.cpp b/tests/gpu/romeo/gpu_aux_validate.cpp index 857c95beb..7b6434bdb 100644 --- a/tests/gpu/romeo/gpu_aux_validate.cpp +++ b/tests/gpu/romeo/gpu_aux_validate.cpp @@ -49,8 +49,8 @@ struct TeProbe { using Aux = pops::Aux; static constexpr int n_vars = 1; static constexpr int n_aux = 5; // phi, grad_x, grad_y, B_z, T_e - POPS_HD State flux(const State&, const Aux&, int) const { return State{Real(0)}; } - POPS_HD Real max_wave_speed(const State&, const Aux&, int) const { return Real(0); } + POPS_HD State flux(const State&, const auto&, int) const { return State{Real(0)}; } + POPS_HD Real max_wave_speed(const State&, const auto&, int) const { return Real(0); } POPS_HD State source(const State& u, const Aux& a) const { State s{}; s[0] = a.T_e * u[0]; // lit la composante aux 4 (T_e) diff --git a/tests/python/architecture/test_adc757_heterogeneous_assembler.py b/tests/python/architecture/test_adc757_heterogeneous_assembler.py new file mode 100644 index 000000000..b0ddab2ea --- /dev/null +++ b/tests/python/architecture/test_adc757_heterogeneous_assembler.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] + + +def _module(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _runtime_mode(mode: str, backend: str, ranks: int, token: str) -> dict: + return { + "id": mode, + "scenario_id": "adc757_amr_advection_runtime_v1", + "installation": { + "wheel_name": f"pops-{mode}.whl", + "wheel_sha256": token * 64, + "installed_tree_sha256": "e" * 64, + "native_sha256": "f" * 64, + "package_file": f"/opt/pops-{mode}/pops/__init__.py", + "native_extension": f"/opt/pops-{mode}/pops/_pops.so", + "python_executable": f"/opt/pops-{mode}/bin/python", + "outside_source_checkout": True, + }, + "doctor": {"passed": True, "checks_sha256": "d" * 64}, + "artifact": { + "identity": f"artifact:{mode}", + "abi_key": f"abi:{mode}", + "module_abi_key": f"module:{mode}", + "abi_compatible": True, + }, + "execution": { + "backend": backend, + "mpi_ranks": ranks, + "accepted_steps": 4, + "final_time": 0.04, + "solution_sha256": "a" * 64, + }, + } + + +def _runtime_evidence() -> dict: + modes = [ + _runtime_mode("serial", "Serial", 1, "1"), + _runtime_mode("threaded", "OpenMP", 1, "2"), + _runtime_mode("gpu", "Cuda", 1, "3"), + _runtime_mode("gpu_mpi", "Cuda", 2, "4"), + ] + gpu_mpi = modes[-1]["artifact"] + common = { + "consumed": True, + "artifact_identity": gpu_mpi["identity"], + "abi_key": gpu_mpi["abi_key"], + } + return { + "schema": "pops.adc757.installed-runtime-matrix.v1", + "status": "passed", + "revision": "candidate", + "scenario_id": "adc757_amr_advection_runtime_v1", + "modes": modes, + "authorities": { + "cell_local_time": { + **common, + "identity": "pops.local-time.runtime@1", + "accepted_steps": 4, + "fallback_count": 0, + "receipt_sha256": "b" * 64, + }, + "amr_rebalance_migration": { + **common, + "identity": "pops.amr.rebalance.runtime@1", + "moved_patches": 2, + "migration_bytes": 4096, + "post_migration_steps": 2, + "receipt_sha256": "c" * 64, + }, + }, + } + + +def _runtime_digest() -> str: + payload = json.dumps( + _runtime_evidence(), sort_keys=True, separators=(",", ":"), ensure_ascii=True + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _measurement(scenario: str, route: str, time: float) -> dict: + candidate = route == "candidate" + local_time = scenario == "prepared_local_time" + work = 700.0 if local_time and candidate else 1000.0 + migration_bytes = 100_000.0 if not local_time and candidate else 0.0 + migration_seconds = 0.02 if not local_time and candidate else 0.0 + return { + "schema": "pops.adc757.heterogeneous-numerics.measurement.v1", + "status": "passed", + "revision": "candidate", + "build_identity": "nvcc_wrapper-Cuda", + "installed_wheel_sha256": "4" * 64, + "module_abi_sha256": hashlib.sha256(b"module:gpu_mpi").hexdigest(), + "runtime_evidence_sha256": _runtime_digest(), + "execution_space": "Cuda", + "mpi_ranks": 2, + "scenario": scenario, + "route": route, + "device_assignments": [ + {"rank": 0, "uuid": "GPU-0"}, + {"rank": 1, "uuid": "GPU-1"}, + ], + "streams": { + "identities": ["cuda:instance=2:lane=0", "cuda:instance=3:lane=1"], + "correctness_parity": True, + "overlap_observed": True, + "workspace_disjoint": True, + }, + "metrics": { + "time_to_solution_seconds": time, + "throughput_cell_updates_per_second": 1300.0 if candidate else 1000.0, + "memory_traffic_bytes": 1_000_000.0, + "kernel_launches": 18.0 if candidate else 32.0, + "task_count": 20.0, + "communication_bytes": migration_bytes, + "communication_seconds": migration_seconds, + "fallback_count": 0.0, + "useful_work_cell_updates": work, + "imbalance_ratio": 1.1 if candidate else 1.8, + "migration_bytes": migration_bytes, + "migration_seconds": migration_seconds, + }, + "correctness": { + "passed": True, + "mass_error": 0.0, + "restart_max_error": 0.0, + "rollback_max_error": 0.0, + "ledger_balance_error": 0.0, + }, + } + + +def _measurements() -> list[dict]: + values: list[dict] = [] + for scenario in ("prepared_local_time", "cost_aware_load_balance"): + for _ in range(5): + values.extend( + [ + _measurement(scenario, "baseline", 1.0), + _measurement(scenario, "candidate", 0.7), + _measurement(scenario, "candidate", 0.7), + _measurement(scenario, "baseline", 1.0), + ] + ) + return values + + +def test_adc757_assembler_builds_a_report_accepted_by_the_independent_verifier() -> None: + assembler = _module(ROOT / "benchmarks" / "adc757" / "assemble.py", "adc757_assemble") + verifier = _module(ROOT / "benchmarks" / "adc757" / "verify.py", "adc757_verify") + report = assembler.assemble( + _measurements(), + revision="candidate", + minimum_speedup=1.01, + runtime_evidence=_runtime_evidence(), + ) + assert verifier.validate(report, expected_revision="candidate")["status"] == "passed" + + +def test_adc757_assembler_refuses_measurements_that_are_not_abba_ordered() -> None: + assembler = _module(ROOT / "benchmarks" / "adc757" / "assemble.py", "adc757_assemble_bad") + measurements = _measurements() + measurements[1], measurements[2] = measurements[2], measurements[1] + measurements[1]["route"] = "baseline" + with pytest.raises(assembler.AssemblyError, match="A,B,B,A"): + assembler.assemble( + measurements, + revision="candidate", + minimum_speedup=1.01, + runtime_evidence=_runtime_evidence(), + ) + + +def test_adc757_assembler_refuses_a_nonpassing_installed_runtime() -> None: + assembler = _module(ROOT / "benchmarks" / "adc757" / "assemble.py", "adc757_refusal") + evidence = _runtime_evidence() + evidence["status"] = "refused" + with pytest.raises(assembler.AssemblyError, match="did not pass"): + assembler.assemble( + _measurements(), + revision="candidate", + minimum_speedup=1.01, + runtime_evidence=evidence, + ) diff --git a/tests/python/architecture/test_adc757_heterogeneous_campaign.py b/tests/python/architecture/test_adc757_heterogeneous_campaign.py new file mode 100644 index 000000000..af38d2f41 --- /dev/null +++ b/tests/python/architecture/test_adc757_heterogeneous_campaign.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +import tomllib + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +VERIFY = ROOT / "benchmarks" / "adc757" / "verify.py" +PROBE = ROOT / "benchmarks" / "adc757" / "runtime_probe.py" + + +def _load_module(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _module(): + return _load_module(VERIFY, "pops_adc757_hardware_verify") + + +def _metrics( + *, + time: float, + throughput: float, + work: float, + imbalance: float, + migration_bytes: float = 0.0, + migration_seconds: float = 0.0, +) -> dict: + return { + "time_to_solution_seconds": time, + "throughput_cell_updates_per_second": throughput, + "memory_traffic_bytes": 1_000_000.0, + "kernel_launches": 40, + "task_count": 20, + "communication_bytes": 10_000.0, + "communication_seconds": 0.01, + "fallback_count": 0, + "useful_work_cell_updates": work, + "imbalance_ratio": imbalance, + "migration_bytes": migration_bytes, + "migration_seconds": migration_seconds, + } + + +def _correctness() -> dict: + return { + "passed": True, + "mass_error": 0.0, + "restart_max_error": 0.0, + "rollback_max_error": 0.0, + "ledger_balance_error": 0.0, + } + + +def _runtime_mode(mode: str, backend: str, ranks: int, token: str) -> dict: + artifact_identity = f"artifact:{mode}" + abi_key = f"abi:{mode}" + return { + "id": mode, + "scenario_id": "adc757_amr_advection_runtime_v1", + "installation": { + "wheel_name": f"pops-{mode}.whl", + "wheel_sha256": token * 64, + "installed_tree_sha256": "e" * 64, + "native_sha256": "f" * 64, + "package_file": f"/opt/pops-{mode}/pops/__init__.py", + "native_extension": f"/opt/pops-{mode}/pops/_pops.so", + "python_executable": f"/opt/pops-{mode}/bin/python", + "outside_source_checkout": True, + }, + "doctor": {"passed": True, "checks_sha256": "d" * 64}, + "artifact": { + "identity": artifact_identity, + "abi_key": abi_key, + "module_abi_key": f"module:{mode}", + "abi_compatible": True, + }, + "execution": { + "backend": backend, + "mpi_ranks": ranks, + "accepted_steps": 4, + "final_time": 0.04, + "solution_sha256": "a" * 64, + }, + } + + +def _installed_runtime() -> dict: + modes = [ + _runtime_mode("serial", "Serial", 1, "1"), + _runtime_mode("threaded", "OpenMP", 1, "2"), + _runtime_mode("gpu", "Cuda", 1, "3"), + _runtime_mode("gpu_mpi", "Cuda", 2, "4"), + ] + gpu_mpi = modes[-1]["artifact"] + common = { + "consumed": True, + "artifact_identity": gpu_mpi["identity"], + "abi_key": gpu_mpi["abi_key"], + } + return { + "schema": "pops.adc757.installed-runtime-matrix.v1", + "status": "passed", + "revision": "candidate", + "scenario_id": "adc757_amr_advection_runtime_v1", + "modes": modes, + "authorities": { + "cell_local_time": { + **common, + "identity": "pops.local-time.runtime@1", + "accepted_steps": 4, + "fallback_count": 0, + "receipt_sha256": "b" * 64, + }, + "amr_rebalance_migration": { + **common, + "identity": "pops.amr.rebalance.runtime@1", + "moved_patches": 2, + "migration_bytes": 4096, + "post_migration_steps": 2, + "receipt_sha256": "c" * 64, + }, + }, + } + + +def _runtime_digest(runtime: dict) -> str: + payload = json.dumps(runtime, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _report() -> dict: + runtime = _installed_runtime() + gpu_mpi = runtime["modes"][-1] + return { + "schema": "pops.adc757.heterogeneous-numerics.v1", + "status": "passed", + "provenance": { + "revision": "candidate", + "build_identity": "headers+compiler+flags", + "installed_wheel_sha256": gpu_mpi["installation"]["wheel_sha256"], + "module_abi_sha256": hashlib.sha256( + gpu_mpi["artifact"]["module_abi_key"].encode("utf-8") + ).hexdigest(), + "runtime_evidence_sha256": _runtime_digest(runtime), + "mpi_ranks": 2, + "topology_identity": "two-level-amr-two-rank", + "timestamp_utc": "2026-08-03T00:00:00Z", + }, + "protocol": { + "ordering": "ABBA", + "clock": "steady_clock", + "device_fence": "before_and_after", + "mpi_barrier": "before_and_after", + "rank_aggregation": "max", + "warmups": 2, + }, + "device": { + "execution_space": "Cuda", + "assignments": [ + {"rank": 0, "uuid": "GPU-0"}, + {"rank": 1, "uuid": "GPU-1"}, + ], + }, + "streams": { + "identities": ["cuda:stream:0", "cuda:stream:1"], + "correctness_parity": True, + "overlap_observed": True, + "workspace_disjoint": True, + }, + "installed_runtime": runtime, + "scenarios": [ + { + "id": "prepared_local_time", + "baseline": _metrics(time=1.0, throughput=100.0, work=1000, imbalance=1.6), + "candidate": _metrics(time=0.8, throughput=125.0, work=700, imbalance=1.3), + "correctness": _correctness(), + "minimum_speedup": 1.02, + "abba_time_to_solution_seconds": [[1.0, 0.8, 0.8, 1.0] for _ in range(5)], + }, + { + "id": "cost_aware_load_balance", + "baseline": _metrics(time=1.0, throughput=100.0, work=1000, imbalance=1.8), + "candidate": _metrics( + time=0.75, + throughput=133.0, + work=1000, + imbalance=1.1, + migration_bytes=100_000, + migration_seconds=0.02, + ), + "correctness": _correctness(), + "minimum_speedup": 1.02, + "abba_time_to_solution_seconds": [[1.0, 0.75, 0.75, 1.0] for _ in range(5)], + }, + ], + } + + +def _make_local_time_slow(report: dict) -> None: + scenario = report["scenarios"][0] + scenario["candidate"]["time_to_solution_seconds"] = 1.1 + scenario["abba_time_to_solution_seconds"] = [[1.0, 1.1, 1.1, 1.0] for _ in range(5)] + + +def test_adc757_hardware_report_accepts_complete_device_evidence() -> None: + module = _module() + assert module.validate(_report(), expected_revision="candidate")["status"] == "passed" + + +def test_adc757_installed_probe_never_promotes_header_presence_to_runtime_evidence() -> None: + probe = _load_module(PROBE, "pops_adc757_runtime_probe") + refusal = probe.refusal_payload( + revision="candidate", + installation={"wheel_sha256": "a" * 64}, + module_abi_key="module-abi", + doctor={"passed": True, "checks_sha256": "b" * 64}, + support={ + "cell_local_time_commit_receipt_primitives": True, + "amr_rebalance_migration_primitives": True, + }, + ) + assert refusal["status"] == "refused" + assert [item["code"] for item in refusal["blockers"]] == [ + "installed_runtime_matrix_receipts_unavailable" + ] + assert "vector kernels are not a PoPS runtime proof" in refusal["blockers"][0]["detail"] + + +def test_adc757_installed_probe_names_missing_c_and_g_routes_exactly() -> None: + probe = _load_module(PROBE, "pops_adc757_runtime_probe_blockers") + refusal = probe.refusal_payload( + revision="candidate", + installation={"wheel_sha256": "a" * 64}, + module_abi_key="module-abi", + doctor={"passed": True, "checks_sha256": "b" * 64}, + support={ + "cell_local_time_commit_receipt_primitives": False, + "amr_rebalance_migration_primitives": False, + }, + ) + assert [item["code"] for item in refusal["blockers"]] == [ + "adc757g_local_time_runtime_unavailable", + "adc757c_amr_migration_runtime_unavailable", + "installed_runtime_matrix_receipts_unavailable", + ] + + +def test_adc757_installed_probe_refuses_false_authority_receipts_before_abba() -> None: + probe = _load_module(PROBE, "pops_adc757_runtime_probe_receipts") + matrix = _installed_runtime() + matrix["authorities"]["amr_rebalance_migration"]["consumed"] = False + gpu_mpi = matrix["modes"][-1] + installation = { + **gpu_mpi["installation"], + "revision": "candidate", + "version": "1.0.0", + } + with pytest.raises(probe.RuntimeProbeError, match="not consumed"): + probe._accept_external_matrix( + matrix, + revision="candidate", + installation=installation, + module_abi_key=gpu_mpi["artifact"]["module_abi_key"], + doctor=gpu_mpi["doctor"], + support={ + "cell_local_time_commit_receipt_primitives": True, + "amr_rebalance_migration_primitives": True, + }, + ) + + +def test_adc757_romeo_driver_uses_only_the_exact_installed_candidate() -> None: + cmake = (ROOT / "benchmarks" / "adc757" / "CMakeLists.txt").read_text(encoding="utf-8") + job = (ROOT / "benchmarks" / "romeo" / "adc757_heterogeneous_numerics.sbatch").read_text( + encoding="utf-8" + ) + assert "POPS_ADC757_INCLUDE_ROOT" in cmake + assert "pops_adc757_installed" in cmake + assert "find_package(Kokkos CONFIG REQUIRED)" in cmake + assert "find_package(MPI REQUIRED COMPONENTS CXX)" in cmake + assert "find_package(pops" not in cmake + assert "add_subdirectory" not in cmake + assert 'scripts/build_python.sh" --mpi' in job + assert "scripts/prove_installed_wheel.py" in job + assert "benchmarks/adc757/runtime_probe.py" in job + assert "toolchain.pops_include()" in job + assert '-DPOPS_ADC757_INCLUDE_ROOT="${POPS_INCLUDE_ROOT}"' in job + assert "--runtime-evidence-sha256" in job + assert job.index("runtime_probe.py") < job.index("for scenario in") + + +def test_adc757_campaign_manifest_requires_the_complete_hardware_contract() -> None: + manifest = tomllib.loads((ROOT / "benchmarks" / "manifest.toml").read_text(encoding="utf-8")) + campaign = manifest["campaigns"]["adc757_heterogeneous_numerics"] + assert campaign == { + "routine_ci": False, + "source": "benchmarks/adc757", + "report_schema": "pops.adc757.heterogeneous-numerics.v1", + "verifier": "benchmarks/adc757/verify.py", + "requires_real_device": True, + "requires_distinct_device_per_rank": True, + "minimum_mpi_ranks": 2, + "minimum_streams": 2, + "requires_stream_overlap": True, + "requires_restart_rollback_and_ledger_parity": True, + "requires_exact_installed_wheels": True, + "requires_pops_doctor": True, + "runtime_scenario": "adc757_amr_advection_runtime_v1", + "runtime_modes": ["serial", "threaded", "gpu", "gpu_mpi"], + "required_runtime_authorities": ["cell_local_time", "amr_rebalance_migration"], + "scenarios": ["prepared_local_time", "cost_aware_load_balance"], + "metrics": list(_metrics(time=1.0, throughput=1.0, work=1.0, imbalance=1.0)), + "job_script": "benchmarks/romeo/adc757_heterogeneous_numerics.sbatch", + "submit_script": "benchmarks/romeo/submit_adc757_heterogeneous_numerics.sh", + } + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda report: report["device"].update(execution_space="OpenMP"), "accelerator"), + ( + lambda report: report["streams"].update(identities=["cuda:stream:0", "cuda:stream:0"]), + "alias", + ), + (_make_local_time_slow, "speedup"), + ( + lambda report: report["scenarios"][1]["candidate"].update(imbalance_ratio=2.0), + "imbalance", + ), + ( + lambda report: report["scenarios"][0]["correctness"].update(restart_max_error=1.0), + "restart_max_error", + ), + ( + lambda report: report["installed_runtime"]["modes"][0]["doctor"].update(passed=False), + "doctor", + ), + ( + lambda report: report["installed_runtime"]["modes"][1]["execution"].update( + solution_sha256="9" * 64 + ), + "same solution", + ), + ( + lambda report: report["installed_runtime"]["authorities"]["cell_local_time"].update( + consumed=False + ), + "not consumed", + ), + ], +) +def test_adc757_hardware_report_refuses_false_closure(mutation, message: str) -> None: + module = _module() + report = _report() + mutation(report) + with pytest.raises(module.EvidenceError, match=message): + module.validate(report, expected_revision="candidate") diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py new file mode 100644 index 000000000..567af1e40 --- /dev/null +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -0,0 +1,635 @@ +"""Source-only integrity checks for the bounded ADC-757 numerical gate.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +MANIFEST = ROOT / "tests/gates/adc757_prepared_numerics.toml" +RUNNER = ROOT / "scripts/run_adc757_prepared_numerics_gate.py" + + +def _load_runner(): + spec = importlib.util.spec_from_file_location("pops_run_adc757_gate", RUNNER) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_adc757_slice_references_exact_real_mandatory_native_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors, "ADC-757 slice matrix is invalid:\n " + "\n ".join(errors) + assert len(data["check"]) == 96 + assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS + assert data["evidence_from"] == [ + "ADC-682", + "ADC-711", + "ADC-733", + "ADC-737", + "ADC-749", + "ADC-750", + "ADC-751", + "ADC-752", + "ADC-753", + "ADC-754", + "ADC-755", + "ADC-756", + ] + assert runner.main(["--check-only"]) == 0 + + +def test_adc757_slice_executes_runtime_recovery_publication_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + claimed = { + "amr_bootstrap_recovery_publication", + "amr_history_recovery_publication", + "physical_boundary_trace_recovery_publication", + "terminal_source_recovery_publication", + } + rows = [row for row in data["check"] if row["requirement"] in claimed] + assert {row["requirement"] for row in rows} == claimed + assert {(row["requirement"], row["polarity"]) for row in rows} == { + (requirement, polarity) + for requirement in claimed + for polarity in ("positive", "refusal") + } + + +def test_adc757_slice_executes_new_prepared_recovery_policy_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + claimed = { + "prepared_riemann_recovery_policy", + "uniform_recovery_warm_start", + } + rows = [row for row in data["check"] if row["requirement"] in claimed] + assert {row["requirement"] for row in rows} == claimed + assert {(row["requirement"], row["polarity"]) for row in rows} == { + (requirement, polarity) + for requirement in claimed + for polarity in ("positive", "refusal") + } + + +def test_adc757_slice_executes_qualified_flux_provider_pack_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row for row in data["check"] if row["requirement"] == "qualified_flux_provider_pack" + ] == [ + { + "requirement": "qualified_flux_provider_pack", + "polarity": "positive", + "target": "test_flux_interfaces", + "test_regex": "^test_flux_interfaces\\." + "generated_provider_requirements_own_native_slot_reads$", + }, + { + "requirement": "qualified_flux_provider_pack", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/codegen/test_compiler_model_provider.py", + "test": "test_field_dependent_flux_without_provider_fails_before_native_source", + }, + ] + + +def test_adc757_slice_executes_post_riemann_boundary_flux_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row for row in data["check"] + if row["requirement"] == "post_riemann_boundary_flux" + ] == [ + { + "requirement": "post_riemann_boundary_flux", + "polarity": "positive", + "target": "test_amr_native_loader", + "test_regex": "^test_amr_native_loader\\." + "PostRiemannBoundaryFluxUsesOutwardOrientationAndPreservesCanonicalFaceStorage$", + }, + { + "requirement": "post_riemann_boundary_flux", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/mesh/test_boundary_topology_ports.py", + "test": "test_post_riemann_flux_refuses_wrong_component_route_or_output", + }, + ] + + +def test_adc757_slice_separates_mpi_executables_from_authenticated_hardware_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert data["deferred"] == list(runner.EXPECTED_DEFERRED) + assert "mpi_collective_execution" not in data["deferred"] + assert "gpu_backend_execution" not in data["deferred"] + assert "accelerator_stream_partitioning" not in data["deferred"] + assert "performance_baselines_and_end_to_end_benchmarks" not in data["deferred"] + assert "workspace_reentrancy_and_stream_partitioning" not in data["deferred"] + assert "remaining_legacy_recovery_and_boundary_authority_deletion" not in data["deferred"] + assert all("riemann_authority" not in family for family in data["deferred"]) + assert "runtime_consumer_cutover_and_legacy_deletion" not in data["deferred"] + assert "boundary_geometry_riemann_and_spatial_provider_families" not in data["deferred"] + assert "amr_regrid_migration_and_restart_coherence" not in data["deferred"] + assert "remaining_local_time_migration_and_load_balance_runtime_integration" not in data[ + "deferred" + ] + assert "remaining_multirank_multibox_amr_local_time_execution" in data["deferred"] + assert [ + row for row in data["check"] if row.get("kind") == "mpi_ctest" + ] == [ + { + "requirement": "mpi_collective_execution", + "polarity": "positive", + "kind": "mpi_ctest", + "target": "test_mpi_system_analytic_level_set", + "test_regex": "^test_mpi_system_analytic_level_set_np2$", + "nproc": 2, + }, + { + "requirement": "mpi_collective_execution", + "polarity": "refusal", + "kind": "mpi_ctest", + "target": "test_mpi_flux_failure_collective", + "test_regex": "^test_mpi_flux_failure_collective_np2$", + "nproc": 2, + }, + { + "requirement": "amr_rebalance_migration_and_restart_coherence", + "polarity": "positive", + "kind": "mpi_ctest", + "target": "test_mpi_amr_rebalance_migration", + "test_regex": "^test_mpi_amr_rebalance_migration_np2$", + "nproc": 2, + }, + { + "requirement": "amr_rebalance_migration_and_restart_coherence", + "polarity": "refusal", + "kind": "mpi_ctest", + "target": "test_mpi_amr_rebalance_migration", + "test_regex": "^test_mpi_amr_rebalance_migration_np4$", + "nproc": 4, + }, + { + "requirement": "bounded_cell_local_program_runtime", + "polarity": "refusal", + "kind": "mpi_ctest", + "target": "test_mpi_cell_temporal_program_refusal", + "test_regex": "^test_mpi_cell_temporal_program_refusal_np2$", + "nproc": 2, + }, + ] + assert data["hardware_evidence"] == runner.EXPECTED_HARDWARE_EVIDENCE + hardware_rows = [ + row + for row in data["check"] + if row["requirement"] in runner.EXPECTED_HARDWARE_REQUIREMENTS + ] + assert {(row["requirement"], row["polarity"]) for row in hardware_rows} == { + (requirement, "refusal") + for requirement in runner.EXPECTED_HARDWARE_REQUIREMENTS + } + assert all(row["polarity"] != "positive" for row in hardware_rows) + assert runner.main(["--check-only", "--closure"]) == 3 + + +def test_adc757_slice_includes_exact_public_measured_load_balance_policy_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + public_rows = [ + row + for row in data["check"] + if row.get("kind") == "pytest" + and row["requirement"] == "measured_load_balance_decision" + ] + assert public_rows == [ + { + "requirement": "measured_load_balance_decision", + "polarity": "positive", + "kind": "pytest", + "path": "tests/python/unit/amr/test_public_amr_resolution.py", + "test": "test_measured_knapsack_roundtrips_exact_native_decision_policy", + }, + { + "requirement": "measured_load_balance_decision", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/amr/test_public_amr_resolution.py", + "test": "test_measured_knapsack_rejects_invalid_decision_policy", + }, + ] + + +def test_adc757_slice_executes_rebalance_and_bounded_cell_local_runtime_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + + migration = [ + row + for row in data["check"] + if row["requirement"] == "amr_rebalance_migration_and_restart_coherence" + ] + assert [(row["polarity"], row.get("kind", "ctest"), row["target"]) for row in migration] == [ + ("positive", "mpi_ctest", "test_mpi_amr_rebalance_migration"), + ("refusal", "mpi_ctest", "test_mpi_amr_rebalance_migration"), + ("positive", "ctest", "test_program_reflux_ledger"), + ("refusal", "ctest", "test_program_reflux_ledger"), + ] + + cell_local = [ + row + for row in data["check"] + if row["requirement"] == "bounded_cell_local_program_runtime" + ] + assert [(row["polarity"], row.get("kind", "ctest")) for row in cell_local] == [ + ("positive", "ctest"), + ("refusal", "ctest"), + ("refusal", "mpi_ctest"), + ("positive", "pytest"), + ("refusal", "pytest"), + ] + + +def test_adc757_slice_authenticates_the_only_prepared_transport_boundary_authority(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + claimed = { + "prepared_boundary_plan_only_transport_authority", + "polar_persistent_prepared_boundary_plan", + } + assert [row for row in data["check"] if row["requirement"] in claimed] == [ + { + "requirement": "prepared_boundary_plan_only_transport_authority", + "polarity": "positive", + "kind": "pytest", + "path": "tests/python/architecture/" + "test_hyperbolic_boundary_authority_ratchet.py", + "test": "test_prepared_boundary_plan_is_the_only_native_transport_authority", + }, + { + "requirement": "prepared_boundary_plan_only_transport_authority", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/architecture/" + "test_hyperbolic_boundary_authority_ratchet.py", + "test": "test_legacy_transport_boundary_authorities_are_deleted", + }, + { + "requirement": "polar_persistent_prepared_boundary_plan", + "polarity": "positive", + "target": "test_polar_system_step", + "test_regex": "^PolarSystemStep\\." + "BoundProgramUsesPersistentPreparedBoundaryClosures$", + }, + { + "requirement": "polar_persistent_prepared_boundary_plan", + "polarity": "refusal", + "target": "test_polar_transport_mms", + "test_regex": "^test_polar_transport_mms\\." + "RejectsSharedInterfaceFaceOmission$", + }, + ] + + +def test_adc757_slice_authenticates_prepared_batch_as_the_only_recovery_authority(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row + for row in data["check"] + if row["requirement"] == "prepared_batch_recovery_only_runtime_authority" + ] == [ + { + "requirement": "prepared_batch_recovery_only_runtime_authority", + "polarity": "positive", + "kind": "pytest", + "path": "tests/python/architecture/" + "test_variable_recovery_consumer_cutover.py", + "test": "test_runtime_materialization_consumes_only_prepared_batch_before_" + "publication", + }, + { + "requirement": "prepared_batch_recovery_only_runtime_authority", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/architecture/" + "test_variable_recovery_consumer_cutover.py", + "test": "test_runtime_materialization_has_no_pointwise_compatibility_authority", + }, + { + "requirement": "prepared_batch_recovery_only_runtime_authority", + "polarity": "refusal", + "target": "test_facade_routing", + "test_regex": "^FacadeRouting\\." + "PrimitiveMaterializationRefusesMissingPreparedBatchAuthority$", + }, + ] + + +def test_adc757_slice_executes_host_workspace_reentrancy_without_claiming_streams(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row for row in data["check"] if row["requirement"] == "host_workspace_reentrancy" + ] == [ + { + "requirement": "host_workspace_reentrancy", + "polarity": "positive", + "target": "test_krylov_workspace_reentrancy", + "test_regex": "^test_krylov_workspace_reentrancy\\." + "distinct_workspaces_run_fresh_operator_and_preconditioner_sessions_concurrently$", + }, + { + "requirement": "host_workspace_reentrancy", + "polarity": "refusal", + "target": "test_krylov_workspace_reentrancy", + "test_regex": "^test_krylov_workspace_reentrancy\\." + "workspace_rebind_reserves_mutation_during_blocking_operator_prepare$", + }, + ] + + +def test_adc757_slice_executes_exact_python_ir_and_restart_proofs(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert [ + row + for row in data["check"] + if row.get("kind") == "pytest" + and row["requirement"] == "python_ir_generated_abi_and_restart_parity" + ] == [ + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "positive", + "kind": "pytest", + "path": "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test": "test_recovery_admissibility_is_emitted_and_hashed", + }, + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test": "test_recovery_admissibility_rejects_ambiguous_authoring", + }, + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "positive", + "kind": "pytest", + "path": "tests/python/unit/runtime/test_amr_checkpoint_contract.py", + "test": "test_preflight_returns_exact_native_payload_and_counters", + }, + { + "requirement": "python_ir_generated_abi_and_restart_parity", + "polarity": "refusal", + "kind": "pytest", + "path": "tests/python/unit/runtime/test_amr_checkpoint_contract.py", + "test": "test_historical_version_refusal_happens_before_restart_transaction", + }, + ] + assert "python_ir_generated_abi_and_restart_parity" not in data["deferred"] + + +def test_adc757_closure_requires_revision_matched_hardware_evidence(monkeypatch, tmp_path): + runner = _load_runner() + monkeypatch.setattr( + runner, + "validate_manifest", + lambda _manifest: ( + { + "check": [], + "deferred": [], + "hardware_evidence": runner.EXPECTED_HARDWARE_EVIDENCE, + }, + [], + ), + ) + assert runner.main(["--check-only", "--closure"]) == 4 + + report = tmp_path / "hardware.json" + report.write_text("{}", encoding="utf-8") + observed = [] + monkeypatch.setattr( + runner, + "_run_hardware_evidence", + lambda evidence, path, revision: ( + observed.append((evidence, path, revision)) + or runner.EXPECTED_HARDWARE_REQUIREMENTS + ), + ) + assert ( + runner.main( + [ + "--check-only", + "--closure", + "--hardware-report", + str(report), + "--expected-revision", + "a" * 40, + ] + ) + == 0 + ) + assert observed == [ + (runner.EXPECTED_HARDWARE_EVIDENCE, report, "a" * 40) + ] + + +def test_adc757_hardware_evidence_requires_a_full_exact_candidate_revision(tmp_path): + runner = _load_runner() + report = tmp_path / "hardware.json" + report.write_text("{}", encoding="utf-8") + with pytest.raises(RuntimeError, match="full lowercase 40-hex"): + runner._run_hardware_evidence( + runner.EXPECTED_HARDWARE_EVIDENCE, + report, + "short-revision", + ) + + +def test_adc757_manifest_refuses_missing_polarity_and_unknown_target(tmp_path): + runner = _load_runner() + source = MANIFEST.read_text(encoding="utf-8") + + missing_refusal = tmp_path / "missing_refusal.toml" + missing_refusal.write_text( + source.replace('polarity = "refusal"', 'polarity = "positive"', 1), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(missing_refusal) + assert any("lacks refusal coverage" in error for error in errors) + + unknown_target = tmp_path / "unknown_target.toml" + unknown_target.write_text( + source.replace( + 'target = "test_newton_robustness"', + 'target = "test_missing_provider_proof"', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(unknown_target) + assert any("unknown CTest target" in error for error in errors) + + wrong_mpi_rank = tmp_path / "wrong_mpi_rank.toml" + wrong_mpi_rank.write_text( + source.replace("nproc = 2", "nproc = 4", 1), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(wrong_mpi_rank) + assert any("one exact rank count" in error for error in errors) + + unknown_python_file = tmp_path / "unknown_python_file.toml" + unknown_python_file.write_text( + source.replace( + 'path = "tests/python/unit/codegen/test_recovery_admissibility_codegen.py"', + 'path = "tests/python/unit/codegen/test_missing_gate_proof.py"', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(unknown_python_file) + assert any("unknown Python test file" in error for error in errors) + + unknown_pytest = tmp_path / "unknown_pytest.toml" + unknown_pytest.write_text( + source.replace( + 'test = "test_recovery_admissibility_is_emitted_and_hashed"', + 'test = "test_missing_gate_proof"', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(unknown_pytest) + assert any("unknown top-level pytest" in error for error in errors) + + skipped = runner.ast.parse( + "@pytest.mark.xfail\ndef test_skipped():\n pass\n" + ).body[0] + assert runner._pytest_is_skipped(skipped) + + skipped_ctest = tmp_path / "skipped_ctest.toml" + skipped_ctest.write_text( + source.replace( + "^test_krylov_workspace_reentrancy\\\\." + "distinct_workspaces_run_fresh_operator_and_preconditioner_sessions_concurrently$", + "^test_krylov_workspace_reentrancy\\\\." + "rank_local_problem_construction_failure_is_published_before_lane_unwind$", + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(skipped_ctest) + assert any("selected CTest" in error and "skipped or disabled" in error for error in errors) + + duplicate_hardware = tmp_path / "duplicate_hardware.toml" + duplicate_hardware.write_text( + source.replace( + ' "accelerator_stream_partitioning",\n' + ' "performance_baselines_and_regression_thresholds",', + ' "gpu_backend_execution",\n' + ' "performance_baselines_and_regression_thresholds",', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(duplicate_hardware) + assert any("hardware_evidence requirements must be unique" in error for error in errors) + + fake_cpu_positive = tmp_path / "fake_cpu_positive.toml" + fake_cpu_positive.write_text( + source.replace( + 'requirement = "gpu_backend_execution"\npolarity = "refusal"', + 'requirement = "gpu_backend_execution"\npolarity = "positive"', + 1, + ), + encoding="utf-8", + ) + _, errors = runner.validate_manifest(fake_cpu_positive) + assert any( + "hardware positive evidence must come only from hardware_evidence" in error + for error in errors + ) + + +def test_adc757_runner_refuses_a_declared_but_unbuilt_proof(monkeypatch, tmp_path): + runner = _load_runner() + + def empty_ctest_listing(command, **kwargs): + assert command[:2] == ["ctest", "--test-dir"] + return SimpleNamespace(returncode=0, stdout="Total Tests: 0\n") + + monkeypatch.setattr(runner.subprocess, "run", empty_ctest_listing) + with pytest.raises(RuntimeError, match="is not built"): + runner._run_ctest( + tmp_path, + "test_prepared_numerics_gate", + r"^PreparedNumericsGate\.ConvergedPreparedPathAllocatesNothingAndRollsBack$", + ) + + +def test_adc757_runner_executes_one_exact_pytest(monkeypatch): + runner = _load_runner() + calls = [] + + def capture_pytest(command, **kwargs): + report = Path(command[command.index("--junitxml") + 1]) + report.write_text("", encoding="utf-8") + calls.append((command, kwargs)) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", capture_pytest) + runner._run_pytest( + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test_recovery_admissibility_is_emitted_and_hashed", + ) + [(command, kwargs)] = calls + assert command[:4] == [runner.sys.executable, "-m", "pytest", "-q"] + assert command[4:8] == ["--strict-markers", "-o", "xfail_strict=true", "--junitxml"] + assert command[-1] == ( + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py::" + "test_recovery_admissibility_is_emitted_and_hashed" + ) + assert kwargs["cwd"] == runner.ROOT + assert kwargs["check"] is False + assert kwargs["env"]["POPS_REQUIRE_MPI_TESTS"] == "1" + assert kwargs["env"]["POPS_REQUIRE_NATIVE_TESTS"] == "1" + + +def test_adc757_runner_refuses_a_runtime_skip(monkeypatch): + runner = _load_runner() + + def skipped_pytest(command, **kwargs): + report = Path(command[command.index("--junitxml") + 1]) + report.write_text( + "", + encoding="utf-8", + ) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", skipped_pytest) + with pytest.raises(RuntimeError, match="skipped/xfail proof"): + runner._run_pytest( + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "test_recovery_admissibility_is_emitted_and_hashed", + ) diff --git a/tests/python/architecture/test_amr_program_support_parity.py b/tests/python/architecture/test_amr_program_support_parity.py index 8b7e6d5cc..100a7d390 100644 --- a/tests/python/architecture/test_amr_program_support_parity.py +++ b/tests/python/architecture/test_amr_program_support_parity.py @@ -5,6 +5,7 @@ error-policy exceptions are not capability declarations. This gate locks the explicit identifiers against ``DEFERRED_GROUPS`` without importing ``pops`` or the compiled extension. """ + import importlib.util import pathlib import re @@ -15,8 +16,20 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] SUPPORT_PY = REPO_ROOT / "python" / "pops" / "runtime" / "amr_program_support.py" -CONTEXT_HPP = (REPO_ROOT / "include" / "pops" / "runtime" / "program" - / "amr_program_context.hpp") +CONTEXT_HPP = REPO_ROOT / "include" / "pops" / "runtime" / "program" / "amr_program_context.hpp" +SERVICES_HPP = ( + REPO_ROOT + / "include" + / "pops" + / "runtime" + / "program" + / "program_execution_services.hpp" +) +PRODUCTION_CODEGEN = ( + REPO_ROOT / "python" / "pops" / "codegen" / "program_codegen.py", + REPO_ROOT / "python" / "pops" / "codegen" / "program_emit_ops.py", + REPO_ROOT / "python" / "pops" / "codegen" / "program_emit_amr.py", +) def _load_support_module(): @@ -79,12 +92,14 @@ def test_support_module_loads_standalone_and_stays_import_free(): offender = re.search(r"(?m)^\s*(?:import\s+pops|from\s+pops)\b", source) assert offender is None, ( "amr_program_support.py must load source-only before _pops exists; found %r" - % (offender.group(0) if offender else None)) + % (offender.group(0) if offender else None) + ) groups = _load_support_module().deferred_groups() assert groups assert set(groups.values()) <= {"green"} | { - value for value in groups.values() if value.startswith("pending")} + value for value in groups.values() if value.startswith("pending") + } def test_header_deferred_set_matches_the_python_mirror(): @@ -94,20 +109,36 @@ def test_header_deferred_set_matches_the_python_mirror(): assert header == mirror, ( "AMR Program explicit-deferral drift:\n" " only in header: %s\n" - " only in mirror: %s" % (sorted(header - mirror), sorted(mirror - header))) + " only in mirror: %s" % (sorted(header - mirror), sorted(mirror - header)) + ) def test_parser_finds_only_explicit_known_deferrals(): + module = _load_support_module() header = _parse_header_deferred_set(CONTEXT_HPP.read_text(encoding="utf-8")) for identifier in ( "cache_should_update", "cache_effective_dt", "neg_div_flux_into", - "solve_fields_from_state_default", "solve_fields_from_blocks_default", - "solve_fields_from_state_at_fine_level", ): assert identifier in header + assert "solve_fields_from_state_at_fine_level" not in header + assert "solve_fields_from_state_default" not in header + assert "SolveOutcome solve_fields_from_state(const std::string&" not in ( + CONTEXT_HPP.read_text(encoding="utf-8") + ) + assert "SolveOutcome solve_fields_from_blocks(const std::string&" not in ( + CONTEXT_HPP.read_text(encoding="utf-8") + ) + assert "solve_fields_from_blocks_at" not in CONTEXT_HPP.read_text(encoding="utf-8") + assert "solve_fields_from_blocks_at" in SERVICES_HPP.read_text(encoding="utf-8") + assert "program_execution_solve_generated_field_from_blocks_outcome_" in ( + CONTEXT_HPP.read_text(encoding="utf-8") + ) + assert "named_solve_reports_" not in CONTEXT_HPP.read_text(encoding="utf-8") + assert "fine_level_field_perturbation" not in module.DEFERRED_GROUPS + assert "refined_shared_block_interfaces" not in module.DEFERRED_GROUPS assert "apply_projection" not in header assert not any(identifier.startswith("history") for identifier in header) @@ -118,11 +149,29 @@ def test_projection_is_green_after_the_real_amr_implementation_landed(): assert module.deferred_groups()["projection"] == "green" +def test_generated_programs_cannot_use_coarse_injection_as_a_fine_solve(): + header = CONTEXT_HPP.read_text(encoding="utf-8") + assert "SolveOutcome solve_fields() const" not in header + assert header.count("SolveOutcome solve_default_field_on_coarse_level() const") == 1 + coarse_route = header.split("SolveOutcome solve_default_field_on_coarse_level() const", 1)[ + 1 + ].split("SolveOutcome solve_fields_from_state_at(", 1)[0] + assert "if (level_ != 0)" in coarse_route + assert "coarse-to-fine auxiliary injection is not a " in coarse_route + assert '"fine-level solve"' in coarse_route + assert "return eng_->solve_default_field();" in coarse_route + assert "default_solve_report_" not in header + + generated = "\n".join(path.read_text(encoding="utf-8") for path in PRODUCTION_CODEGEN) + assert "ctx.solve_fields(" not in generated + assert "solve_default_field_on_coarse_level" not in generated + assert "ctx.solve_fields_from_state_at(" in generated + + class _Program: def __init__(self, nodes, *, recursive_nodes=None): self._nodes = list(nodes) - self._recursive_nodes = list( - self._nodes if recursive_nodes is None else recursive_nodes) + self._recursive_nodes = list(self._nodes if recursive_nodes is None else recursive_nodes) def ir_nodes(self, *, recursive=False): return list(self._recursive_nodes if recursive else self._nodes) @@ -143,7 +192,7 @@ def test_complete_query_requires_resolved_context(): module.amr_program_op_support(_Program([]), context=None) -def test_context_sensitive_deferrals_are_reported_only_when_reachable(): +def test_context_sensitive_routes_report_green_or_pending_from_resolved_hierarchy(): module = _load_support_module() matrix_free = {"op": "matrix_free_operator", "attrs": {"apply_block": ["#2"]}} field_jacobian = _Program( @@ -153,12 +202,12 @@ def test_context_sensitive_deferrals_are_reported_only_when_reachable(): {"op": "rhs_jacvec", "attrs": {"field_coupled": True}}, ], ) - assert module.amr_program_op_support( - field_jacobian, context=_context(module, refined=False)) == {} - assert module.amr_program_op_support( - field_jacobian, context=_context(module, refined=True)) == { - "fine_level_field_perturbation": "pending", - } + assert ( + module.amr_program_op_support(field_jacobian, context=_context(module, refined=False)) == {} + ) + assert ( + module.amr_program_op_support(field_jacobian, context=_context(module, refined=True)) == {} + ) assert module.amr_program_op_support( _Program([]), context=_context( module, refined=True, interfaces=True, frozen=False)) == {} @@ -187,14 +236,14 @@ def test_context_sensitive_deferrals_are_reported_only_when_reachable(): def test_ir_ops_mirror_the_codegen_op_group_sets(): module = _load_support_module() - kernels = (REPO_ROOT / "python" / "pops" / "codegen" - / "program_emit_kernels.py").read_text(encoding="utf-8") + kernels = (REPO_ROOT / "python" / "pops" / "codegen" / "program_emit_kernels.py").read_text( + encoding="utf-8" + ) match = re.search(r"_CONDENSED_OPS\s*=\s*frozenset\(\{([^}]*)\}\)", kernels, re.S) assert match is not None codegen_condensed = set(re.findall(r'"([A-Za-z_]\w*)"', match.group(1))) assert set(module.DEFERRED_GROUPS["condensed"]["ir_ops"]) == codegen_condensed - assert module.DEFERRED_GROUPS["named_field_solve"]["ir_ops"] == frozenset( - {"solve_fields"}) + assert module.DEFERRED_GROUPS["named_field_solve"]["ir_ops"] == frozenset({"solve_fields"}) assert module.amr_program_op_support( _Program([{"op": "solve_fields", "attrs": {"field": "potential"}}]), context=_context(module), diff --git a/tests/python/architecture/test_automatic_projection_balance_fence.py b/tests/python/architecture/test_automatic_projection_balance_fence.py new file mode 100644 index 000000000..14064e070 --- /dev/null +++ b/tests/python/architecture/test_automatic_projection_balance_fence.py @@ -0,0 +1,119 @@ +"""ADC-686: projection balance evidence is due-only, metric, and still private.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PROGRAM_STATE = ROOT / "include" / "pops" / "runtime" / "program" / "program_runtime_state.hpp" +EXECUTION_SERVICES = ( + ROOT / "include" / "pops" / "runtime" / "program" / "program_execution_services.hpp" +) +UNIFORM_CONTEXT = ROOT / "include" / "pops" / "runtime" / "program" / "program_context.hpp" +AMR_CONTEXT = ROOT / "include" / "pops" / "runtime" / "program" / "amr_program_context.hpp" +BALANCE_CODEGEN = ROOT / "python" / "pops" / "codegen" / "program_balance_due.py" +UNIFORM_IMPL = ROOT / "src" / "runtime" / "system" / "system_impl.hpp" +AMR_IMPL = ROOT / "src" / "runtime" / "amr" / "amr_system.cpp" + + +def _between(text: str, begin: str, end: str) -> str: + return text.split(begin, 1)[1].split(end, 1)[0] + + +def test_generated_due_marker_precedes_operators_and_is_attempt_local() -> None: + codegen = BALANCE_CODEGEN.read_text() + emit = _between( + codegen, + "def emit_balance_due_guards(", + "\ndef balance_value_due_expression(", + ) + assert "automatic_tokens = []" in emit + assert "if automatic_tokens:" in emit + assert "ctx.note_automatic_balance_capture_due(%s);" in emit + + state = PROGRAM_STATE.read_text() + assert "bool automatic_balance_due_ = false;" in state + capture_due = _between( + state, + "[[nodiscard]] bool automatic_balance_capture_due() const noexcept", + "/// Accumulate one signed, metric-integrated native operator contribution.", + ) + assert "!balance_replay_active_ && automatic_balance_due_" in capture_due + assert "automatic_balance_due_ = automatic_balance_due_ || due;" in capture_due + + attempt_entry = _between( + state, + "void begin_step_projection_report()", + "void note_step_projection(", + ) + assert "automatic_balance_due_ = false;" in attempt_entry + + uniform = UNIFORM_IMPL.read_text() + adaptive = AMR_IMPL.read_text() + for source in (uniform, adaptive): + assert "automatic_balance_due" in source + assert "impl.program_.automatic_balance_due_" in source + + +def test_projection_delta_is_captured_only_when_due_and_stays_qualified() -> None: + services = EXECUTION_SERVICES.read_text() + projection = _between( + services, + "void apply_projection(int block, MultiFab& state) const", + "/// Minimum physical cell size used by the native CFL authority.", + ) + assert "if (!runtime.automatic_balance_capture_due())" in projection + assert projection.count("program_execution_projection_balance_integrals_") == 2 + due_projection = projection.split( + "const std::optional> before", 1 + )[1] + assert due_projection.index("program_execution_apply_projection_") < due_projection.index( + "const std::optional> after" + ) + assert "record_automatic_balance_term(" in projection + assert '"projection"' in projection + assert "runtime_block, level, component" in projection + + state = PROGRAM_STATE.read_text() + accepted = _between( + state, + "std::map accepted_balance_terms(", + "void begin_balance_due_window(", + ) + explicit_only = accepted.split( + "std::map selected_accepted_balance_terms(", 1 + )[0] + assert "automatic_balance_terms_" not in explicit_only + assert "automatic_balance_terms_" in accepted + + +def test_uniform_projection_evidence_uses_exact_available_measure() -> None: + context = UNIFORM_CONTEXT.read_text() + provider = _between( + context, + "std::optional> program_execution_projection_balance_integrals_(", + "Real program_execution_hmin_() const", + ) + assert "if (sys_->program_is_polar())" in provider + assert "return std::nullopt;" in provider + assert "context.geom.dx() * context.geom.dy()" in provider + assert "RelativeCellMeasure measure;" in provider + assert "measure.active_cells = context.domain_mask;" in provider + assert "measure.inverse_volume_fraction = context.eb_inverse_volume_fraction;" in provider + assert "pops::reduce_sum(state, component, measure)" in provider + + +def test_amr_projection_evidence_excludes_covered_cells_and_reduces_once() -> None: + context = AMR_CONTEXT.read_text() + provider = _between( + context, + "std::optional> program_execution_projection_balance_integrals_(", + "Real program_execution_hmin_() const", + ) + assert "active_mask(views, level_, next)" in provider + assert "CompositeSumKind::Sum" in provider + assert "local_sum(" in provider + assert "if (!eng_->level_is_replicated(level_))" in provider + assert provider.count("all_reduce_sum_inplace(") == 1 + assert "geometry.dx()) * static_cast(geometry.dy())" in provider + assert "state.n_grow() != live.n_grow()" in provider + assert "state.local_size() != live.local_size()" in provider diff --git a/tests/python/architecture/test_automatic_reflux_balance_fence.py b/tests/python/architecture/test_automatic_reflux_balance_fence.py new file mode 100644 index 000000000..2b300d152 --- /dev/null +++ b/tests/python/architecture/test_automatic_reflux_balance_fence.py @@ -0,0 +1,103 @@ +"""ADC-686: automatic reflux evidence stays exact, sparse, and fail-closed.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PROGRAM_STATE = ( + ROOT / "include" / "pops" / "runtime" / "program" / "program_runtime_state.hpp" +) +AMR_CONTEXT = ( + ROOT / "include" / "pops" / "runtime" / "program" / "amr_program_context.hpp" +) +AMR_REFLUX = ROOT / "include" / "pops" / "runtime" / "amr" / "amr_program_reflux.hpp" +AMR_SUBCYCLING = ( + ROOT / "include" / "pops" / "numerics" / "time" / "amr" / "levels" + / "amr_subcycling.hpp" +) +AMR_PATCH_RANGE = ( + ROOT / "include" / "pops" / "numerics" / "time" / "amr" / "levels" + / "amr_patch_range.hpp" +) +UNIFORM_IMPL = ROOT / "src" / "runtime" / "system" / "system_impl.hpp" +AMR_IMPL = ROOT / "src" / "runtime" / "amr" / "amr_system.cpp" + + +def _between(text: str, begin: str, end: str) -> str: + return text.split(begin, 1)[1].split(end, 1)[0] + + +def test_automatic_balance_mailbox_is_attempt_local_and_not_a_route_fallback() -> None: + state = PROGRAM_STATE.read_text() + assert "struct AutomaticBalanceKey" in state + assert "std::map automatic_balance_terms_;" in state + assert "automatic_balance_terms_.clear();" in state + assert "record_automatic_balance_term(" in state + assert "automatic_balance_capture_due()" in state + + accepted = _between( + state, + "std::map accepted_balance_terms(", + "void begin_balance_due_window(", + ) + explicit_only = accepted.split( + "std::map selected_accepted_balance_terms(", 1 + )[0] + assert "step_balance_terms_" in explicit_only + assert "automatic_balance_terms_" not in explicit_only + assert "automatic_balance_terms_" in accepted + + uniform = UNIFORM_IMPL.read_text() + adaptive = AMR_IMPL.read_text() + for source in (uniform, adaptive): + assert "automatic_balance_terms" in source + assert "impl.program_.automatic_balance_terms_" in source + + +def test_reflux_integral_comes_from_the_gathered_sparse_correction() -> None: + register = AMR_PATCH_RANGE.read_text() + component_sums = _between( + register, + "[[nodiscard]] std::vector component_sums(", + "[[nodiscard]] std::size_t lookup_capacity()", + ) + assert "device_fence();" in component_sums + assert "cell_measure * buf[offset + component]" in component_sums + assert "all_reduce" not in component_sums + + transition = AMR_SUBCYCLING.read_text() + synchronize = _between( + transition, + "void synchronize_integrated(", + "\n private:", + ) + assert synchronize.index("correction_.gather(communicator);") < synchronize.index( + "correction_.component_sums(dx * dy)" + ) + assert synchronize.index("correction_.component_sums(dx * dy)") < synchronize.index( + "ApplyRefluxRegisterKernel" + ) + + route = AMR_REFLUX.read_text() + routing = _between(route, "inline void route_reflux_program(", "\n}\n\n} // namespace detail") + assert "std::vector* integrated_state_correction = nullptr" in routing + assert "integrated_state_correction);" in routing + + +def test_amr_records_reflux_before_average_down_only_when_balance_is_due() -> None: + context = AMR_CONTEXT.read_text() + synchronize = _between( + context, + "void synchronize_level_pair_(", + "void finalize_history_rotation_()", + ) + assert "automatic_balance_capture_due()" in synchronize + assert "record_automatic_balance_term(" in synchronize + assert '"reflux"' in synchronize + assert "reduce_sum(" not in synchronize + assert synchronize.index("route_reflux_program(") < synchronize.index( + "record_automatic_balance_term(" + ) + assert synchronize.index("record_automatic_balance_term(") < synchronize.index( + "SyncPhase::AverageDown" + ) diff --git a/tests/python/architecture/test_ci_cpp_include_impact.py b/tests/python/architecture/test_ci_cpp_include_impact.py index 00d884590..e97436440 100644 --- a/tests/python/architecture/test_ci_cpp_include_impact.py +++ b/tests/python/architecture/test_ci_cpp_include_impact.py @@ -375,6 +375,35 @@ def test_cpp_duration_catalog_inventory_rejects_invalid_weights( sel.validate_cpp_duration_catalogs(["test_alpha"]) +@pytest.mark.parametrize( + ("metadata", "message"), + ( + ({"target_count": 2, "estimated_targets": []}, "target_count"), + ( + {"target_count": 1, "estimated_targets": ["test_missing"]}, + "estimated_targets orphaned", + ), + ( + {"target_count": 1, "estimated_targets": ["test_alpha", "test_alpha"]}, + "estimated_targets must be sorted unique", + ), + ), +) +def test_cpp_duration_catalog_inventory_authenticates_metadata( + tmp_path, monkeypatch, metadata, message, +): + build_path = tmp_path / "build.json" + test_path = tmp_path / "test.json" + catalog = {"_meta": metadata, "test_alpha": 1.0} + build_path.write_text(json.dumps(catalog), encoding="utf-8") + test_path.write_text(json.dumps(catalog), encoding="utf-8") + monkeypatch.setattr(sel, "CPP_BUILD_DURATIONS_JSON", build_path) + monkeypatch.setattr(sel, "CPP_DURATIONS_JSON", test_path) + + with pytest.raises(SystemExit, match=message): + sel.validate_cpp_duration_catalogs(["test_alpha"]) + + def test_amr_program_header_plan_covers_both_analytic_targets_exactly_once(tmp_path): """The ADC-760 reproducer must produce one authenticated, exact one-shard plan.""" output = _run_plan_cpp_shard( diff --git a/tests/python/architecture/test_ci_impacted_selection.py b/tests/python/architecture/test_ci_impacted_selection.py index ee066b5df..410db1b53 100644 --- a/tests/python/architecture/test_ci_impacted_selection.py +++ b/tests/python/architecture/test_ci_impacted_selection.py @@ -320,7 +320,7 @@ def test_manifest_projects_exact_mpi_targets_for_dedicated_job(): for suite in all_suites ) ctest_plan = sel.cpp_mpi_ctest_plan(manifest) - assert len(ctest_plan) == sel.cpp_mpi_ctest_count(manifest) == expected_count == 80 + assert len(ctest_plan) == sel.cpp_mpi_ctest_count(manifest) == expected_count == 90 assert ctest_plan["test_mpi_external_lifecycle_np1"] == 1 assert ctest_plan["test_mpi_hdf5_collective_np2"] == 2 assert ctest_plan["test_mpi_amr_compiled_parity_rank_parity"] == 4 @@ -492,6 +492,38 @@ def test_cpp_target_label_fence_requires_each_selected_target(tmp_path): args.targets.pop() +def test_cpp_target_label_fence_selects_standalone_with_no_shard_targets(tmp_path): + sel = _load("ci_select_tests") + inventory = tmp_path / "ctest.json" + inventory.write_text(json.dumps({ + "tests": [ + { + "name": "Suite.OtherShard", + "properties": [ + {"name": "LABELS", "value": ["cpp-target:test_other"]}, + ], + }, + { + "name": "test_standalone_contract", + "properties": [ + {"name": "LABELS", "value": ["cpp-standalone"]}, + ], + }, + ], + })) + standalone_regex = tmp_path / "standalone.regex" + args = SimpleNamespace( + ctest_json=str(inventory), + targets=[], + standalone_regex_file=str(standalone_regex), + ) + + assert sel.verify_cpp_target_labels(args) == 0 + assert re.fullmatch( + standalone_regex.read_text().strip(), "test_standalone_contract" + ) + + def test_cpp_target_label_fence_ignores_other_shards_but_rejects_ambiguous_owners( tmp_path, ): @@ -619,6 +651,16 @@ def test_manifest_projects_exact_python_mpi_entrypoints(): "path": "tests/python/integration/mpi/test_amr_regrid_on_restart_mpi.py", "nproc": 2, }, + { + "suite": "pops_python_integration_mpi", + "path": "tests/python/integration/mpi/test_async_balance_cadence_mpi.py", + "nproc": 2, + }, + { + "suite": "pops_python_integration_mpi", + "path": "tests/python/integration/mpi/test_external_amr_field_solver_mpi.py", + "nproc": 2, + }, { "suite": "pops_python_integration_mpi", "path": "tests/python/integration/mpi/test_scientific_output_mpi.py", @@ -673,6 +715,8 @@ class Args: "2\ttests/python/integration/mpi/test_amr_history_mpi.py", "2\ttests/python/integration/mpi/test_amr_nonlinear_collective_mpi.py", "2\ttests/python/integration/mpi/test_amr_regrid_on_restart_mpi.py", + "2\ttests/python/integration/mpi/test_async_balance_cadence_mpi.py", + "2\ttests/python/integration/mpi/test_external_amr_field_solver_mpi.py", "2\ttests/python/integration/mpi/test_scientific_output_mpi.py", "2\ttests/python/integration/mpi/test_uniform_history_checkpoint_mpi.py", ] @@ -685,8 +729,8 @@ class Args: line.partition("=")[::2] for line in (tmp_path / "github-output.txt").read_text().splitlines() ) - assert outputs["python_mpi_count"] == "8" - assert outputs["python_mpi_entrypoint_count"] == "7" + assert outputs["python_mpi_count"] == "10" + assert outputs["python_mpi_entrypoint_count"] == "9" assert outputs["python_mpi_orchestrator_count"] == "1" @@ -828,7 +872,7 @@ def test_ci_required_gate_aggregates_full_matrix_and_mpi_path_changes(): "\n # GATE C++", 1)[0] cpp_shards_block = workflow.split("\n gate-cpp-shards:\n", 1)[1].split( "\n # Check historique", 1)[0] - assert "timeout-minutes: 22" in cpp_prewarm_block + assert "timeout-minutes: 30" in cpp_prewarm_block assert ( "lane: [system, amr-base, amr-block-base, amr-compressible]" in cpp_prewarm_block @@ -836,12 +880,18 @@ def test_ci_required_gate_aggregates_full_matrix_and_mpi_path_changes(): assert "scripts/ci_python_module_objects.py" in cpp_prewarm_block assert "--contract-file" in cpp_prewarm_block assert "-DPOPS_HEAVY_TEST_TU_POOL=\"$lane_parallelism\"" in cpp_prewarm_block + assert "system)" in cpp_prewarm_block + assert "lane_watchdog=24m" in cpp_prewarm_block assert 'amr-base|amr-compressible) lane_parallelism=2 ;;' in cpp_prewarm_block - assert 'run_with_heartbeat "C++ prewarm ${{ matrix.lane }}" 18m' in cpp_prewarm_block + assert ( + 'run_with_heartbeat "C++ prewarm ${{ matrix.lane }}" "$lane_watchdog"' + in cpp_prewarm_block + ) assert "compression-level: 0" in cpp_prewarm_block assert "ctest --preset ci-kokkos -N --show-only=json-v1" in cpp_shards_block assert "scripts/ci_select_tests.py verify-cpp-target-labels" in cpp_shards_block assert "--standalone-regex-file" in cpp_shards_block + assert 'if [ "${{ matrix.shard }}" -eq 0 ]; then' in cpp_shards_block assert "name: Standalone CTest contracts" in cpp_shards_block assert 'standalone_regex=$(<"$standalone_regex_file")' in cpp_shards_block assert '-R "$standalone_regex"' in cpp_shards_block @@ -886,7 +936,7 @@ def test_ci_required_gate_aggregates_full_matrix_and_mpi_path_changes(): "\n # Agregation REQUISE", 1 )[0] assert "runs-on: ubuntu-24.04" in mpi_prewarm_block - assert "timeout-minutes: 40" in mpi_prewarm_block + assert "timeout-minutes: 50" in mpi_prewarm_block assert "needs: [set-mode, changes]" in mpi_prewarm_block assert "if: needs.set-mode.outputs.mpi_required == 'true'" in mpi_prewarm_block assert ( @@ -896,7 +946,12 @@ def test_ci_required_gate_aggregates_full_matrix_and_mpi_path_changes(): assert "cmake --preset ci-mpi" in mpi_prewarm_block assert "scripts/ci_python_module_objects.py" in mpi_prewarm_block assert "--contract-file" in mpi_prewarm_block - assert 'run_with_heartbeat "MPI prewarm ${{ matrix.lane }}" 18m' in mpi_prewarm_block + assert "system)" in mpi_prewarm_block + assert "lane_watchdog=24m" in mpi_prewarm_block + assert ( + 'run_with_heartbeat "MPI prewarm ${{ matrix.lane }}" "$lane_watchdog"' + in mpi_prewarm_block + ) assert "compression-level: 0" in mpi_prewarm_block mpi_block = workflow.split("\n mpi:\n", 1)[1].split( @@ -949,7 +1004,10 @@ def test_ci_required_gate_aggregates_full_matrix_and_mpi_path_changes(): assert "selected_count=$(python3 -c" in mpi_block assert "selected ${selected_count}/${expected} launches" in mpi_block assert "ctest --preset ci-mpi --output-on-failure --parallel 4 --no-tests=error" in mpi_block - assert "timeout-minutes: 70" in mpi_block + # The complete M4 installed-package gate now runs after the native MPI, + # Python MPI and collective-HDF5 matrices in this same required job. Keep + # the outer watchdog aligned with that complete sequential contract. + assert "timeout-minutes: 180" in mpi_block assert "timeout-minutes: 35" in mpi_block assert '/usr/bin/python3 -u "$mpi_test"' in mpi_block assert "mpiexec -n \"$mpi_ranks\"" not in mpi_block @@ -1213,7 +1271,7 @@ def test_ci_required_gate_aggregates_full_matrix_and_mpi_path_changes(): not in python_prewarm_block assert "lane: [system, amr-base, amr-block-base, amr-compressible]" \ in python_prewarm_block - assert "timeout-minutes: 22" in python_prewarm_block + assert "timeout-minutes: 30" in python_prewarm_block assert "lookup-only: true" in python_prewarm_block assert "scripts/ci_python_module_objects.py" in python_prewarm_block assert "--contract-file" in python_prewarm_block @@ -1221,10 +1279,14 @@ def test_ci_required_gate_aggregates_full_matrix_and_mpi_path_changes(): assert "-DPOPS_HEAVY_MODULE_TU_POOL=4" in python_prewarm_block assert "-DCMAKE_CXX_FLAGS=\"-ffile-prefix-map=${{ github.workspace }}=.\"" in python_prewarm_block assert python_prewarm_block.count("run_with_heartbeat() {") == 1 - assert 'run_with_heartbeat "Python prewarm ${{ matrix.lane }}" 18m' \ + assert ( + 'run_with_heartbeat "Python prewarm ${{ matrix.lane }}" "$lane_watchdog"' in python_prewarm_block + ) assert "mem_available=${mem_available_mib}MiB" in python_prewarm_block assert 'amr-base|amr-compressible) lane_parallelism=2 ;;' in python_prewarm_block + assert "system)" in python_prewarm_block + assert "lane_watchdog=24m" in python_prewarm_block assert 'lane_parallelism=2' in python_prewarm_block assert '--parallel "$lane_parallelism"' in python_prewarm_block # Lanes publish only their new, disjoint entries. Restoring the same historical cache in all @@ -1275,6 +1337,20 @@ def test_openmp_native_scripts_share_the_fail_closed_requirement_policy(relative assert "OK (rien a compiler)" not in source +def test_quality_manifest_coverage_is_fail_closed(): + workflow = (REPO_ROOT / ".github/workflows/quality.yml").read_text(encoding="utf-8") + manifest_gate = workflow.split( + " - name: Couverture manifest de tests (test_manifest.toml, bloquante)\n", + 1, + )[1].split("\n # --- Prewarm natif", 1)[0] + + assert "python3 docs/gen_test_counts.py --check-matrix" in manifest_gate + assert 'echo "::error::tests/test_manifest.toml' in manifest_gate + assert 'exit "$rc"' in manifest_gate + assert "::warning::tests/test_manifest.toml" not in manifest_gate + assert "_Informatif" not in manifest_gate + + def test_quality_cold_instrumented_builds_use_exact_parallel_runtime_prewarm(): workflow = (REPO_ROOT / ".github/workflows/quality.yml").read_text(encoding="utf-8") prewarm = workflow.split("\n quality-native-prewarm:\n", 1)[1].split( diff --git a/tests/python/architecture/test_final_public_api.py b/tests/python/architecture/test_final_public_api.py index 1d3e8257d..8290263bb 100644 --- a/tests/python/architecture/test_final_public_api.py +++ b/tests/python/architecture/test_final_public_api.py @@ -257,6 +257,7 @@ def test_runtime_instance_has_only_the_explicit_read_and_restart_surface() -> No "patch_rectangles", "post_commit_diagnostics", "post_commit_reports", + "program_accepted_state", "program_report", "restart", "restore_consumer_recovery", @@ -325,8 +326,8 @@ def test_physics_has_no_competing_model_facade() -> None: from pops import physics assert physics.__all__ == [ - "Model", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", "Scalar", - "Temperature", "Velocity", + "Model", "Axial", "ComponentRole", "Density", "Energy", "Momentum", "Pressure", + "Scalar", "Temperature", "Velocity", ] assert physics.Model is pops.Model for removed in ("PdeModel", "HyperbolicModel", "PhysicsModel", "HybridModel"): @@ -334,6 +335,7 @@ def test_physics_has_no_competing_model_facade() -> None: model = pops.Model("single_public_model") assert not hasattr(model, "dsl") assert not hasattr(model, "compile") + assert not hasattr(model, "to_module") for retired_module in ( "pops.physics.facade", "pops.physics.model", @@ -344,3 +346,11 @@ def test_physics_has_no_competing_model_facade() -> None: ): with pytest.raises(ModuleNotFoundError): importlib.import_module(retired_module) + + +def test_moment_model_has_one_model_construction_route() -> None: + from pops import moments + + specification = moments.CartesianVelocityMoments(order=2) + assert callable(specification.build) + assert not hasattr(specification, "check") diff --git a/tests/python/architecture/test_final_release_gate.py b/tests/python/architecture/test_final_release_gate.py index be66df947..9a4f94413 100644 --- a/tests/python/architecture/test_final_release_gate.py +++ b/tests/python/architecture/test_final_release_gate.py @@ -1,6 +1,7 @@ """Source-only contract checks for the final release gate (ADC-695).""" from __future__ import annotations +import copy import hashlib import importlib.util import json @@ -81,12 +82,60 @@ def _write_final_source_tree(root: Path) -> None: for example in contract.FINAL_EXAMPLES: path = root / example path.parent.mkdir(parents=True, exist_ok=True) + output_targets = [ + expectation["consumer_target"] + for expectations in contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS[example].values() + for expectation in expectations + ] path.write_text( "--output-dir\n" + "\n".join(contract.REQUIRED_PROOF_MARKERS) + + "\n" + + "\n".join('# target="%s"' % target for target in output_targets) + "\nif __name__ == \"__main__\":\n pass\n", encoding="utf-8", ) + test_sources = {} + for ledger in ( + contract.FINAL_EXAMPLE_ACCEPTANCE_TESTS, + contract.FINAL_EXAMPLE_QUALIFICATION_TESTS, + ): + for example, nodeid in zip(contract.FINAL_EXAMPLES, ledger, strict=True): + relative, function_name = nodeid.split("::", 1) + entry = test_sources.setdefault(relative, [example.name, []]) + assert entry[0] == example.name + entry[1].append(function_name) + for relative, (example_name, function_names) in test_sources.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "EXAMPLE = %r\n\n%s\n" + % ( + example_name, + "\n\n".join( + "def %s():\n pass" % function_name + for function_name in function_names + ), + ), + encoding="utf-8", + ) + + +def _write_paraview_series(root: Path) -> tuple[Path, Path]: + root.mkdir(parents=True, exist_ok=True) + vtu = root / "state.vtu" + vtu.write_text( + '', + encoding="utf-8", + ) + pvd = root / "state.pvd" + pvd.write_text( + '' + '' + "", + encoding="utf-8", + ) + return vtu, pvd def test_final_release_source_contract_accepts_exact_canonical_set(tmp_path): @@ -117,6 +166,131 @@ def test_final_release_source_contract_requires_executable_restart_output_proof( assert any("lacks final proof markers" in error for error in errors) +def test_final_release_source_contract_requires_exact_scientific_output_targets(tmp_path): + _write_final_source_tree(tmp_path) + example = contract.FINAL_EXAMPLES[2] + path = tmp_path / example + required_target = contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS[example]["npz"][0][ + "consumer_target" + ] + path.write_text( + path.read_text(encoding="utf-8").replace( + '# target="%s"' % required_target, + '# scientific target removed', + ), + encoding="utf-8", + ) + + errors = contract.source_contract_errors(tmp_path) + + assert any("lacks its exact npz scientific-output target" in error for error in errors) + + +def test_final_release_source_contract_separates_consumer_targets_from_artifact_roots( + tmp_path, +): + _write_final_source_tree(tmp_path) + expected_roots = { + contract.FINAL_EXAMPLES[0]: {"manual/accepted/state/tracer", + "manual/accepted/solution/tracer"}, + contract.FINAL_EXAMPLES[1]: {"accepted/state/two_fluid", + "accepted/visualization/two_fluid"}, + contract.FINAL_EXAMPLES[2]: {"manual/accepted/hdf5/state", + "manual/accepted/npz/state", + "manual/accepted/paraview/state"}, + contract.FINAL_EXAMPLES[3]: {"accepted/state/hyqmom15", + "accepted/visualization/hyqmom15"}, + } + + for example, formats in contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS.items(): + rows = tuple(row for expectations in formats.values() for row in expectations) + assert {row["artifact_root"] for row in rows} == expected_roots[example] + assert all( + Path(row["artifact_root"]).parts[-len(Path(row["consumer_target"]).parts):] + == Path(row["consumer_target"]).parts + for row in rows + ) + + assert contract.source_contract_errors(tmp_path) == [] + + +@pytest.mark.parametrize("artifact_root", ("../state/tracer", "accepted/wrong")) +def test_final_release_source_contract_refuses_escaping_or_mismatched_artifact_roots( + monkeypatch, tmp_path, artifact_root +): + _write_final_source_tree(tmp_path) + outputs = copy.deepcopy(contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS) + outputs[contract.FINAL_EXAMPLES[0]]["hdf5"] = ({ + "consumer_target": "state/tracer", + "artifact_root": artifact_root, + },) + monkeypatch.setattr(contract, "FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS", outputs) + + errors = contract.source_contract_errors(tmp_path) + + assert any( + "escaping hdf5 scientific-output expectation" in error + or "does not end with consumer target" in error + for error in errors + ) + + +def test_final_release_source_contract_requires_exact_mandatory_example_tests(tmp_path): + _write_final_source_tree(tmp_path) + nodeid = contract.FINAL_EXAMPLE_ACCEPTANCE_TESTS[-1] + relative, _function_name = nodeid.split("::", 1) + (tmp_path / relative).write_text( + "EXAMPLE = 'wrong.py'\n" + "@pytest.mark.skip(reason='optional')\n" + "def renamed_test():\n" + " pass\n", + encoding="utf-8", + ) + + errors = contract.source_contract_errors(tmp_path) + + assert any("must resolve exactly once" in error for error in errors) + + +def test_final_release_source_contract_refuses_duplicate_required_nodeids( + monkeypatch, tmp_path +): + _write_final_source_tree(tmp_path) + monkeypatch.setattr( + contract, + "FINAL_EXAMPLE_QUALIFICATION_TESTS", + ( + contract.FINAL_EXAMPLE_ACCEPTANCE_TESTS[0], + *contract.FINAL_EXAMPLE_QUALIFICATION_TESTS[1:], + ), + ) + + errors = contract.source_contract_errors(tmp_path) + + assert "final-example required test nodeids must be unique" in errors + + +@pytest.mark.parametrize( + "injected", + ( + "\nfrom unittest.mock import patch\n", + "\npytestmark = pytest.mark.xfail(reason='optional')\n", + ), +) +def test_final_release_source_contract_refuses_mocked_or_optional_required_tests( + tmp_path, injected +): + _write_final_source_tree(tmp_path) + nodeid = contract.FINAL_EXAMPLE_ACCEPTANCE_TESTS[0] + relative, _function_name = nodeid.split("::", 1) + path = tmp_path / relative + path.write_text(path.read_text(encoding="utf-8") + injected, encoding="utf-8") + + errors = contract.source_contract_errors(tmp_path) + + assert any(nodeid in error and "optional" in error for error in errors) + + @pytest.mark.parametrize("module", ("pops.ir", "pops._ir")) def test_final_release_source_contract_refuses_internal_or_transitional_imports( tmp_path, module @@ -152,6 +326,155 @@ def test_required_junit_lane_rejects_skips_xfails_failures_and_empty_reports(tmp gate._junit_summary(report) +def test_required_junit_lane_authenticates_exact_final_example_tests(tmp_path): + cases = [] + for nodeid in contract.FINAL_EXAMPLE_REQUIRED_TESTS: + relative, function_name = nodeid.split("::", 1) + classname = str(Path(relative).with_suffix("")).replace("/", ".") + cases.append( + '' % (classname, function_name) + ) + report = tmp_path / "final-examples.xml" + report.write_text( + '%s' + % (len(cases), "".join(cases)), + encoding="utf-8", + ) + + assert gate._require_junit_nodeids( + report, contract.FINAL_EXAMPLE_REQUIRED_TESTS + ) == list(contract.FINAL_EXAMPLE_REQUIRED_TESTS) + + report.write_text( + '%s' + % (len(cases) - 1, "".join(cases[:-1])), + encoding="utf-8", + ) + with pytest.raises(gate.FinalGateError, match="appears 0 times"): + gate._require_junit_nodeids( + report, contract.FINAL_EXAMPLE_REQUIRED_TESTS + ) + + report.write_text( + '%s%s' + % (len(cases) + 1, "".join(cases), cases[0]), + encoding="utf-8", + ) + with pytest.raises(gate.FinalGateError, match="appears 2 times"): + gate._require_junit_nodeids( + report, contract.FINAL_EXAMPLE_REQUIRED_TESTS + ) + + +def test_release_preflight_reauthenticates_junit_all_pass_and_exact_nodeids(tmp_path): + cases = [] + for nodeid in contract.FINAL_EXAMPLE_REQUIRED_TESTS: + relative, function_name = nodeid.split("::", 1) + classname = str(Path(relative).with_suffix("")).replace("/", ".") + cases.append( + '' % (classname, function_name) + ) + report = tmp_path / "final-examples.xml" + + def write_report(rows): + report.write_text( + '%s' + % (len(rows), "".join(rows)), + encoding="utf-8", + ) + + def lane(*, tests, failures=0, skips=0): + return { + "path": str(report), + "sha256": hashlib.sha256(report.read_bytes()).hexdigest(), + "tests": tests, + "failures": failures, + "skips_or_xfails": skips, + } + + write_report(cases) + preflight._junit_evidence( + report, + lane(tests=len(cases)), + required_nodeids=contract.FINAL_EXAMPLE_REQUIRED_TESTS, + ) + + write_report(cases[:-1]) + with pytest.raises(preflight.PreflightError, match="appears 0 times"): + preflight._junit_evidence( + report, + lane(tests=len(cases) - 1), + required_nodeids=contract.FINAL_EXAMPLE_REQUIRED_TESTS, + ) + + write_report([*cases, cases[0]]) + with pytest.raises(preflight.PreflightError, match="appears 2 times"): + preflight._junit_evidence( + report, + lane(tests=len(cases) + 1), + required_nodeids=contract.FINAL_EXAMPLE_REQUIRED_TESTS, + ) + + failed = cases[0].replace("/>", ">") + write_report([failed, *cases[1:]]) + with pytest.raises(preflight.PreflightError, match="not all-pass"): + preflight._junit_evidence( + report, + lane(tests=len(cases), failures=1), + required_nodeids=contract.FINAL_EXAMPLE_REQUIRED_TESTS, + ) + + xfailed = cases[0].replace( + "/>", '>' + ) + write_report([xfailed, *cases[1:]]) + with pytest.raises(preflight.PreflightError, match="not all-pass"): + preflight._junit_evidence( + report, + lane(tests=len(cases), skips=1), + required_nodeids=contract.FINAL_EXAMPLE_REQUIRED_TESTS, + ) + + +def test_required_python_lane_makes_xpass_fatal(): + source = (SCRIPTS / "run_final_gate.py").read_text(encoding="utf-8") + + assert '"-o", "xfail_strict=true"' in source + + +def test_required_python_lane_is_the_closed_m4_and_final_example_ledger(): + nodeids = contract.required_python_conformance_nodeids(ROOT) + + assert nodeids + assert nodeids[-len(contract.FINAL_EXAMPLE_REQUIRED_TESTS):] == ( + contract.FINAL_EXAMPLE_REQUIRED_TESTS + ) + assert contract.INSTALLED_COMPONENT_PACKAGE_NODEID not in nodeids + assert len(nodeids) == len(set(nodeids)) + + +def test_release_workflow_does_not_serialize_the_complete_python_suite_twice(): + gate_source = (SCRIPTS / "run_final_gate.py").read_text(encoding="utf-8") + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + + assert '["python", "-m", "pytest", "-q"]' not in gate_source + assert "required_python_conformance_nodeids(ROOT)" in gate_source + assert "timeout-minutes: 180" in workflow + + +def test_release_preflight_requires_the_exact_final_example_test_ledger(): + evidence = { + "final_example_nodeids": list(contract.FINAL_EXAMPLE_REQUIRED_TESTS), + } + + preflight._final_example_test_evidence(evidence) + evidence["final_example_nodeids"] = evidence["final_example_nodeids"][:-1] + with pytest.raises(preflight.PreflightError, match="test ledger drifted"): + preflight._final_example_test_evidence(evidence) + + def test_required_python_lane_rejects_script_style_hidden_skips(): gate._require_no_hidden_skip("42 tests passed") with pytest.raises(gate.FinalGateError, match="hidden skip"): @@ -180,6 +503,133 @@ def test_final_gate_pins_one_conda_environment_and_native_headers( assert "bash" not in command +def test_installed_component_lane_clears_checkout_headers(monkeypatch, tmp_path): + executable = tmp_path / "conda" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + monkeypatch.setenv("POPS_CONDA_EXE", str(executable)) + command = gate._conda_command( + [ + "POPS_PROVE_INSTALLED_COMPONENT_PACKAGE=1", + "python", + "-m", + "pytest", + contract.INSTALLED_COMPONENT_PACKAGE_NODEID, + ], + pops_include=None, + ) + + assert [ + argument for argument in command if argument.startswith("POPS_INCLUDE=") + ] == ["POPS_INCLUDE="] + assert "POPS_PROVE_INSTALLED_COMPONENT_PACKAGE=1" in command + assert str((ROOT / "include").resolve()) not in command + + +def test_installed_component_node_is_real_and_rejects_mock_native_routes(): + relative, node = contract.INSTALLED_COMPONENT_PACKAGE_NODEID.split("::", 1) + source = (ROOT / relative).read_text(encoding="utf-8") + assert "def %s(" % node in source + helper = source.split("def _require_installed_component_package_proof()", 1)[1].split( + "\ndef ", 1 + )[0] + assert "Path(_pops.__file__).resolve()" in helper + assert "importlib.machinery.EXTENSION_SUFFIXES" in helper + assert "_pops.__has_kokkos__ is True" in helper + assert '["schema_version"] == 1' in helper + test_body = source.split("def %s(" % node, 1)[1].split("\ndef ", 1)[0] + assert test_body.index("_require_installed_component_package_proof()") \ + < test_body.index("compile_component(component)") + + +def test_preflight_authenticates_exact_installed_component_lane(tmp_path): + relative, function_name = contract.INSTALLED_COMPONENT_PACKAGE_NODEID.split("::", 1) + classname = str(Path(relative).with_suffix("")).replace("/", ".") + report = tmp_path / "reports" / "installed-component-package.xml" + report.parent.mkdir() + report.write_text( + '' + % (classname, function_name), + encoding="utf-8", + ) + lane = { + "path": str(report), + "sha256": hashlib.sha256(report.read_bytes()).hexdigest(), + "tests": 1, + "failures": 0, + "skips_or_xfails": 0, + } + argv = [ + "/proof/conda", + "run", + "--no-capture-output", + "-n", + "pops", + "/usr/bin/env", + "PYTHONPATH=", + "PYTHONNOUSERSITE=1", + "POPS_REQUIRE_NATIVE_TESTS=1", + "POPS_INCLUDE=", + "POPS_PROVE_INSTALLED_COMPONENT_PACKAGE=1", + "python", + "-m", + "pytest", + "-q", + "-s", + "-o", + "xfail_strict=true", + contract.INSTALLED_COMPONENT_PACKAGE_NODEID, + "--junitxml", + str(report), + ] + row = { + "commands": [{"argv": argv}], + "evidence": { + "installed_component_package": { + "nodeid": contract.INSTALLED_COMPONENT_PACKAGE_NODEID, + "headers": "installed-wheel", + "lane": lane, + }, + }, + } + preflight._installed_component_package_evidence(tmp_path, row) + + source_headers = copy.deepcopy(row) + source_headers["commands"][0]["argv"][ + source_headers["commands"][0]["argv"].index("POPS_INCLUDE=") + ] = "POPS_INCLUDE=/checkout/include" + with pytest.raises(preflight.PreflightError, match="wheel-owned headers"): + preflight._installed_component_package_evidence(tmp_path, source_headers) + + skipped = copy.deepcopy(row) + skipped["evidence"]["installed_component_package"]["lane"]["skips_or_xfails"] = 1 + with pytest.raises(preflight.PreflightError, match="not all-pass"): + preflight._installed_component_package_evidence(tmp_path, skipped) + + renamed = copy.deepcopy(row) + report.write_text( + '' + % classname, + encoding="utf-8", + ) + renamed["evidence"]["installed_component_package"]["lane"]["sha256"] = ( + hashlib.sha256(report.read_bytes()).hexdigest() + ) + with pytest.raises(preflight.PreflightError, match="appears 0 times"): + preflight._installed_component_package_evidence(tmp_path, renamed) + + report.write_text( + '' + % (classname, function_name), + encoding="utf-8", + ) + + duplicate = copy.deepcopy(row) + duplicate["commands"].append(copy.deepcopy(duplicate["commands"][0])) + with pytest.raises(preflight.PreflightError, match="exactly once"): + preflight._installed_component_package_evidence(tmp_path, duplicate) + + def test_final_gate_honours_explicit_conda_executable(monkeypatch, tmp_path): executable = tmp_path / "conda" executable.write_text("#!/bin/sh\nexit 0\n") @@ -194,21 +644,80 @@ def test_final_gate_honours_explicit_conda_executable(monkeypatch, tmp_path): def test_artifact_reopen_requires_and_records_npz(tmp_path): - (tmp_path / "state.h5").write_bytes(b"\x89HDF\r\n\x1a\ncontent") - (tmp_path / "state.vtu").write_text("", encoding="utf-8") - npz = tmp_path / "state.npz" + example = contract.FINAL_EXAMPLES[2] + outputs = contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS[example] + hdf5 = tmp_path / outputs["hdf5"][0]["artifact_root"] / "state.h5" + hdf5.parent.mkdir(parents=True) + hdf5.write_bytes(b"\x89HDF\r\n\x1a\ncontent") + paraview, collection = _write_paraview_series( + tmp_path / outputs["paraview"][0]["artifact_root"] + ) + npz = tmp_path / outputs["npz"][0]["artifact_root"] / "state.npz" + npz.parent.mkdir(parents=True) with zipfile.ZipFile(npz, "w") as archive: archive.writestr("state.npy", b"payload") evidence, hdf5_paths, npz_paths = gate._reopen_outputs( - tmp_path, example=Path("final.py")) + tmp_path, example=example) assert set(evidence) == {"hdf5", "npz", "paraview"} - assert hdf5_paths == (tmp_path / "state.h5",) + assert hdf5_paths == (hdf5,) assert npz_paths == (npz,) + assert {row["path"] for row in evidence["paraview"]} == { + str(paraview.relative_to(tmp_path)), + str(collection.relative_to(tmp_path)), + } npz.unlink() - with pytest.raises(gate.FinalGateError, match="HDF5, NPZ and ParaView"): - gate._reopen_outputs(tmp_path, example=Path("final.py")) + checkpoint = tmp_path / "checkpoints" / "restart" / "state.npz" + checkpoint.parent.mkdir(parents=True) + with zipfile.ZipFile(checkpoint, "w") as archive: + archive.writestr("state.npy", b"checkpoint") + with pytest.raises(gate.FinalGateError, match="npz scientific artifact"): + gate._reopen_outputs(tmp_path, example=example) + + +def test_artifact_reopen_requires_a_nonempty_pvd_collection(tmp_path): + example = contract.FINAL_EXAMPLES[0] + outputs = contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS[example] + hdf5 = tmp_path / outputs["hdf5"][0]["artifact_root"] / "state.h5" + hdf5.parent.mkdir(parents=True) + hdf5.write_bytes(b"\x89HDF\r\n\x1a\ncontent") + _paraview, collection = _write_paraview_series( + tmp_path / outputs["paraview"][0]["artifact_root"] + ) + collection.unlink() + + with pytest.raises(gate.FinalGateError, match=r"\.pvd"): + gate._reopen_outputs(tmp_path, example=example) + + collection.write_text( + '' + '' + "", + encoding="utf-8", + ) + with pytest.raises(gate.FinalGateError, match="absent or escaping VTU"): + gate._reopen_outputs(tmp_path, example=example) + + +def test_artifact_reopen_does_not_label_checkpoint_npz_as_scientific_output(tmp_path): + example = contract.FINAL_EXAMPLES[0] + outputs = contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS[example] + hdf5 = tmp_path / outputs["hdf5"][0]["artifact_root"] / "state.h5" + hdf5.parent.mkdir(parents=True, exist_ok=True) + hdf5.write_bytes(b"\x89HDF\r\n\x1a\ncontent") + _write_paraview_series(tmp_path / outputs["paraview"][0]["artifact_root"]) + checkpoint = tmp_path / "checkpoints" / "restart" / "state.npz" + checkpoint.parent.mkdir(parents=True) + with zipfile.ZipFile(checkpoint, "w") as archive: + archive.writestr("state.npy", b"checkpoint") + + evidence, _hdf5_paths, npz_paths = gate._reopen_outputs( + tmp_path, example=example + ) + + assert evidence["npz"] == [] + assert npz_paths == () def test_release_evidence_authenticates_the_exact_retained_wheel(tmp_path): @@ -233,6 +742,80 @@ def test_release_evidence_authenticates_the_exact_retained_wheel(tmp_path): preflight._wheel_evidence(tmp_path, gates, release) +def _write_public_api_evidence(tmp_path: Path) -> tuple[Path, dict, object]: + package = tmp_path / "site-packages" / "pops" + wheel_sha256 = "a" * 64 + typed_sha256 = "b" * 64 + public_sha256 = "c" * 64 + metadata_sha256 = "d" * 64 + payload = { + "schema_version": preflight.PUBLIC_API_EVIDENCE_SCHEMA_VERSION, + "producer": { + "script": "scripts/prove_public_api_parity.py", + "sha256": hashlib.sha256( + (SCRIPTS / "prove_public_api_parity.py").read_bytes() + ).hexdigest(), + }, + "wheel_path": str(tmp_path / "pops.whl"), + "wheel_sha256": wheel_sha256, + "distribution": { + "name": "PoPS", + "version": "1.0.0", + "metadata_sha256": metadata_sha256, + }, + "typed_payload_files": 3, + "typed_payload_sha256": typed_sha256, + "public_api_sha256": public_sha256, + "public_names": ["Model", "Program", "Case"], + "pure_authoring": True, + "qualified_handles": True, + "py_typed": True, + "installed": True, + "installed_distribution": { + "name": "PoPS", + "version": "1.0.0", + "metadata_sha256": metadata_sha256, + }, + "installed_package": str(package), + "installed_typed_payload_sha256": typed_sha256, + "installed_public_api_sha256": public_sha256, + } + path = tmp_path / "public-api-evidence.json" + path.write_text(json.dumps(payload), encoding="utf-8") + release_evidence = { + "runtime": {"pops_file": str(package / "__init__.py")}, + "gates": { + "official_build": { + "evidence": {"wheel": {"sha256": wheel_sha256}}, + }, + }, + } + release = type("ReleaseContract", (), {"PACKAGE_VERSION": "1.0.0"}) + return path, release_evidence, release + + +def test_release_preflight_binds_installed_public_api_to_wheel_and_runtime(tmp_path): + evidence, release_evidence, release = _write_public_api_evidence(tmp_path) + + preflight._public_api_evidence(evidence, release_evidence, release) + + payload = json.loads(evidence.read_text(encoding="utf-8")) + payload["wheel_sha256"] = "e" * 64 + evidence.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(preflight.PreflightError, match="another wheel"): + preflight._public_api_evidence(evidence, release_evidence, release) + + +def test_release_preflight_rejects_public_api_proven_on_another_install(tmp_path): + evidence, release_evidence, release = _write_public_api_evidence(tmp_path) + payload = json.loads(evidence.read_text(encoding="utf-8")) + payload["installed_package"] = str(tmp_path / "other" / "pops") + evidence.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(preflight.PreflightError, match="authenticated installed runtime"): + preflight._public_api_evidence(evidence, release_evidence, release) + + @pytest.mark.parametrize( ("filename", "tag", "purelib", "message"), ( @@ -598,13 +1181,22 @@ def test_release_preflight_requires_exact_runtime_bound_example_commands(tmp_pat key = example.as_posix() output_root = tmp_path / "examples" / example.stem output_root.mkdir(parents=True) - hdf5 = output_root / "state.h5" + targets = contract.FINAL_EXAMPLE_SCIENTIFIC_OUTPUTS[example] + hdf5 = output_root / targets["hdf5"][0]["artifact_root"] / "state.h5" + hdf5.parent.mkdir(parents=True) hdf5.write_bytes(b"\x89HDF\r\n\x1a\npayload") - npz = output_root / "state.npz" - with zipfile.ZipFile(npz, "w") as archive: - archive.writestr("state.npy", b"payload") - paraview = output_root / "state.vtu" - paraview.write_text("", encoding="utf-8") + npz = ( + output_root / targets["npz"][0]["artifact_root"] / "state.npz" + if targets["npz"] + else None + ) + if npz is not None: + npz.parent.mkdir(parents=True) + with zipfile.ZipFile(npz, "w") as archive: + archive.writestr("state.npy", b"payload") + paraview, collection = _write_paraview_series( + output_root / targets["paraview"][0]["artifact_root"] + ) checkpoint = output_root / "checkpoint.bin" checkpoint.write_bytes(b"restart") transcript = "\n".join( @@ -644,21 +1236,29 @@ def test_release_preflight_requires_exact_runtime_bound_example_commands(tmp_pat reopened[key] = { "hdf5": [ { - "path": hdf5.name, + "path": str(hdf5.relative_to(output_root)), "sha256": hashlib.sha256(hdf5.read_bytes()).hexdigest(), } ], - "npz": [ - { - "path": npz.name, - "sha256": hashlib.sha256(npz.read_bytes()).hexdigest(), - } - ], + "npz": ( + [ + { + "path": str(npz.relative_to(output_root)), + "sha256": hashlib.sha256(npz.read_bytes()).hexdigest(), + } + ] + if npz is not None + else [] + ), "paraview": [ { - "path": paraview.name, + "path": str(paraview.relative_to(output_root)), "sha256": hashlib.sha256(paraview.read_bytes()).hexdigest(), - } + }, + { + "path": str(collection.relative_to(output_root)), + "sha256": hashlib.sha256(collection.read_bytes()).hexdigest(), + }, ], } restarted[key] = { @@ -673,6 +1273,37 @@ def test_release_preflight_requires_exact_runtime_bound_example_commands(tmp_pat } preflight._examples_evidence(tmp_path, gates, runtime) + checkpoint_as_npz = copy.deepcopy(gates) + first_key = contract.FINAL_EXAMPLES[0].as_posix() + first_checkpoint = Path(restarted[first_key]["checkpoint"]) + checkpoint_as_npz["artifact_reopen"]["evidence"]["examples"][first_key]["npz"] = [ + { + "path": str(first_checkpoint.relative_to(first_checkpoint.parents[1])), + "sha256": hashlib.sha256(first_checkpoint.read_bytes()).hexdigest(), + } + ] + with pytest.raises(preflight.PreflightError, match="output coverage drifted"): + preflight._examples_evidence(tmp_path, checkpoint_as_npz, runtime) + + imex_key = contract.FINAL_EXAMPLES[2].as_posix() + escaped_npz = copy.deepcopy(gates) + escaped_npz["artifact_reopen"]["evidence"]["examples"][imex_key]["npz"][0][ + "path" + ] = "checkpoints/restart/state.npz" + with pytest.raises(preflight.PreflightError, match="escaped its exact artifact root"): + preflight._examples_evidence(tmp_path, escaped_npz, runtime) + + missing_pvd = copy.deepcopy(gates) + missing_pvd["artifact_reopen"]["evidence"]["examples"][first_key]["paraview"] = [ + artifact + for artifact in missing_pvd["artifact_reopen"]["evidence"]["examples"][ + first_key + ]["paraview"] + if Path(artifact["path"]).suffix != ".pvd" + ] + with pytest.raises(preflight.PreflightError, match=r"lacks.*\.pvd"): + preflight._examples_evidence(tmp_path, missing_pvd, runtime) + commands[0]["argv"][commands[0]["argv"].index(runtime["native_sha256"])] = "d" * 64 with pytest.raises(preflight.PreflightError, match="command drifted"): preflight._examples_evidence(tmp_path, gates, runtime) diff --git a/tests/python/architecture/test_flux_interface_fences.py b/tests/python/architecture/test_flux_interface_fences.py index 1083f28e5..b8248d8c5 100644 --- a/tests/python/architecture/test_flux_interface_fences.py +++ b/tests/python/architecture/test_flux_interface_fences.py @@ -1,4 +1,5 @@ """ADC-682 fences for the final PhysicalFlux/NumericalFlux/SpatialOperator split.""" +import json from pathlib import Path import re @@ -48,6 +49,29 @@ def test_bound_native_flux_pack_is_exact_and_does_not_store_global_aux(): assert "FluxDensity checked_density() const" in header +def test_physical_flux_consumes_the_exact_pack_without_reconstructing_aux(): + header = _behavior(ROOT / "include/pops/numerics/fv/flux_interfaces.hpp") + physical = header.split("struct PhysicalFluxView", 2)[2].split("template ", 1)[0] + assert "physical_providers" not in physical + assert "Aux result" not in physical + assert "const Aux" not in physical + assert "trace.providers" in physical + assert "left.providers" in physical + assert "right.providers" in physical + + emitter = (ROOT / "python/pops/codegen/module_emit_brick.py").read_text(encoding="utf-8") + assert 'aux_param = "const auto& a"' in emitter + assert "_flux_provider_locals_lines" in emitter + + +def test_generated_flux_pack_metadata_controls_native_storage_reads(): + header = _behavior(ROOT / "include/pops/numerics/fv/flux_interfaces.hpp") + assert "qualified_flux_provider_requirements_valid" in header + assert "qualified_flux_provider_storage_slot" in header + assert "std::make_index_sequence" in header + assert "generated physical flux provider requirements are invalid" in header + + def test_provider_selection_is_qualified_and_never_returns_a_neutral_value(): source = (ROOT / "python/pops/model/provider_pack.py").read_text(encoding="utf-8") assert "def select(" in source @@ -55,3 +79,98 @@ def test_provider_selection_is_qualified_and_never_returns_a_neutral_value(): assert "owner_qid" in source assert "return 0" not in source assert "return 0.0" not in source + + +def test_hllc_rejects_nonfinite_provider_stages_before_publication(): + policy = _behavior(ROOT / "include/pops/numerics/fv/numerical_flux.hpp") + interface = _behavior(ROOT / "include/pops/numerics/fv/flux_interfaces.hpp") + hllc = policy.split("struct HLLCFlux", 1)[1].split( + "concept RoePhysicalFlux", 1 + )[0] + causes = ( + "kHllcNonFinitePhysicalFlux", + "kHllcNonFinitePressure", + "kHllcNonFiniteContact", + "kHllcNonFiniteStarState", + "kHllcNonFiniteFlux", + ) + + for cause in causes: + assert cause in interface + assert cause in hllc + assert hllc.count("detail::finite_state") >= 6 + assert hllc.count("Kokkos::isfinite") >= 2 + + +def test_capability_driven_riemann_has_no_euler_specific_production_authority(): + production_roots = (ROOT / "include/pops", ROOT / "src", ROOT / "python/pops") + sources = ( + path + for root in production_roots + for path in root.rglob("*") + if path.suffix in {".hpp", ".cpp", ".py"} + ) + production = "\n".join(path.read_text(encoding="utf-8") for path in sources) + + for retired_authority in ( + "EulerHLLCFlux2D", + "EulerRoeFlux2D", + "euler_hllc", + "euler_roe", + ): + assert retired_authority not in production + + +def test_polar_riemann_dispatch_uses_model_capabilities_not_a_coordinate_allowlist(): + builder = _behavior( + ROOT / "include/pops/runtime/builders/block/block_builder_polar.hpp" + ) + catalog = json.loads( + (ROOT / "schemas/component_catalog.v2.json").read_text(encoding="utf-8") + ) + + assert "case RiemannRouteId::kHllc" in builder + assert "if constexpr (HasHLLCStructure)" in builder + assert "case RiemannRouteId::kRoe" in builder + assert "if constexpr (HasRoeDissipation)" in builder + assert "no fallback" in builder + riemann = next( + family for family in catalog["route_families"] if family["name"] == "riemann" + ) + routes = {route["token"]: route for route in riemann["routes"]} + assert routes["hllc"]["metadata"]["polar_ok"] is True + assert routes["roe"]["metadata"]["polar_ok"] is True + + +def test_fixed_riemann_recovery_route_is_wired_for_cartesian_uniform_and_amr_only(): + catalog = json.loads( + (ROOT / "schemas/component_catalog.v2.json").read_text(encoding="utf-8") + ) + riemann = next( + family for family in catalog["route_families"] if family["name"] == "riemann" + ) + route = next( + row for row in riemann["routes"] + if row["token"] == "roe_hll_rusanov_recovery" + ) + uniform = _behavior(ROOT / "include/pops/runtime/builders/block/block_builder.hpp") + amr = _behavior(ROOT / "include/pops/runtime/builders/compiled/amr_dsl_block.hpp") + system_install = _behavior(ROOT / "src/runtime/system/system_install.cpp") + amr_compressible = _behavior( + ROOT / "src/runtime/builders/amr/block/compressible/amr_block_compressible.cpp" + ) + policy = _behavior(ROOT / "include/pops/numerics/fv/numerical_flux.hpp") + + assert route["native_entry"] == ( + "pops::PreparedRiemannRecoveryPolicy" + ) + assert route["metadata"]["polar_ok"] is False + assert "using RoeHllRusanovRecoveryPolicy" in policy + assert "case RiemannRouteId::kRoeHllRusanovRecovery" in uniform + assert "build_block" in uniform + assert "case RiemannRouteId::kRoeHllRusanovRecovery" in amr + assert "build_amr_block" in amr + assert 'wave_speed_cache && riem != "hll"' in uniform + assert system_install.count('wave_speed_cache && riemann != "hll"') == 2 + assert 'wave_speed_cache && a.riemann != "hll"' in amr_compressible diff --git a/tests/python/architecture/test_hdf5_observer_lane_fence.py b/tests/python/architecture/test_hdf5_observer_lane_fence.py new file mode 100644 index 000000000..ee07cd388 --- /dev/null +++ b/tests/python/architecture/test_hdf5_observer_lane_fence.py @@ -0,0 +1,33 @@ +"""ADC-683 fences for observer-owned collective HDF5 communication.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +HEADER = ROOT / "include/pops/runtime/output/hdf5_collective.hpp" +SOURCE = ROOT / "src/runtime/output/hdf5_collective.cpp" +BINDING = ROOT / "python/bindings/core/init/init_parallel_hdf5.cpp" +WRITER = ROOT / "python/pops/output/_writers/hdf5.py" + + +def test_native_hdf5_surface_has_no_process_world_overload_or_probe(): + header = HEADER.read_text(encoding="utf-8") + source = SOURCE.read_text(encoding="utf-8") + + assert "WorldCommunicator" not in header + assert "WorldCommunicator" not in source + assert "world_communicator.hpp" not in source + assert "MPI_COMM_WORLD" not in source + assert "MPI_Initialized" not in source + assert "const CommunicatorView& communicator" in header + + +def test_python_hdf5_route_requires_a_duplicated_observer_lane(): + binding = BINDING.read_text(encoding="utf-8") + writer = WRITER.read_text(encoding="utf-8") + + assert "WorldCommunicator" not in binding + assert "world_communicator.hpp" not in binding + assert "py::isinstance" in binding + assert "requires an exact duplicated observer MPI lane" in binding + assert "require_communicator(communicator, allow_world=False)" in writer diff --git a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py new file mode 100644 index 000000000..c088ac7ad --- /dev/null +++ b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py @@ -0,0 +1,231 @@ +"""ADC-749/757: one prepared native transport-boundary authority remains.""" + +from __future__ import annotations + +import ast +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PRODUCTION_ROOTS = (ROOT / "include/pops", ROOT / "src/runtime") + +# These names denoted executable authorities parallel to PreparedBoundaryPlan. +# Closure is a zero-occurrence invariant across production, not a count ledger. +DELETED_LEGACY_AUTHORITIES = ( + "AmrBoundaryFillAuthority", + "make_amr_boundary_fill_authority", + "transport_boundary_fill", + "transport_bc", + "wall_radial", + "fill_ghosts_polar", +) + + +def _production_sources() -> tuple[Path, ...]: + return tuple( + sorted( + path + for root in PRODUCTION_ROOTS + for path in root.rglob("*") + if path.suffix in {".cpp", ".hpp"} + ) + ) + + +def _occurrences() -> dict[str, dict[str, int]]: + patterns = { + identifier: re.compile(r"\b%s\b" % re.escape(identifier)) + for identifier in DELETED_LEGACY_AUTHORITIES + } + counts = {identifier: {} for identifier in patterns} + for path in _production_sources(): + source = path.read_text(encoding="utf-8") + relative = path.relative_to(ROOT).as_posix() + for identifier, pattern in patterns.items(): + count = len(pattern.findall(source)) + if count: + counts[identifier][relative] = count + return counts + + +def test_legacy_transport_boundary_authorities_are_deleted() -> None: + occurrences = _occurrences() + violations = [ + "%s: %s has %d occurrence(s)" % (identifier, path, count) + for identifier, paths in occurrences.items() + for path, count in paths.items() + ] + assert not violations, ( + "a deleted transport-boundary authority returned; lower the route to " + "PreparedBoundaryPlan instead:\n " + "\n ".join(violations) + ) + + +def test_prepared_boundary_plan_is_the_only_native_transport_authority() -> None: + polar_builder = ( + ROOT / "include/pops/runtime/builders/block/block_builder_polar.hpp" + ).read_text(encoding="utf-8") + polar_operator = ( + ROOT / "include/pops/numerics/spatial/operators/polar_operator.hpp" + ).read_text(encoding="utf-8") + amr_runtime = (ROOT / "include/pops/runtime/amr/amr_runtime.hpp").read_text( + encoding="utf-8" + ) + + assert "build_block_polar requires a prepared boundary plan" in polar_builder + assert "boundary_plan->fill_same_level_and_physical" in polar_builder + assert "boundary_plan.zeroes_face(0, -1)" in polar_operator + assert "boundary_plan.zeroes_face(0, 1)" in polar_operator + assert "boundary_plan.has_component_boundaries()" in polar_operator + assert "boundary_plan.has_omitted_faces()" in polar_operator + system_install = (ROOT / "src/runtime/system/system_install.cpp").read_text( + encoding="utf-8" + ) + for operation in ( + "install_ghost_boundary_component", + "install_boundary_flux_component", + "install_field_boundary_residual_component", + "install_field_boundary_jvp_component", + ): + body = system_install[system_install.index(f"System::{operation}") :] + body = body[: body.index("\n}")] + assert "if (P->polar_)" in body + assert "block.boundary_plan->fills_all_allocated_physical_ghosts()" in amr_runtime + assert ( + "non-periodic AMR regrid requires a prepared boundary authority for every block" + in amr_runtime + ) + + +def test_resolved_transport_authority_accepts_only_executable_descriptors() -> None: + """Numerical resolution, rather than a later compile/bind phase, owns acceptance.""" + source = ROOT / "python/pops/boundary/transport.py" + tree = ast.parse(source.read_text(encoding="utf-8")) + authority = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "ResolvedTransportBoundarySet" + ) + post_init = next( + node for node in authority.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "__post_init__" + ) + calls = { + node.func.attr + for node in ast.walk(post_init) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) and node.func.value.id == "self" + } + assert "_native_contract" in calls, ( + "ResolvedTransportBoundarySet must authenticate the complete executable boundary " + "contract during numerical resolution; do not defer unsupported descriptors to compile" + ) + + +def test_boundary_provider_identity_cannot_erase_its_selected_law() -> None: + """Every provider identity must retain the law selected by its public factory.""" + source = ROOT / "python/pops/mesh/boundaries/providers.py" + tree = ast.parse(source.read_text(encoding="utf-8")) + provider = next( + node for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "BoundaryProvider" + ) + annotations = { + node.target.id + for node in provider.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + } + assert "kind" in annotations, "BoundaryProvider must retain one immutable typed law" + canonical = next( + node for node in provider.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "canonical_identity" + ) + keys = { + node.value for node in ast.walk(canonical) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + assert "provider_kind" in keys, ( + "BoundaryProvider canonical identity must authenticate its selected law" + ) + + +def test_post_riemann_flux_is_one_typed_outward_oriented_pipeline_stage() -> None: + catalog = json.loads( + (ROOT / "schemas/component_catalog.v2.json").read_text(encoding="utf-8") + ) + interface = next( + row for row in catalog["native_interface_abis"] + if row["name"] == "boundary_flux" + ) + assert interface["cpp_table"] == "PopsBoundaryFluxApiV1" + assert interface["operations"] == ["transform_faces"] + route = catalog["boundary_handle_native_routes"]["boundary_flux_provider"] + assert route["interface"] == "boundary_flux" + assert route["operation"] == "transform_faces" + + executor = ( + ROOT / "include/pops/mesh/boundary/boundary_component_executor.hpp" + ).read_text(encoding="utf-8") + assert re.search( + r"const double outward\s*=\s*static_cast\(workspace\.side\)\s*\*", + executor, + ) + assert re.search( + r"static_cast\(static_cast\(workspace\.side\)\s*\*\s*outward\)", + executor, + ) + + uniform = ( + ROOT / "include/pops/runtime/builders/block/block_builder.hpp" + ).read_text(encoding="utf-8") + uniform_stage = uniform[ + uniform.index("assemble_rhs_without_prepared_interfaces"): + uniform.index("struct BlockRhsEval") + ] + assert uniform_stage.index("compute_face_fluxes") < uniform_stage.index( + "transform_grid_boundary_fluxes" + ) < uniform_stage.index("mf_eval_rhs") + unqualified = uniform[ + uniform.index("void operator()(MultiFab& U, MultiFab& R) const"): + uniform.index( + "void operator()(const runtime::multiblock::BoundaryEvaluationPoint& point", + ) + ] + assert "has_flux_transformations()" in unqualified + assert "requires a BoundaryEvaluationPoint" in unqualified + assert "has_omitted_faces()" in unqualified + assert "shared-interface flux requires BoundaryEvaluationPoint group authority" in unqualified + assert "eval_core_filled(U, R);" in unqualified + + amr = ( + ROOT / "include/pops/runtime/builders/compiled/amr_dsl_block.hpp" + ).read_text(encoding="utf-8") + first_flux = amr.index("detail::compute_amr_face_fluxes") + first_transform = amr.index("transform_grid_boundary_fluxes", first_flux) + first_divergence = amr.index("pops::mf_eval_rhs", first_transform) + assert first_flux < first_transform < first_divergence + + +def test_no_flux_is_a_builtin_face_law_of_the_same_prepared_pipeline() -> None: + transport = (ROOT / "python/pops/boundary/transport.py").read_text(encoding="utf-8") + hyperbolic = ( + ROOT / "include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp" + ).read_text(encoding="utf-8") + plan = ( + ROOT / "include/pops/mesh/boundary/prepared_boundary_plan.hpp" + ).read_text(encoding="utf-8") + operator = ( + ROOT / "include/pops/runtime/builders/block/block_builder.hpp" + ).read_text(encoding="utf-8") + + assert 'condition_type: ClassVar[str] = "no_flux"' in transport + assert '"no_flux": LowLevelNoFlux' in transport + assert 'token == "no_flux"' in hyperbolic + assert "HyperbolicBoundaryLaw::NoFlux" in plan + assert "zero_prepared_boundary_fluxes" in operator + assert "has_zero_flux_faces()" in operator + assert operator.count("prepared_boundary_face_omission(ctx)") >= 2 + assert operator.count("PreparedBoundaryFluxFilter{&ctx}") >= 2 diff --git a/tests/python/architecture/test_import_graph.py b/tests/python/architecture/test_import_graph.py index 0721ae577..428a03691 100644 --- a/tests/python/architecture/test_import_graph.py +++ b/tests/python/architecture/test_import_graph.py @@ -15,7 +15,7 @@ mesh -> analytic, domain, frames, identity, model, params amr -> _ir, identity, mesh, model, time layouts -> amr, mesh - boundary -> _ir, domain, identity, model, representations + boundary -> _ir, analytic, domain, identity, model, representations numerics -> identity, model, params linalg -> (nothing) (Spec 5: abstract algebra descriptors) solvers -> identity (typed solver descriptor sink) @@ -63,7 +63,7 @@ "mesh": {"analytic", "domain", "frames", "identity", "model", "params"}, "amr": {"_ir", "identity", "mesh", "model", "time"}, "layouts": {"amr", "mesh"}, - "boundary": {"_ir", "domain", "identity", "model", "representations"}, + "boundary": {"_ir", "analytic", "domain", "identity", "model", "representations"}, "numerics": {"identity", "model", "params"}, "solvers": {"identity"}, "fields": {"_ir", "identity", "model", "time"}, diff --git a/tests/python/architecture/test_m2_temporal_execution_gate.py b/tests/python/architecture/test_m2_temporal_execution_gate.py index 8251124eb..55a55c328 100644 --- a/tests/python/architecture/test_m2_temporal_execution_gate.py +++ b/tests/python/architecture/test_m2_temporal_execution_gate.py @@ -26,7 +26,7 @@ def _load_runner(): def test_m2_manifest_references_only_real_mandatory_proofs(): data, errors = _load_runner().validate_manifest(MANIFEST) assert not errors, "M2 gate matrix is incomplete:\n " + "\n ".join(errors) - assert len(data["check"]) == 34 + assert len(data["check"]) == 39 def test_m2_final_gate_has_no_deferred_requirement(): @@ -117,6 +117,61 @@ def test_m2_pytest_nodeids_are_individually_collectible(): assert process_collected == set() +def test_m2_adc667_history_and_migration_routes_use_exact_proofs(): + data, errors = _load_runner().validate_manifest(MANIFEST) + assert not errors + checks = { + (row["target"], row["polarity"], row["nodeid"]) + for row in data["check"] + if row["issue"] == "ADC-667" + and row["requirement"] == "temporal_restart" + } + assert checks == { + ( + "transaction", + "positive", + "tests/python/unit/runtime/test_temporal_restart_state.py" + "::test_accepted_attempt_advances_cursor_and_round_trips_exact_controller_state", + ), + ( + "transaction", + "refusal", + "tests/python/unit/runtime/test_temporal_restart_state.py" + "::test_rejection_preserves_native_cursor_and_makes_checkpoint_ineligible", + ), + ( + "schedule", + "positive", + "tests/python/unit/time/test_multirate_history_contract.py" + "::test_history_interpolation_is_an_explicit_cross_clock_provider", + ), + ( + "restart", + "positive", + "tests/python/unit/runtime/test_temporal_restart_state.py" + "::test_uniform_child_clock_history_owns_exact_slot_ledger_across_restart", + ), + ( + "schedule", + "refusal", + "tests/python/unit/time/test_multirate_history_contract.py" + "::test_cross_clock_extension_without_provider_is_rejected", + ), + ( + "restart", + "positive", + "tests/python/unit/codegen/test_checkpoint_migration.py" + "::test_true_frozen_v2_migrates_and_strict_uniform_restart_accepts", + ), + ( + "restart", + "refusal", + "tests/python/unit/runtime/test_temporal_restart_state.py" + "::test_frozen_release_v2_fixture_is_refused_offline_and_at_runtime_boundary", + ), + } + + def test_m2_restart_hierarchy_and_program_only_routes_use_real_exact_proofs(): data, errors = _load_runner().validate_manifest(MANIFEST) assert not errors diff --git a/tests/python/architecture/test_m3_amr_multilayout_gate.py b/tests/python/architecture/test_m3_amr_multilayout_gate.py index 5d25853a6..0e89ff6df 100644 --- a/tests/python/architecture/test_m3_amr_multilayout_gate.py +++ b/tests/python/architecture/test_m3_amr_multilayout_gate.py @@ -27,7 +27,7 @@ def _load_runner(): def test_m3_manifest_references_only_real_mandatory_proofs(): data, errors = _load_runner().validate_manifest(MANIFEST) assert not errors, "M3 gate matrix is incomplete:\n " + "\n ".join(errors) - assert len(data["check"]) == 41 + assert len(data["check"]) == 43 def test_m3_gate_pins_three_level_subcycled_reflux_proof(): @@ -89,6 +89,32 @@ def test_m3_gate_pins_accepted_interface_ledger_restart_proof(): ) in source.read_text(encoding="utf-8") +def test_m3_gate_pins_qualified_field_warm_start_restart_and_rollback(): + data, errors = _load_runner().validate_manifest(MANIFEST) + assert not errors + nodeid = ( + "tests/python/integration/amr/test_amr_composite_field_carrier.py::" + "test_fac_overrides_propagate_through_a_refined_final_root_lifecycle" + ) + assert { + "issue": "ADC-678", + "requirement": "accepted_state", + "polarity": "positive", + "kind": "pytest", + "target": "accepted_state", + "nodeid": nodeid, + } in data["check"] + + source = ( + ROOT / "tests/python/integration/amr/test_amr_composite_field_carrier.py" + ).read_text(encoding="utf-8") + assert "accepted_warm_starts = _field_warm_starts(simulation, slot)" in source + assert "resolved = _resolve(solver, strict_restart=True)" in source + assert "restarted.restart(checkpoint)" in source + assert "injected post-field-restore validation failure" in source + assert "np.testing.assert_array_equal(actual, expected)" in source + + def test_m3_gate_pins_transactional_persistent_hysteresis_proofs(): data, errors = _load_runner().validate_manifest(MANIFEST) assert not errors @@ -115,6 +141,17 @@ def test_m3_gate_pins_transactional_persistent_hysteresis_proofs(): "test_regridded_contract_authenticates_transformed_topology_and_level_axes" ), } in checks + assert { + "issue": "ADC-678", + "requirement": "accepted_state", + "polarity": "refusal", + "kind": "pytest", + "target": "accepted_state", + "nodeid": ( + "tests/python/unit/amr/test_external_amr_providers.py::" + "test_external_tagger_requires_exact_candidate_program_capability" + ), + } in checks assert { "issue": "ADC-678", "requirement": "accepted_state", @@ -126,6 +163,15 @@ def test_m3_gate_pins_transactional_persistent_hysteresis_proofs(): ), } in checks + provider_source = ( + ROOT / "tests/python/unit/amr/test_external_amr_providers.py" + ).read_text(encoding="utf-8") + assert "external AMR Tagger persistent_hysteresis is not implemented" in provider_source + runtime_source = ( + ROOT / "tests/cpp/integration/amr/test_amr_multiblock_regrid_union.cpp" + ).read_text(encoding="utf-8") + assert "check_persistent_tagging_hysteresis_and_rollback()" in runtime_source + assert "check_persistent_tagging_equality_at_inclusive_boundary()" in runtime_source restart_source = ( ROOT / "tests/python/integration/amr/test_amr_regrid_on_restart.py" ).read_text(encoding="utf-8") @@ -278,6 +324,19 @@ def test_m3_mpi_python_proof_is_exact_and_manifest_owned(monkeypatch): ), "nproc": 2, } in checks + restart_mpi_source = ( + ROOT / "tests/python/integration/mpi/test_amr_regrid_on_restart_mpi.py" + ).read_text(encoding="utf-8") + assert "injected rank-local pre-collective validation failure" in restart_mpi_source + assert "all(allgather_value(_COMM, caught))" in restart_mpi_source + assert "_restart_accepted_contract_identity" in restart_mpi_source + assert 'receipt["history_consensus_identity_before"]' in restart_mpi_source + assert "both AB2 histories are conservatively rematerialized" in restart_mpi_source + program_context = ( + ROOT / "include/pops/runtime/program/amr_program_context.hpp" + ).read_text(encoding="utf-8") + assert "AMR RegridOnRestart requires a clean accepted Program boundary" in program_context + assert "supports shared-interface flux groups only in serial" not in program_context assert { "issue": "ADC-678", "requirement": "accepted_state", diff --git a/tests/python/architecture/test_m4_runtime_io_gate.py b/tests/python/architecture/test_m4_runtime_io_gate.py new file mode 100644 index 000000000..b785d1472 --- /dev/null +++ b/tests/python/architecture/test_m4_runtime_io_gate.py @@ -0,0 +1,866 @@ +"""Source-only integrity checks for the executable M4 runtime/IO gate.""" + +from __future__ import annotations + +import ast +import importlib.util +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +MANIFEST = ROOT / "tests/gates/m4_runtime_io.toml" +RUNNER = ROOT / "scripts/run_m4_gate.py" + + +def _load_runner(): + spec = importlib.util.spec_from_file_location("pops_run_m4_gate", RUNNER) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _mutated_manifest(tmp_path: Path, old: str, new: str) -> Path: + source = MANIFEST.read_text(encoding="utf-8") + assert old in source + path = tmp_path / "m4.toml" + path.write_text(source.replace(old, new, 1), encoding="utf-8") + return path + + +def test_m4_manifest_is_a_closed_exact_matrix(): + runner = _load_runner() + data, errors = runner.audit_manifest(MANIFEST) + + assert not errors, "M4 gate audit is structurally invalid:\n " + "\n ".join(errors) + assert data["deferred"] == [] + assert len(data["check"]) == 55 + assert data["issues"] == [ + "ADC-679", + "ADC-680", + "ADC-681", + "ADC-682", + "ADC-683", + "ADC-684", + "ADC-685", + "ADC-686", + "ADC-687", + ] + assert {row["issue"] for row in data["check"]} == set(data["issues"]) + assert { + (row["issue"], row["requirement"], row["polarity"]) + for row in data["deferred"] + } == set() + + _, closure_errors = runner.validate_manifest(MANIFEST) + assert closure_errors == [] + + +def test_m4_cli_reports_closed_and_check_only_accepts_source_contract(): + audit = subprocess.run( + [sys.executable, str(RUNNER), "--audit-only"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert audit.returncode == 0 + assert "M4 gate source matrix: AUDITED CLOSED" in audit.stdout + + closure = subprocess.run( + [sys.executable, str(RUNNER), "--check-only"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert closure.returncode == 0 + assert "M4 gate source matrix: CLOSED" in closure.stdout + + +def test_m4_required_ci_lane_executes_the_complete_installed_gate(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + nodeid = ( + "tests/python/architecture/test_m4_runtime_io_gate.py::" + "test_m4_required_ci_lane_executes_the_complete_installed_gate" + ) + assert [ + row for row in data["check"] if row.get("nodeid") == nodeid + ] == [{ + "issue": "ADC-687", + "requirement": "gate_execution", + "polarity": "positive", + "kind": "pytest", + "target": "gate_execution", + "nodeid": nodeid, + }] + + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + mpi_job = workflow.split("\n mpi:\n", 1)[1] + mpi_job = mpi_job.split("\n gate-openmp-prewarm:\n", 1)[0] + assert "if: needs.set-mode.outputs.mpi_required == 'true'" in mpi_job + assert "python3-vtk9" in mpi_job + assert "/usr/bin/python3 scripts/run_m4_gate.py --list-ctest-targets" in mpi_job + assert 'cmake --build --preset ci-mpi --parallel 4 --target "${m4_targets[@]}"' in mpi_job + + complete = mpi_job.split( + "- name: M4 complete native runtime and scientific I/O gate", 1 + )[1] + complete = complete.split("- name: ccache stats (MPI)", 1)[0] + assert "POPS_REQUIRE_MPI_TESTS: \"1\"" in complete + assert "POPS_REQUIRE_NATIVE_TESTS: \"1\"" in complete + assert 'OMPI_MCA_io: "^ompio"' in complete + assert "vtkXMLPUnstructuredGridReader" in complete + assert "vtkXMLUnstructuredGridReader" in complete + assert "/usr/bin/python3 scripts/run_m4_gate.py \\" in complete + assert "--build-dir build-mpi" in complete + assert "--mpi-exec mpiexec" in complete + assert "--audit-only" not in complete + assert "--python-only" not in complete + assert "continue-on-error" not in complete + + aggregator = workflow.split("\n gate:\n", 1)[1] + aggregator = aggregator.split("\n mpi:\n", 1)[0] + assert "mpi" in aggregator.split("needs:", 1)[1].splitlines()[0] + assert '--gate mpi "${{ needs.mpi.result }}"' in aggregator + assert '"${{ needs.set-mode.outputs.mpi_required }}"' in aggregator + + mpi_filter = workflow.split("\n mpi:\n", 1)[1] + mpi_filter = mpi_filter.split("\n # full", 1)[0] + for protected_path in ( + "tests/gates/m4_runtime_io.toml", + "tests/python/architecture/test_m4_runtime_io_gate.py", + "scripts/run_m4_gate.py", + ".github/workflows/ci.yml", + ): + assert "'%s'" % protected_path in mpi_filter + + +def test_m4_closed_gate_lists_every_exact_native_build_target(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + expected = ( + "test_amr_native_loader", + "test_brick_catalog", + "test_component_interfaces", + "test_flux_interfaces", + "test_mpi_hdf5_collective", + "test_native_loader_param_overflow", + "test_platform_manifest", + "test_program_context_contract", + "test_program_runtime", + ) + assert runner._required_ctest_targets(data["check"]) == expected + + listed = subprocess.run( + [sys.executable, str(RUNNER), "--list-ctest-targets"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + assert listed.returncode == 0 + assert tuple(listed.stdout.splitlines()) == expected + + +def test_m4_gate_pins_every_external_component_family(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + + executable = { + ( + row["requirement"], + row["polarity"], + row.get("nodeid", row.get("test_regex")), + ) + for row in data["check"] + } + assert { + ( + "external_flux", + "positive", + "tests/python/integration/native_loader/" + "test_external_component_package.py::" + "test_source_component_executes_through_generic_native_loader_and_flux_consumer", + ), + ( + "external_boundary", + "positive", + r"^test_amr_native_loader\." + r"BoundaryPlanSessionsOwnFreshLaneQualifiedComponentStates$", + ), + ( + "external_tagger", + "positive", + r"^test_amr_native_loader\." + r"PreparedAmrProvidersExecuteExactTablesAndProvenance$", + ), + ( + "external_transfer", + "positive", + "tests/python/integration/runtime/test_multi_layout_runtime.py::" + "test_two_native_layouts_execute_sliced_programs_and_exact_transfer", + ), + ( + "external_solver", + "positive", + "tests/python/integration/native_loader/" + "test_external_field_solver_runtime.py::" + "test_external_field_pair_executes_and_reports_materialized_topology", + ), + ( + "external_writer", + "positive", + "tests/python/integration/native_loader/" + "test_external_component_package.py::" + "test_qualified_writer_runs_through_uniform_and_amr_runtime_transactions", + ), + } <= executable + + assert ( + "external_solver", + "positive", + "tests/python/integration/mpi/test_external_amr_field_solver_mpi.py::" + "test_external_amr_field_bridge_executes_and_refuses_collectively", + ) in executable + + +def test_m4_gate_pins_real_runtime_instance_and_positive_checkpoint_proofs(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + checks = data["check"] + + assert { + "issue": "ADC-684", + "requirement": "runtime_instance", + "polarity": "positive", + "kind": "pytest", + "target": "runtime_instance", + "nodeid": ( + "tests/python/integration/runtime/test_shared_interface_runtime.py::" + "test_runtime_instance_executes_one_two_sided_shared_flux" + ), + } in checks + assert { + "issue": "ADC-684", + "requirement": "runtime_instance", + "polarity": "positive", + "kind": "pytest", + "target": "runtime_instance", + "nodeid": ( + "tests/python/integration/runtime/test_multi_layout_runtime.py::" + "test_uniform_amr_and_multi_layout_share_complete_runtime_instance_contract" + ), + } in checks + assert { + "issue": "ADC-684", + "requirement": "runtime_instance", + "polarity": "refusal", + "kind": "pytest", + "target": "runtime_instance", + "nodeid": ( + "tests/python/integration/native_loader/" + "test_external_field_solver_runtime.py::" + "test_real_prepared_field_solver_failure_rolls_back_runtime_instance_and_retries" + ), + } in checks + assert { + "issue": "ADC-684", + "requirement": "external_transfer", + "polarity": "positive", + "kind": "pytest", + "target": "external_transfer", + "nodeid": ( + "tests/python/integration/runtime/test_multi_layout_runtime.py::" + "test_two_native_layouts_execute_sliced_programs_and_exact_transfer" + ), + } in checks + assert { + "issue": "ADC-685", + "requirement": "external_writer", + "polarity": "positive", + "kind": "pytest", + "target": "external_writer", + "nodeid": ( + "tests/python/integration/native_loader/" + "test_external_component_package.py::" + "test_qualified_writer_runs_through_uniform_and_amr_runtime_transactions" + ), + } in checks + assert { + "issue": "ADC-686", + "requirement": "strict_checkpoint", + "polarity": "positive", + "kind": "pytest", + "target": "strict_checkpoint", + "nodeid": ( + "tests/python/integration/runtime/test_multi_layout_runtime.py::" + "test_multi_layout_checkpoint_restart_restores_every_layout_and_mapping_count" + ), + } in checks + assert { + "issue": "ADC-686", + "requirement": "strict_checkpoint", + "polarity": "refusal", + "kind": "pytest", + "target": "strict_checkpoint", + "nodeid": ( + "tests/python/integration/amr/test_amr_regrid_on_restart.py::" + "test_authenticated_amr_contract_refusal_rolls_back_native_restart_transaction" + ), + } in checks + selected = { + row.get("nodeid", row.get("test_regex")) + for row in checks + } + assert ( + "tests/python/integration/runtime/test_multi_layout_runtime.py::" + "test_mid_step_child_failure_preserves_root_error_and_rolls_back_composite" + ) not in selected + assert ( + "tests/python/integration/runtime/test_multi_layout_runtime.py::" + "test_failed_child_restart_rolls_back_already_restored_layouts" + ) not in selected + + +def test_m4_runtime_refusal_uses_a_real_prepared_component_without_step_wrapper(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + nodeid = ( + "tests/python/integration/native_loader/" + "test_external_field_solver_runtime.py::" + "test_real_prepared_field_solver_failure_rolls_back_runtime_instance_and_retries" + ) + assert [ + row + for row in data["check"] + if row.get("nodeid") == nodeid + ] == [{ + "issue": "ADC-684", + "requirement": "runtime_instance", + "polarity": "refusal", + "kind": "pytest", + "target": "runtime_instance", + "nodeid": nodeid, + }] + + source_path = ( + ROOT + / "tests/python/integration/native_loader/test_external_field_solver_runtime.py" + ) + tree = ast.parse(source_path.read_text(encoding="utf-8")) + function = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name + == "test_real_prepared_field_solver_failure_rolls_back_runtime_instance_and_retries" + ) + calls = { + ( + node.func.id + if isinstance(node.func, ast.Name) + else node.func.attr + if isinstance(node.func, ast.Attribute) + else "" + ) + for node in ast.walk(function) + if isinstance(node, ast.Call) + } + assert {"_component", "compile", "bind", "run"} <= calls + names = {node.id for node in ast.walk(function) if isinstance(node, ast.Name)} + assert names.isdisjoint( + { + "FailFirstStep", + "_RankLocalFailureTarget", + "Mock", + "MagicMock", + "SimpleNamespace", + } + ) + attributes = { + node.attr for node in ast.walk(function) if isinstance(node, ast.Attribute) + } + assert "_native_step_target" not in attributes + assert "_engines" not in attributes + assert not any(isinstance(node, ast.ClassDef) for node in ast.walk(function)) + + +def test_m4_runtime_positive_compiles_all_layout_kinds_without_test_doubles(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + nodeid = ( + "tests/python/integration/runtime/test_multi_layout_runtime.py::" + "test_uniform_amr_and_multi_layout_share_complete_runtime_instance_contract" + ) + assert [ + row for row in data["check"] if row.get("nodeid") == nodeid + ] == [{ + "issue": "ADC-684", + "requirement": "runtime_instance", + "polarity": "positive", + "kind": "pytest", + "target": "runtime_instance", + "nodeid": nodeid, + }] + + path = ROOT / "tests/python/integration/runtime/test_multi_layout_runtime.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + function = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name + == "test_uniform_amr_and_multi_layout_share_complete_runtime_instance_contract" + ) + calls = { + ( + node.func.id + if isinstance(node.func, ast.Name) + else node.func.attr + if isinstance(node.func, ast.Attribute) + else "" + ) + for node in ast.walk(function) + if isinstance(node, ast.Call) + } + assert {"compile", "bind", "run", "program_report", "inspect", "integral"} <= calls + labels = { + node.value + for node in ast.walk(function) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + assert {"uniform", "amr", "multi-layout"} <= labels + names = {node.id for node in ast.walk(function) if isinstance(node, ast.Name)} + assert names.isdisjoint( + { + "FailFirstStep", + "_RankLocalFailureTarget", + "Mock", + "MagicMock", + "SimpleNamespace", + "monkeypatch", + } + ) + attributes = { + node.attr for node in ast.walk(function) if isinstance(node, ast.Attribute) + } + assert "_native_step_target" not in attributes + assert "_engines" not in attributes + assert not any(isinstance(node, ast.ClassDef) for node in ast.walk(function)) + + +def test_m4_gate_pins_real_writer_refusal_without_publication_fakes(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + expected = { + "issue": "ADC-685", + "requirement": "consumer_graph", + "polarity": "refusal", + "kind": "pytest", + "target": "consumer_graph", + "nodeid": ( + "tests/python/integration/native_loader/" + "test_external_component_package.py::" + "test_real_writer_collision_compensates_the_complete_consumer_graph_transaction" + ), + } + assert expected in data["check"] + + path = ( + ROOT + / "tests/python/integration/native_loader/test_external_component_package.py" + ) + tree = ast.parse(path.read_text(encoding="utf-8")) + function = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name + == "test_real_writer_collision_compensates_the_complete_consumer_graph_transaction" + ) + calls = { + ( + node.func.id + if isinstance(node.func, ast.Name) + else node.func.attr + if isinstance(node.func, ast.Attribute) + else "" + ) + for node in ast.walk(function) + if isinstance(node, ast.Call) + } + assert { + "_compile_writer", + "_bind_writer_case", + "_stage_consumers", + "accept", + "_fire_consumers", + } <= calls + names = {node.id for node in ast.walk(function) if isinstance(node, ast.Name)} + assert names.isdisjoint( + {"_Publisher", "_Prepared", "SimpleNamespace", "Mock", "MagicMock"} + ) + + +def test_m4_gate_keeps_real_tamper_and_capacity_refusals(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + + refusals = { + row.get("nodeid", row.get("test_regex")) + for row in data["check"] + if row["requirement"] == "tamper_capability_abi" + and row["polarity"] == "refusal" + } + assert { + ( + "tests/python/unit/codegen/test_component_packages.py::" + "test_fixed_binary_cannot_claim_template_genericity" + ), + r"^test_native_loader_param_overflow\.Runs$", + r"^test_amr_native_loader\.RefusesComponentBuiltForAnotherNativeAbi$", + r"^PlatformManifest\.UnknownCapabilityRefusesBeforeKernel$", + ( + "tests/python/unit/codegen/test_component_packages.py::" + "test_fixed_binary_bytes_are_authenticated_before_package_use" + ), + } <= refusals + assert ( + "tests/python/unit/codegen/test_component_manifest_v2.py::" + "test_target_capability_refusal_contains_requested_and_supported_evidence" + ) not in refusals + assert ( + "tests/python/unit/runtime/test_platform_manifest.py::" + "test_aot_component_rejects_openmpi_mpich_abi_mix_even_with_same_headers_and_standard" + ) not in refusals + assert { + (row["issue"], row["requirement"], row["polarity"]) + for row in data["deferred"] + if row["requirement"] == "tamper_capability_abi" + } == set() + + +def test_m4_gate_pins_mandatory_native_reopen_and_collective_hdf5_np2(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + checks = data["check"] + + native_reopen = { + row["requirement"]: row["nodeid"] + for row in checks + if row["requirement"] in {"exact_npz", "exact_hdf5", "exact_paraview"} + and row["polarity"] == "positive" + and row["kind"] == "pytest" + } + assert native_reopen == { + "exact_npz": ( + "tests/python/integration/io/m4_native_reopen_proof.py::" + "test_npz_reopens_with_numpy_without_a_pops_reader" + ), + "exact_hdf5": ( + "tests/python/integration/io/m4_native_reopen_proof.py::" + "test_hdf5_reopens_with_h5py_without_a_pops_reader" + ), + "exact_paraview": ( + "tests/python/integration/io/m4_native_reopen_proof.py::" + "test_paraview_reopens_with_vtk_without_a_pops_reader" + ), + } + source = ( + ROOT / "tests/python/integration/io/m4_native_reopen_proof.py" + ).read_text(encoding="utf-8") + assert "pytest.importorskip" not in source + assert "import h5py" in source + assert "from vtkmodules.vtkIOXML import vtkXMLUnstructuredGridReader" in source + mpi_native = [ + row + for row in checks + if row["requirement"] == "exact_paraview" + and row["kind"] == "mpi_python" + ] + assert mpi_native == [{ + "issue": "ADC-686", + "requirement": "exact_paraview", + "polarity": "positive", + "kind": "mpi_python", + "target": "exact_paraview", + "nodeid": ( + "tests/python/integration/mpi/test_scientific_output_mpi.py::" + "_validate_paraview" + ), + "nproc": 2, + }] + mpi_source = ( + ROOT / "tests/python/integration/mpi/test_scientific_output_mpi.py" + ).read_text(encoding="utf-8") + assert "vtkXMLPUnstructuredGridReader" in mpi_source + assert "vtkXMLUnstructuredGridReader" in mpi_source + assert "native PVD/PVTU traversal" in mpi_source + assert "except ImportError" not in mpi_source + assert { + "issue": "ADC-686", + "requirement": "collective_hdf5", + "polarity": "positive", + "kind": "ctest", + "target": "collective_hdf5@test_mpi_hdf5_collective", + "test_regex": "^test_mpi_hdf5_collective_np2$", + } in checks + + +def test_m4_gate_pins_complete_program_only_dispatch_and_fallback_fences(): + data, errors = _load_runner().audit_manifest(MANIFEST) + assert not errors + selected = { + row["nodeid"] + for row in data["check"] + if row["requirement"] == "legacy_stepper_retirement" + } + assert selected == { + "tests/python/architecture/test_no_schur_header_leak.py::" + "test_native_source_stage_headers_are_retired", + "tests/python/architecture/test_program_only_temporal_facades.py::" + "test_system_temporal_facades_dispatch_only_through_an_installed_program", + "tests/python/architecture/test_program_only_temporal_facades.py::" + "test_amr_temporal_facades_use_amr_runtime_only_as_the_spatial_engine", + "tests/python/architecture/test_program_only_temporal_facades.py::" + "test_historical_block_scheduler_is_not_an_installed_temporal_authority", + "tests/python/architecture/test_program_only_temporal_facades.py::" + "test_production_has_no_second_amr_time_engine", + "tests/python/architecture/test_component_interface_dispatch.py::" + "test_component_trust_boundary_never_classifies_the_scientific_component_type", + "tests/python/architecture/test_component_interface_dispatch.py::" + "test_native_registry_has_no_rtti_or_untyped_capability_escape_hatch", + "tests/python/unit/codegen/test_component_adapters.py::" + "test_native_interface_is_declared_and_unbound_never_falls_back", + } + + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + job = workflow.split("\n gate-python-architecture:\n", 1)[1] + job = job.split("\n gate-python-build:\n", 1)[0] + command = "run: python3 scripts/run_m4_gate.py --check-only" + assert [line.strip() for line in job.splitlines()].count(command) == 1 + assert "run: python3 scripts/run_m4_gate.py --audit-only" not in job + + documentation = ( + ROOT / "docs/design/m4-conformance-gate.md" + ).read_text(encoding="utf-8") + assert "evidence ledger is **SOURCE-CLOSED AND REQUIRED BY CI**" in documentation + assert "four serial proofs" in documentation + + +def test_m4_gate_rejects_fake_nodeid_before_execution(tmp_path): + manifest = _mutated_manifest( + tmp_path, + ( + "tests/python/unit/codegen/test_component_manifest_v2.py::" + "test_native_parser_normalizer_matches_python_canonical_bytes" + ), + ( + "tests/python/unit/codegen/test_component_manifest_v2.py::" + "test_definitely_missing_m4_proof" + ), + ) + + _, errors = _load_runner().validate_manifest(manifest) + + assert any("references missing test function" in error for error in errors) + + +def test_m4_gate_rejects_wildcard_ctest_selector_before_build(tmp_path): + manifest = _mutated_manifest( + tmp_path, + 'test_regex = "^test_mpi_hdf5_collective_np2$"', + 'test_regex = "^test_mpi_hdf5_collective_.*$"', + ) + + _, errors = _load_runner().validate_manifest(manifest) + + assert any( + "is not one exact source-registered case for target " + "'test_mpi_hdf5_collective'" in error + for error in errors + ) + + +def test_m4_gate_rejects_requirement_attributed_to_the_wrong_issue(tmp_path): + manifest = _mutated_manifest( + tmp_path, + ( + 'issue = "ADC-679"\n' + 'requirement = "component_manifest"\n' + 'polarity = "positive"' + ), + ( + 'issue = "ADC-680"\n' + 'requirement = "component_manifest"\n' + 'polarity = "positive"' + ), + ) + + _, errors = _load_runner().validate_manifest(manifest) + + assert any( + "requirement 'component_manifest' cannot be attributed to 'ADC-680'" in error + for error in errors + ) + + +def test_m4_gate_rejects_importorskip_and_mock_proofs(tmp_path): + optional_manifest = _mutated_manifest( + tmp_path, + ( + "tests/python/integration/io/m4_native_reopen_proof.py::" + "test_hdf5_reopens_with_h5py_without_a_pops_reader" + ), + ( + "tests/python/unit/output/test_exact_writers.py::" + "test_hdf5_is_reopened_with_native_reader_and_exact_selection" + ), + ) + _, optional_errors = _load_runner().validate_manifest(optional_manifest) + assert any( + "is not an unconditional real proof" in error + and "pytest.importorskip" in error + for error in optional_errors + ) + + mock_manifest = _mutated_manifest( + tmp_path, + ( + "tests/python/unit/runtime/test_consumer_transactions.py::" + "test_graph_and_plan_are_semantic_and_insertion_order_independent" + ), + ( + "tests/python/architecture/test_m3_amr_multilayout_gate.py::" + "test_m3_mpi_python_proof_is_exact_and_manifest_owned" + ), + ) + _, mock_errors = _load_runner().validate_manifest(mock_manifest) + assert any( + "is not an unconditional real proof" in error + and "fixture:monkeypatch" in error + for error in mock_errors + ) + + +def test_m4_mpi_entrypoint_accepts_only_the_required_prerequisite_guard(): + runner = _load_runner() + data, errors = runner.audit_manifest(MANIFEST) + assert not errors + mpi_proof = next( + row for row in data["check"] + if row["kind"] == "mpi_python" + ) + assert mpi_proof["nodeid"] == ( + "tests/python/integration/mpi/test_scientific_output_mpi.py::" + "_validate_paraview" + ) + environment = runner._required_environment() + assert environment["POPS_REQUIRE_MPI_TESTS"] == "1" + assert str(ROOT) in environment["PYTHONPATH"].split(runner.os.pathsep) + trusted = ast.parse( + "from tests.python.support.requirements import require_mpi_or_skip\n" + ) + untrusted = ast.parse("def require_mpi_or_skip(_reason):\n return None\n") + assert runner._has_authenticated_mpi_guard(trusted) + assert not runner._has_authenticated_mpi_guard(untrusted) + + +def test_m4_gate_has_no_explicit_deferred_gap(): + runner = _load_runner() + data, audit_errors = runner.audit_manifest(MANIFEST) + assert not audit_errors + assert data["deferred"] == [] + + _, errors = runner.validate_manifest(MANIFEST) + assert errors == [] + + +def test_m4_required_pytest_execution_rejects_junit_skips(monkeypatch): + runner = _load_runner() + skipped_xml = ( + '' + '' + '' + '' + "" + ) + + def successful_pytest_with_a_skip(command, *, cwd, env, check): + assert cwd == ROOT + assert env["POPS_REQUIRE_MPI_TESTS"] == "1" + assert env["POPS_REQUIRE_NATIVE_TESTS"] == "1" + assert check is False + assert "xfail_strict=true" in command + report = Path(command[command.index("--junitxml") + 1]) + report.write_text(skipped_xml, encoding="utf-8") + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", successful_pytest_with_a_skip) + with pytest.raises(RuntimeError, match="reported 1 skipped/xfail proof"): + runner._run_required_pytest( + [ + "tests/python/integration/io/m4_native_reopen_proof.py::" + "test_npz_reopens_with_numpy_without_a_pops_reader" + ] + ) + + +def test_m4_required_ctest_execution_rejects_junit_skips(tmp_path, monkeypatch): + runner = _load_runner() + skipped_xml = ( + '' + '' + '' + '' + "" + ) + calls = 0 + + def ctest_with_a_skip(command, **kwargs): + nonlocal calls + calls += 1 + assert kwargs["cwd"] == ROOT + if "-N" in command: + assert kwargs["check"] is True + assert kwargs["capture_output"] is True + return SimpleNamespace( + returncode=0, + stdout="Test #1: ComponentInterfaces.Proof\nTotal Tests: 1\n", + ) + assert kwargs["check"] is False + report = Path(command[command.index("--output-junit") + 1]) + report.write_text(skipped_xml, encoding="utf-8") + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(runner.subprocess, "run", ctest_with_a_skip) + with pytest.raises(RuntimeError, match="reported 1 skipped proof"): + runner._run_ctest( + tmp_path / "build", + "test_component_interfaces", + r"^ComponentInterfaces\.Proof$", + ) + assert calls == 2 + + +def test_m4_check_only_accepts_closed_ledger_without_launcher_or_build(monkeypatch): + runner = _load_runner() + + def forbidden_call(*_args, **_kwargs): + raise AssertionError("--check-only attempted to launch an executable") + + monkeypatch.setattr(runner.shutil, "which", forbidden_call) + monkeypatch.setattr(runner.subprocess, "run", forbidden_call) + + assert runner.main(["--check-only"]) == 0 diff --git a/tests/python/architecture/test_multiblock_interface_communicator_fence.py b/tests/python/architecture/test_multiblock_interface_communicator_fence.py new file mode 100644 index 000000000..6c2bc98b9 --- /dev/null +++ b/tests/python/architecture/test_multiblock_interface_communicator_fence.py @@ -0,0 +1,52 @@ +"""ADC-683 fences for execution-lane-owned multi-block interface collectives.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCHEDULER = ROOT / "include/pops/runtime/multiblock/interface_flux_scheduler.hpp" + + +def _function(source: str, signature: str) -> str: + start = source.index(signature) + opening_brace = source.index("{", start) + depth = 0 + for offset in range(opening_brace, len(source)): + token = source[offset] + if token == "{": + depth += 1 + elif token == "}": + depth -= 1 + if depth == 0: + return source[start : offset + 1] + raise AssertionError(f"unterminated C++ function {signature}") + + +def test_interface_scheduler_hot_path_never_falls_back_to_mpi_world(): + source = SCHEDULER.read_text(encoding="utf-8") + consensus = _function(source, "static void require_distributed_flux_consensus_(") + apply_one = _function(source, "static void apply_one_(") + + assert "MPI_COMM_WORLD" not in consensus + assert "MPI_COMM_WORLD" not in apply_one + assert "const CommunicatorView& communicator" in consensus + assert "prepared.communicator" in apply_one + + +def test_interface_scheduler_limits_world_rank_space_to_storage_admission(): + source = SCHEDULER.read_text(encoding="utf-8") + install = _function( + source, + "void install(AxisAlignedInterface route, MultiFab& left_state,", + ) + hot_path = source.split( + "void apply(const BoundaryEvaluationPoint& point,", + maxsplit=1, + )[1] + + assert "MPI_COMM_WORLD" not in source + assert source.count("world_communicator_view()") == 1 + assert "const CommunicatorView field_rank_space =" in install + assert "MPI_Comm_compare(communicator, field_rank_space.native_handle()" in install + assert "execution_communicator = CommunicatorView{communicator};" in install + assert "world_communicator_view()" not in hot_path diff --git a/tests/python/architecture/test_native_stub_contract.py b/tests/python/architecture/test_native_stub_contract.py index ebedd0f2b..14d3667d6 100644 --- a/tests/python/architecture/test_native_stub_contract.py +++ b/tests/python/architecture/test_native_stub_contract.py @@ -164,7 +164,7 @@ def test_every_native_plugin_compile_route_uses_the_central_loader_manifest(): "%s must consume the authenticated central native-loader manifest" % (route,)) assert routes == { ("python/pops/codegen/_compile_drivers.py", "compile_native"), - ("python/pops/codegen/_compile_drivers.py", "compile_problem"), + ("python/pops/codegen/_compile_drivers.py", "_compile_problem_impl"), ("python/pops/external/compiler.py", "compile_component"), } diff --git a/tests/python/architecture/test_no_duplicate_core_systems.py b/tests/python/architecture/test_no_duplicate_core_systems.py index ad4a2f96b..9da746960 100644 --- a/tests/python/architecture/test_no_duplicate_core_systems.py +++ b/tests/python/architecture/test_no_duplicate_core_systems.py @@ -18,6 +18,7 @@ The AST scans are source-only (they run without the native extension); the lowering proofs import ``pops`` and skip cleanly when it is not importable. ASCII only. """ + import ast import pathlib @@ -55,7 +56,7 @@ # single allowed stepper class, named explicitly (no broad allowlist). _ALLOWED_STEPPER_CLASSES = { "Program": "python/pops/time/_program/api.py: the ONE canonical compiled-time stepper; step() is a " - "build-time IR authoring decorator, not a numerical advance loop", + "build-time IR authoring decorator, not a numerical advance loop", } # lib/time/rk.py:ButcherTableau is a DATA helper (A/b/c coefficient table), not a stepper: it is not @@ -63,7 +64,7 @@ # non-stepper class the time surface may define with an RK-adjacent name. _ALLOWED_NON_STEPPER_DATA = { "ButcherTableau": "python/pops/lib/time/rk.py: a Butcher A/b/c coefficient table (data), not a " - "stepper; carries no step/advance/integrate and is not exported as a stepper", + "stepper; carries no step/advance/integrate and is not exported as a stepper", } # The canonical physical field-operator base + its home package. A second public class exposing a @@ -112,8 +113,11 @@ def _public_classes(tree): def _class_methods(node): - return {child.name for child in node.body - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))} + return { + child.name + for child in node.body + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) + } def _dotted_name(node): @@ -144,12 +148,15 @@ def test_time_surface_defines_no_second_public_stepper(): # it is the named exception; any other stepper-shaped public class is a violation. violations.append( "%s:%d public class %r defines stepper method(s) %s" - % (rel, node.lineno, node.name, sorted(methods & _STEPPER_METHODS))) + % (rel, node.lineno, node.name, sorted(methods & _STEPPER_METHODS)) + ) assert not violations, ( "only pops.time.Program may be a public stepper; a second stepper-shaped class bypasses the " - "canonical time program:\n " + "\n ".join(violations) - + "\n(allowed: %s)" % ", ".join(sorted(_ALLOWED_STEPPER_CLASSES))) + "canonical time program:\n " + + "\n ".join(violations) + + "\n(allowed: %s)" % ", ".join(sorted(_ALLOWED_STEPPER_CLASSES)) + ) def test_lib_time_exports_are_macros_not_stepper_classes(): @@ -169,8 +176,10 @@ def test_lib_time_exports_are_macros_not_stepper_classes(): if isinstance(target, ast.Name) and target.id == "__all__": if isinstance(node.value, (ast.List, ast.Tuple)): exported.update( - elt.value for elt in node.value.elts - if isinstance(elt, ast.Constant) and isinstance(elt.value, str)) + elt.value + for elt in node.value.elts + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + ) assert exported, "pops.lib.time.__init__ must declare __all__" # Collect every FunctionDef / ClassDef name across the sub-modules with its kind. @@ -193,14 +202,19 @@ def test_lib_time_exports_are_macros_not_stepper_classes(): node = class_defs[name] if _class_methods(node) & _STEPPER_METHODS: violations.append( - "%s: allowed data helper %r unexpectedly defines a stepper method" % (name, name)) + "%s: allowed data helper %r unexpectedly defines a stepper method" + % (name, name) + ) continue if name in class_defs: - violations.append("pops.lib.time exports class %r (must export scheme macros only)" % name) + violations.append( + "pops.lib.time exports class %r (must export scheme macros only)" % name + ) assert not violations, ( "pops.lib.time must export scheme-builder functions (and the ButcherTableau data helper), " - "never a stepper class:\n " + "\n ".join(violations)) + "never a stepper class:\n " + "\n ".join(violations) + ) def test_lib_time_macro_returns_the_same_program_handle(): @@ -226,7 +240,8 @@ def test_lib_time_macro_returns_the_same_program_handle(): for name in ("ForwardEuler", "SSPRK2", "SSPRK3", "RK4"): result = getattr(lib_time, name)(instance, rate=rate) assert isinstance(result, Program), ( - "pops.lib.time.%s must return a pops.time.Program, got %r" % (name, type(result))) + "pops.lib.time.%s must return a pops.time.Program, got %r" % (name, type(result)) + ) # --------------------------------------------------------------------------------------------- @@ -242,20 +257,24 @@ def test_only_pops_fields_defines_a_field_operator_class(): for node in _public_classes(_parse(path)): base_names = {_dotted_name(base) or "" for base in node.bases} subclasses_field = any( - name and name.endswith(_FIELD_OPERATOR_BASE_SUFFIX) for name in base_names) + name and name.endswith(_FIELD_OPERATOR_BASE_SUFFIX) for name in base_names + ) has_register = bool(_class_methods(node) & _FIELD_REGISTER_METHODS) if subclasses_field: violations.append( "%s:%d public class %r subclasses FieldOperator outside pops/fields" - % (rel, node.lineno, node.name)) + % (rel, node.lineno, node.name) + ) elif has_register: violations.append( "%s:%d public class %r exposes register_field outside pops/fields" - % (rel, node.lineno, node.name)) + % (rel, node.lineno, node.name) + ) assert not violations, ( "the physical field operator has one home (pops.fields.FieldOperator); a parallel " - "field system elsewhere is refused:\n " + "\n ".join(violations)) + "field system elsewhere is refused:\n " + "\n ".join(violations) + ) def test_bind_path_consumes_field_plans_never_constructs_them(): @@ -270,11 +289,13 @@ def test_bind_path_consumes_field_plans_never_constructs_them(): if tail in _FIELD_AUTHORING_CTOR_NAMES: violations.append( "%s:%d constructs %s() (bind must consume, not author, a field plan)" - % (rel, node.lineno, tail)) + % (rel, node.lineno, tail) + ) assert not violations, ( "the runtime bind path must consume field authoring, never construct FieldOperator/" - "FieldDiscretization itself:\n " + "\n ".join(violations)) + "FieldDiscretization itself:\n " + "\n ".join(violations) + ) def test_field_handle_is_the_sole_public_field_solve_route(): @@ -352,11 +373,16 @@ def to_data(self): def test_native_named_field_solve_uses_exact_block_slots_not_a_representative(): """A coupled named-field solve must preserve every qualified block stage.""" - context = _read(REPO_ROOT / "include" / "pops" / "runtime" / "program" - / "program_context.hpp") + context = _read(REPO_ROOT / "include" / "pops" / "runtime" / "program" / "program_context.hpp") + services = _read( + REPO_ROOT / "include" / "pops" / "runtime" / "program" / "program_execution_services.hpp" + ) assert "representative" not in context - assert "workspace.program_to_system[p]" in context - assert "solve_fields_from_blocks_in_place_(field, workspace.system_stages)" in context + assert "workspace.program_to_runtime[program_slot]" in services + assert "solve_fields_from_blocks_at_in_place_(point, field, runtime_stages)" in context + assert "require_field_evaluation_point_" not in context + assert 'require_field_evaluation_point_(point, "Program simultaneous field solve")' in services + assert "solve_fields_from_blocks_in_place_(field, runtime_stages)" not in context assert "solve_fields_from_state(field, representative" not in context @@ -380,7 +406,7 @@ def test_no_public_function_takes_an_amr_config_string_kwarg(): continue args = node.args pairs = list( - zip(args.args[len(args.args) - len(args.defaults):], args.defaults, strict=True) + zip(args.args[len(args.args) - len(args.defaults) :], args.defaults, strict=True) ) pairs += list(zip(args.kwonlyargs, args.kw_defaults, strict=True)) for arg, default in pairs: @@ -389,11 +415,13 @@ def test_no_public_function_takes_an_amr_config_string_kwarg(): if isinstance(default, ast.Constant) and isinstance(default.value, str): violations.append( "%s:%d public %s(%s=%r) is an AMR-config string selector" - % (rel, node.lineno, node.name, arg.arg, default.value)) + % (rel, node.lineno, node.name, arg.arg, default.value) + ) assert not violations, ( "AMR is configured by the typed layout=AMR(...) descriptor, not a string kwarg or a " - "target='amr_system' branch:\n " + "\n ".join(violations)) + "target='amr_system' branch:\n " + "\n ".join(violations) + ) def test_amr_config_lives_in_the_layout_descriptor_only(): @@ -408,22 +436,24 @@ def test_amr_config_lives_in_the_layout_descriptor_only(): layout = final_amr_layout(cartesian_grid(n=16, L=1.0)) manifest = layout.inspect() assert manifest["capabilities"]["layout"] == "amr", ( - "AMR(...) must be the typed AMR configuration surface") + "AMR(...) must be the typed AMR configuration surface" + ) view_path = POPS / "runtime" / "amr" / "_view.py" - classes = [node for node in _public_classes(_parse(view_path)) - if node.name == "AmrRuntimeView"] + classes = [node for node in _public_classes(_parse(view_path)) if node.name == "AmrRuntimeView"] assert len(classes) == 1, "the canonical AMR runtime view must remain unique" public_methods = {name for name in _class_methods(classes[0]) if not name.startswith("_")} mutators = sorted( - name for name in public_methods + name + for name in public_methods if name.startswith(("set_", "configure", "add_")) or "level" in name.lower() or "ratio" in name.lower() ) assert not mutators, ( "sim.amr is a read-only runtime view; it must expose no AMR-config mutator, found: %s" - % mutators) + % mutators + ) if __name__ == "__main__": diff --git a/tests/python/architecture/test_no_legacy_runtime_routes.py b/tests/python/architecture/test_no_legacy_runtime_routes.py index 856e87bc1..41cdfd4a9 100644 --- a/tests/python/architecture/test_no_legacy_runtime_routes.py +++ b/tests/python/architecture/test_no_legacy_runtime_routes.py @@ -269,6 +269,46 @@ def test_case_has_one_registration_spelling_per_authority() -> None: assert hasattr(case, "consumers") and not hasattr(case, "output") +def test_native_runtime_wrappers_do_not_restore_add_block_through_passthrough() -> None: + from pops.runtime._lifecycle import RETIRED_NATIVE_PASSTHROUGH + + assert RETIRED_NATIVE_PASSTHROUGH == frozenset({"add_block"}) + + for relative in ( + "runtime/_system_install.py", + "runtime/_amr_system.py", + "runtime/_system_contract.py", + "runtime/_amr_system_contract.py", + ): + source = (PACKAGE / relative).read_text(encoding="utf-8") + tree = ast.parse(source, filename=relative) + assert not any( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "add_block" + for node in ast.walk(tree) + ), relative + + for relative in ("runtime/_system.py", "runtime/_amr_system.py"): + source = (PACKAGE / relative).read_text(encoding="utf-8") + tree = ast.parse(source, filename=relative) + passthrough = next( + node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "__getattr__" + ) + assert any( + isinstance(node, ast.Name) and node.id == "_RETIRED_NATIVE_PASSTHROUGH" + for node in ast.walk(passthrough) + ), relative + assert any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "AttributeError" + for node in ast.walk(passthrough) + ), relative + + def test_amr_has_one_checkpoint_output_and_tagging_authority_path() -> None: from pops import amr as authoring_amr import pops.mesh as public_mesh diff --git a/tests/python/architecture/test_prepared_boundary_linearization_authority.py b/tests/python/architecture/test_prepared_boundary_linearization_authority.py new file mode 100644 index 000000000..49bbf7d4c --- /dev/null +++ b/tests/python/architecture/test_prepared_boundary_linearization_authority.py @@ -0,0 +1,33 @@ +"""The System boundary linearization has one prepared execution authority.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SYSTEM_PROGRAM = ROOT / "src" / "runtime" / "system" / "system_program.cpp" + + +def _first_overload(source: str, signature: str) -> str: + start = source.index(signature) + end = source.index(signature, start + len(signature)) + return source[start:end] + + +def test_boundary_residual_refuses_a_missing_prepared_session(): + source = SYSTEM_PROGRAM.read_text(encoding="utf-8") + body = _first_overload(source, "void System::block_boundary_residual_into_at(") + + assert "if (!block.boundary_session)" in body + assert "persistent prepared boundary session" in body + assert "block.boundary_residual_at_point_prepared(" in body + assert "block.boundary_residual_at_point;" not in body + + +def test_boundary_jvp_refuses_a_missing_prepared_session(): + source = SYSTEM_PROGRAM.read_text(encoding="utf-8") + body = _first_overload(source, "void System::block_boundary_jvp_into_at(") + + assert "if (!block.boundary_session)" in body + assert "persistent prepared boundary session" in body + assert "block.boundary_jvp_at_point_prepared(" in body + assert "block.boundary_jvp_at_point;" not in body diff --git a/tests/python/architecture/test_prepared_local_nonlinear_authority.py b/tests/python/architecture/test_prepared_local_nonlinear_authority.py new file mode 100644 index 000000000..a125a1bb7 --- /dev/null +++ b/tests/python/architecture/test_prepared_local_nonlinear_authority.py @@ -0,0 +1,144 @@ +"""ADC-750 fences for the sole prepared local nonlinear solver authority.""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PROVIDER = ROOT / "include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp" +COLLECTIVE = ROOT / "include/pops/numerics/nonlinear/local_nonlinear_collective.hpp" +IMPLICIT_STEPPER = ROOT / "include/pops/numerics/time/integrators/implicit_stepper.hpp" +MODEL_KERNELS = ROOT / "python/pops/codegen/program_emit_model_kernels.py" +PROGRAM_OPS = ROOT / "python/pops/codegen/program_emit_ops.py" + + +def _without_cpp_comments(source: str) -> str: + return re.sub(r"//.*?$|/\*.*?\*/", "", source, flags=re.MULTILINE | re.DOTALL) + + +def _cpp_body(source: str, signature: str) -> str: + start = source.index(signature) + opening = source.index("{", start + len(signature)) + depth = 0 + for index in range(opening, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[opening + 1 : index] + raise AssertionError(f"unterminated C++ body for {signature!r}") + + +def _python_function_source(path: Path, name: str) -> str: + source = path.read_text(encoding="utf-8") + module = ast.parse(source) + functions = [ + node + for node in module.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name + ] + assert len(functions) == 1 + function = functions[0] + assert function.end_lineno is not None + return "\n".join(source.splitlines()[function.lineno - 1 : function.end_lineno]) + + +def test_prepared_provider_is_the_only_local_nonlinear_algorithm_definition(): + definition = re.compile( + r"LocalNonlinearCellResult\s+solve_prepared_local_nonlinear\s*\(" + ) + definitions = [ + path.relative_to(ROOT).as_posix() + for path in sorted((ROOT / "include").rglob("*.hpp")) + if definition.search(_without_cpp_comments(path.read_text(encoding="utf-8"))) + ] + assert definitions == ["include/pops/numerics/nonlinear/prepared_local_nonlinear.hpp"] + + provider = _without_cpp_comments(PROVIDER.read_text(encoding="utf-8")) + solve = _cpp_body(provider, "solve_prepared_local_nonlinear(") + assert "pivoted_dense_solve" in solve + assert "build_local_jacobian" in solve + assert "LocalNonlinearCellResult result;" in solve + for forbidden in ( + "mat_inverse", + "std::function", + "std::vector", + "std::unique_ptr", + "std::shared_ptr", + "malloc(", + "calloc(", + "realloc(", + "throw ", + ): + assert forbidden not in solve + + +def test_generated_program_routes_delegate_instead_of_emitting_newton(): + for function_name in ( + "_emit_solve_coupled_implicit_kernel", + "_emit_solve_local_nonlinear_kernel", + ): + source = _python_function_source(MODEL_KERNELS, function_name) + assert source.count("solve_prepared_local_nonlinear") == 1 + for forbidden in ( + "mat_inverse", + "pivoted_dense_solve", + "build_local_jacobian", + "for (int newton", + "for (int iteration", + ): + assert forbidden not in source + + +def test_implicit_source_device_kernel_is_a_stack_only_provider_adapter(): + source = _without_cpp_comments(IMPLICIT_STEPPER.read_text(encoding="utf-8")) + adapter = source.split("struct PreparedImplicitSourceKernel", 1)[1].split( + "struct LocalStatMax", 1 + )[0] + kernel = _cpp_body(adapter, "POPS_HD void operator()(int i, int j) const") + assert kernel.count("solve_prepared_local_nonlinear") == 1 + assert "LocalNonlinearCellResult solved;" in kernel + for forbidden in ( + "mat_inverse", + "pivoted_dense_solve", + "build_local_jacobian", + "std::function", + "std::vector", + "std::unique_ptr", + "std::shared_ptr", + "malloc(", + "calloc(", + "realloc(", + "throw ", + ): + assert forbidden not in kernel + + +def test_implicit_source_publication_consumes_one_collective_outcome(): + source = _without_cpp_comments(IMPLICIT_STEPPER.read_text(encoding="utf-8")) + publication = _cpp_body(source, "const MultiFab* active_cells = nullptr)") + assert publication.count("PreparedImplicitSourceKernel") == 1 + assert publication.count("SolveOutcome::collective_world") == 1 + assert "ImplicitSourcePublication" in publication + assert "solved_value_available()" not in publication + + +def test_failure_location_uses_staged_integer_collectives_without_float_packing(): + provider = PROVIDER.read_text(encoding="utf-8") + implicit = IMPLICIT_STEPPER.read_text(encoding="utf-8") + generated = MODEL_KERNELS.read_text(encoding="utf-8") + program_ops = PROGRAM_OPS.read_text(encoding="utf-8") + collective = COLLECTIVE.read_text(encoding="utf-8") + + for source in (provider, implicit, generated, program_ops): + assert "encode_local_nonlinear_failure" not in source + assert "encode_ranked_local_nonlinear_failure" not in source + assert "Kokkos::Min" in collective + assert "all_reduce_min(static_cast" in collective + assert "LocalNonlinearFailureJMin" in collective + assert "LocalNonlinearFailureIMin" in collective + assert "LocalNonlinearFailureComponentMin" in collective diff --git a/tests/python/architecture/test_prepared_reflux_runtime_execution_fence.py b/tests/python/architecture/test_prepared_reflux_runtime_execution_fence.py new file mode 100644 index 000000000..34e2fbefe --- /dev/null +++ b/tests/python/architecture/test_prepared_reflux_runtime_execution_fence.py @@ -0,0 +1,135 @@ +"""ADC-681: a prepared Reflux component executes without owning AMR authority.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PATCH_RANGE = ( + ROOT / "include" / "pops" / "numerics" / "time" / "amr" / "levels" + / "amr_patch_range.hpp" +) +SUBCYCLING = ( + ROOT / "include" / "pops" / "numerics" / "time" / "amr" / "levels" + / "amr_subcycling.hpp" +) +PROVIDERS = ( + ROOT / "include" / "pops" / "runtime" / "amr" + / "prepared_component_providers.hpp" +) +AMR_RUNTIME = ROOT / "include" / "pops" / "runtime" / "amr" / "amr_runtime.hpp" +PROGRAM_REFLUX = ( + ROOT / "include" / "pops" / "runtime" / "amr" / "amr_program_reflux.hpp" +) +PROGRAM_CONTEXT = ( + ROOT / "include" / "pops" / "runtime" / "program" / "amr_program_context.hpp" +) +AMR_SYSTEM = ROOT / "src" / "runtime" / "amr" / "amr_system.cpp" +AMR_BINDING = ROOT / "python" / "bindings" / "core" / "init" / "init_amr.cpp" +RUNTIME_AUTHORITIES = ROOT / "python" / "pops" / "runtime" / "_runtime_authorities.py" +AMR_PROVIDER_PROTOCOLS = ROOT / "python" / "pops" / "amr" / "providers.py" + + +def _between(text: str, begin: str, end: str) -> str: + return text.split(begin, 1)[1].split(end, 1)[0] + + +def test_transition_executes_local_kernel_before_pops_collective_publication() -> None: + source = SUBCYCLING.read_text() + transition = _between( + source, + "class PreparedAmrProgramRefluxTransition", + "class PreparedAmrProgramRefluxPlan", + ) + assert "PreparedAmrRefluxLocalKernel local_kernel_" in transition + assert "workspace.poison();" in transition + assert "local_kernel_(PreparedAmrRefluxLocalRequest{" in transition + assert "workspace.all_finite()" in transition + assert "all_reduce_or_inplace(&preflight_consensus" in transition + assert "prepared Reflux provider differs between communicator ranks" in transition + assert transition.index("local_kernel_(PreparedAmrRefluxLocalRequest{") < ( + transition.index("route_prepared_reflux_correction_") + ) + assert transition.index("all_reduce_max(local_failure") < transition.index( + "correction_.gather(communicator);" + ) + assert "route_reflux_integrated_pair_prevalidated_" in transition + assert "apply_reflux_interface_batch" not in transition + + +def test_component_adapter_is_host_local_noncollective_and_has_no_topology() -> None: + source = PROVIDERS.read_text() + adapter = _between( + source, + "class PreparedRefluxComponent final", + "/// External Clustering ABI contract", + ) + assert "without_collective_authority()" in adapter + assert "collective_contract() const noexcept" in adapter + assert "POPS_MEMORY_SPACE_HOST_V1" in adapter + assert "apply_reflux_interface_batch" in adapter + assert "POPS_NATIVE_INTERFACE_REFLUX_V1" in adapter + assert "FluxRegister" not in adapter + assert "CoverageMask" not in adapter + assert "all_reduce" not in adapter + + +def test_pops_maps_validated_faces_through_coverage_and_periodicity() -> None: + source = PATCH_RANGE.read_text() + kernel = _between( + source, + "struct RoutePreparedRefluxCorrectionKernel", + "} // namespace detail", + ) + assert "canonicalize" in kernel + assert "coverage.covered" in kernel + assert "correction.add" in kernel + assert "faces.x_low[index]" in kernel + assert "faces.x_high[index]" in kernel + assert "faces.y_low[index]" in kernel + assert "faces.y_high[index]" in kernel + + +def test_runtime_installation_reprepares_transitions_and_routes_logical_time() -> None: + runtime = AMR_RUNTIME.read_text() + install = _between( + runtime, + "void install_external_reflux(", + "/// Inject the current Program evaluation coordinate", + ) + assert "external_reflux_ = std::move(provider);" in install + assert "require_prepared_provider_collective_consensus" in install + assert "rematerialize_persistent_topology_resources_" in install + rematerialize = _between( + runtime, + "void rematerialize_persistent_topology_resources_(", + "void record_topology_replacement_()", + ) + assert "provider->apply(request);" in rematerialize + assert "external_reflux_kernel, block.state_identity" in rematerialize + + route = PROGRAM_REFLUX.read_text() + assert "const amr::ClockStamp& logical_time" in route + assert "&logical_time" in route + assert "integrated_state_correction" in route + context = PROGRAM_CONTEXT.read_text() + assert "route_reflux_program(*eng_, sb, child, coarse_role, fine_role," in context + assert "sync_clock," in context + assert "capture_balance ? &integrated_reflux : nullptr" in context + + +def test_reflux_uses_the_public_normalized_amr_provider_resolution() -> None: + system = AMR_SYSTEM.read_text() + binding = AMR_BINDING.read_text() + authorities = RUNTIME_AUTHORITIES.read_text() + protocols = AMR_PROVIDER_PROTOCOLS.read_text() + assert "install_amr_reflux_component(" in system + assert "runtime->install_external_reflux(amr_reflux_component_);" in system + assert "if (amr_reflux_component_)" not in _between( + system, + "runtime->install_external_tagger(amr_tagger_component_);", + "if (!boundary_plans_.empty())", + ) + assert '"_install_amr_reflux_component"' in binding + assert 'component_installer="_install_amr_reflux_component"' in protocols + assert '"_install_amr_reflux_component"' not in authorities + assert 'tuple(providers) != ("clustering", "tagger", "reflux")' in authorities diff --git a/tests/python/architecture/test_program_execution_services.py b/tests/python/architecture/test_program_execution_services.py index 5156abc47..15c646281 100644 --- a/tests/python/architecture/test_program_execution_services.py +++ b/tests/python/architecture/test_program_execution_services.py @@ -10,6 +10,12 @@ PROGRAM_RUNTIME_STATE = PROGRAM_DIR / "program_runtime_state.hpp" UNIFORM = PROGRAM_DIR / "program_context.hpp" AMR = PROGRAM_DIR / "amr_program_context.hpp" +UNIFORM_DRIVER = ROOT / "include" / "pops" / "runtime" / "system" / "system_program_driver.hpp" +AMR_RUNTIME = ROOT / "src" / "runtime" / "amr" / "amr_system.cpp" +BINDINGS = ( + ROOT / "python" / "bindings" / "core" / "init" / "init_system.cpp", + ROOT / "python" / "bindings" / "core" / "init" / "init_amr.cpp", +) PREPARED_AFFINE = ( ROOT / "include" / "pops" / "numerics" / "elliptic" / "linear" / "prepared_affine_problem.hpp" ) @@ -26,6 +32,7 @@ SHARED_SIGNATURES = ( "struct FieldStageOverride", + "struct GeneratedFieldSolveWorkspace", "struct CouplingStateOverride", "struct RhsGroupRequest", "struct RhsGroupBatch", @@ -40,6 +47,8 @@ "struct ProgramClockCoordinate", "class ExclusiveUseGuard", "static bool field_layout_matches_(", + "void prepare_generated_field_solve_workspace_(", + "void require_field_evaluation_point_(", "ProgramRuntimeState& program_runtime_state_()", "void install(std::function step)", "SolveOutcome solve_fields()", @@ -117,6 +126,8 @@ "int n_blocks(", "Real physical_time(", "void record_scalar(", + "void record_balance_term(", + "bool balance_consumer_is_due(", "RuntimeParams program_params(", "void set_field_logical_timepoint(", "void set_field_boundary_parameters(", @@ -141,8 +152,8 @@ ) SHARED_OVERLOAD_COUNTS = { - "SolveOutcome solve_fields_from_state(": 2, - "SolveOutcome solve_fields_from_blocks(": 3, + "SolveOutcome solve_fields_from_state(": 1, + "SolveOutcome solve_fields_from_blocks(": 1, "void neg_div_flux_into(": 4, "void rhs_core_into_at(": 2, "void boundary_residual_into_at(": 2, @@ -189,6 +200,36 @@ def test_uniform_and_amr_inherit_the_same_execution_service(): ) +def test_uniform_and_amr_enter_one_shared_cadence_dispatcher(): + state = _read(PROGRAM_RUNTIME_STATE) + uniform_driver = _read(UNIFORM_DRIVER) + amr_runtime = _read(AMR_RUNTIME) + + assert state.count("void dispatch_cadence_step(") == 1 + for operation in ( + "prepare_cadence_step(", + "validate_cadence_partition(", + "prepare_cadence_substep(", + "run_balance_due_window(", + "commit_cadence_step(", + "complete_balance_step(", + ): + assert operation in state + assert operation not in uniform_driver + assert operation not in amr_runtime + + assert ( + 'P->program_.dispatch_cadence_step(P->t, P->macro_step_, dt, "System");' + in uniform_driver + ) + assert 'program_.dispatch_cadence_step(t, macro_step_, dt, "AmrSystem");' in amr_runtime + + +def test_balance_attempt_sink_is_not_python_bound(): + for binding in BINDINGS: + assert "record_program_balance_term" not in _read(binding) + + def test_codegen_uses_one_facade_selected_provider_factory_not_concrete_context_dispatch(): shared = _read(SHARED) uniform = _read(UNIFORM) @@ -264,13 +305,15 @@ def test_operator_snapshot_revision_state_is_owned_only_by_the_shared_service(): shared = _read(SHARED) declarations = ( "mutable std::uint64_t operator_snapshot_revision_ = 0;", - "mutable std::uint64_t active_operator_snapshot_revision_ = 0;", + "mutable std::optional active_operator_snapshot_;", ) for declaration in declarations: assert shared.count(declaration) == 1 assert declaration not in _read(UNIFORM) assert declaration not in _read(AMR) assert shared.count("void invalidate_active_operator_snapshot_() const noexcept") == 1 + assert "probe != *active_operator_snapshot_" in shared + assert "active_operator_snapshot_revision_" not in shared assert "invalidate_active_operator_snapshot_" not in _read(UNIFORM) assert "invalidate_active_operator_snapshot_" not in _read(AMR) @@ -305,15 +348,11 @@ def test_contexts_expose_explicit_provider_hooks_for_the_shared_surface(): "program_execution_capture_logical_evaluation_", "program_execution_apply_logical_evaluation_", "program_execution_restore_logical_evaluation_", - "program_execution_solve_fields_from_state_at_", "program_execution_solve_fields_outcome_", "program_execution_solve_fields_from_state_outcome_", "program_execution_field_solve_from_state_at_outcome_", - "program_execution_solve_named_field_from_state_outcome_", "program_execution_solve_fields_from_blocks_outcome_", - "program_execution_solve_named_field_from_blocks_outcome_", "program_execution_solve_generated_field_from_blocks_outcome_", - "program_execution_scratch_", "program_execution_default_grid_context_", "program_execution_block_grid_context_", "program_execution_owns_operator_authority_", @@ -345,23 +384,116 @@ def test_contexts_expose_explicit_provider_hooks_for_the_shared_surface(): "program_execution_publish_lincomb_", "program_execution_publish_exact_lincomb_", "program_execution_validate_commit_aliases_", + "program_execution_record_balance_term_", + "program_execution_balance_consumer_is_due_", "program_execution_runtime_state_", "program_execution_clock_coordinate_", - "program_execution_set_field_timepoint_", - "program_execution_set_field_parameters_", - "program_execution_set_field_kernel_", + "program_execution_field_facade_", ): - assert source.count(hook) == 1, ( - "%s must provide exactly one explicit provider hook %s" % (context, hook) + definitions = re.findall(rf"(?m)^ \S[^\n;=]*\b{re.escape(hook)}\s*\(", source) + assert len(definitions) == 1, "%s must define exactly one explicit provider hook %s" % ( + context, + hook, ) +def test_boundary_point_provider_is_the_topology_primitive_not_a_mirrored_trampoline(): + for path in (UNIFORM, AMR): + source = _read(path) + assert "BoundaryEvaluationPoint boundary_point_(" not in source + assert "return boundary_point_(stage_id);" not in source + assert "BoundaryEvaluationPoint program_execution_boundary_point_(" in source + + +def test_field_state_evaluation_consumes_outcomes_in_the_shared_service(): + shared = _read(SHARED) + providers = (_read(UNIFORM), _read(AMR)) + + assert shared.count("consume_field_outcome_(") == 3 + assert shared.count("solve_fields_from_state_at(point, provider_slot, block,") == 2 + assert "program_execution_solve_fields_from_state_at_" not in shared + assert all( + "program_execution_solve_fields_from_state_at_" not in provider for provider in providers + ) + + +def test_generated_field_stage_workspace_is_one_shared_program_authority(): + shared = _read(SHARED) + uniform = _read(UNIFORM) + amr = _read(AMR) + + for authority in ( + "struct GeneratedFieldSolveWorkspace", + "prepare_generated_field_solve_workspace_", + "generated_field_solve_workspaces_", + "expected_program_blocks", + ): + assert authority in shared + assert authority not in uniform + assert authority not in amr + + for invariant in ( + "requires a non-negative IR identity", + "requires at least one stage override", + "IR identity was reused for a different field", + "block map is not injective", + "changed its ordered block pack", + "contains a duplicate Program block", + "generated field-solve stage does not match its exact runtime-block layout", + "generated field-solve stage cannot alias another block's live state", + ): + assert shared.count(invariant) == 1 + assert invariant not in uniform + assert invariant not in amr + + assert "ExclusiveUseGuard use(workspace.in_use," in shared + assert "struct WorkspaceUse" not in shared + assert "struct WorkspaceUse" not in uniform + assert "struct WorkspaceUse" not in amr + assert "sys_->solve_fields_from_blocks_at_in_place_(point, field, runtime_stages)" in uniform + assert "eng_->solve_named_fields_from_states_at(point, field, runtime_stages)" in amr + + +def test_field_evaluation_point_validation_is_shared_before_provider_dispatch(): + shared = _read(SHARED) + providers = (_read(UNIFORM), _read(AMR)) + validation = shared.split("void require_field_evaluation_point_(", 1)[1].split("\n public:", 1)[ + 0 + ] + + assert shared.count("void require_field_evaluation_point_(") == 1 + for invariant in ( + "point.clock.empty()", + "point.tick < 0", + "point.substep < 0", + "point.stage < 0", + "!(point.dt > 0.0)", + "!std::isfinite(point.dt)", + "!std::isfinite(point.physical_time)", + "point.stage_fraction < amr::Rational(0, 1)", + "amr::Rational(1, 1) < point.stage_fraction", + ): + assert invariant in validation + assert validation.index(invariant) < validation.index( + "provider_().program_execution_resource_level_()" + ) + assert ( + shared.count('require_field_evaluation_point_(point, "Program single-state field solve")') + == 1 + ) + assert ( + shared.count('require_field_evaluation_point_(point, "Program simultaneous field solve")') + == 1 + ) + assert all("require_field_evaluation_point_" not in provider for provider in providers) + + def test_grid_free_program_state_services_are_shared_not_mirrored(): shared = _read(SHARED) runtime_state = _read(PROGRAM_RUNTIME_STATE) providers = (_read(UNIFORM), _read(AMR)) - assert shared.count("program_runtime_state_().block_map()") == 2 + assert shared.count("program_runtime_state_().block_map()") == 3 assert shared.count("program_runtime_state_().record_diagnostic(name, value)") == 1 assert shared.count("program_runtime_state_().note_step_projection(name)") == 1 assert shared.count("program_runtime_state_().params(block)") == 1 @@ -380,6 +512,33 @@ def test_grid_free_program_state_services_are_shared_not_mirrored(): assert all(retired_hook not in provider for provider in providers) +def test_field_configuration_uses_one_shared_facade_dispatch(): + shared = _read(SHARED) + uniform = _read(UNIFORM) + amr = _read(AMR) + + for operation in ( + "set_field_logical_timepoint", + "set_field_boundary_parameters", + "set_field_boundary_kernel", + ): + assert shared.count("provider_().program_execution_field_facade_().%s" % operation) == 1 + assert operation not in uniform + assert operation not in amr + + for retired_hook in ( + "program_execution_set_field_timepoint_", + "program_execution_set_field_parameters_", + "program_execution_set_field_kernel_", + ): + assert retired_hook not in shared + assert retired_hook not in uniform + assert retired_hook not in amr + + assert "System& program_execution_field_facade_() const { return *sys_; }" in uniform + assert "AmrSystem& program_execution_field_facade_() const { return *facade_; }" in amr + + def test_clock_coordinate_is_one_shared_contract_not_three_provider_queries(): shared = _read(SHARED) providers = (_read(UNIFORM), _read(AMR)) @@ -541,12 +700,13 @@ def test_resource_topology_transaction_is_shared_while_raw_topology_and_scratch_ assert retired_direct_surface not in uniform assert retired_direct_surface not in amr assert retired_direct_surface not in emitter - for provider_owned_scratch in ( + for retired_provider_scratch in ( "program_scratch_topology_epoch_", "program_scratch_materialization_generation_", ): - assert provider_owned_scratch not in shared - assert provider_owned_scratch in amr + assert retired_provider_scratch not in shared + assert retired_provider_scratch not in uniform + assert retired_provider_scratch not in amr assert "ctx.for_each_program_resource_level(" in emitter assert "ctx.with_program_resource_level(" in emitter assert "ctx.set_level(" not in emitter @@ -655,7 +815,17 @@ def test_shared_projection_maps_the_program_block_once_and_leaves_native_dispatc shared = _read(SHARED) uniform = _read(UNIFORM) amr = _read(AMR) - assert "program_execution_apply_projection_(sys_block(block), state)" in shared + projection = shared.split("void apply_projection(int block, MultiFab& state) const {", 1)[ + 1 + ].split("\n }", 1)[0] + assert projection.count("const int runtime_block = sys_block(block);") == 1 + assert "program_execution_apply_projection_(runtime_block, state)" in projection + assert "program_execution_apply_projection_(sys_block(block), state)" not in projection + assert ( + projection.count("program_execution_projection_balance_integrals_(runtime_block, state)") + == 2 + ) + assert "program_execution_projection_balance_integrals_(block, state)" not in projection assert "sys_->block_project(runtime_block, state);" in uniform assert ( "eng_->project_level_state(static_cast(runtime_block), level_, state);" in amr @@ -665,6 +835,10 @@ def test_shared_projection_maps_the_program_block_once_and_leaves_native_dispatc 0 ] assert "sys_block(" not in projection_hook + balance_hook = provider.split("program_execution_projection_balance_integrals_", 1)[ + 1 + ].split("Real program_execution_hmin_", 1)[0] + assert "sys_block(" not in balance_hook def test_shared_cfl_dispatch_maps_the_program_block_once_and_leaves_topology_to_providers(): @@ -736,6 +910,8 @@ def test_shared_coupling_owns_workspace_mapping_layout_alias_and_reentrancy(): "cannot alias accepted live states", ): assert invariant in shared + assert "ExclusiveUseGuard use(coupling_workspace_.in_use," in shared + assert "struct WorkspaceUse" not in shared assert "program_execution_apply_coupling_(" in shared assert "sys_->apply_coupling_operators(dt, runtime_states)" in uniform assert "eng_->apply_coupling_operators_at_level(level_, dt, runtime_states)" in amr @@ -756,6 +932,31 @@ def test_logical_subdivision_is_shared_and_provider_rollback_is_opaque(): assert "amr::Rational(iteration, count)" not in amr +def test_persistent_scratch_registry_is_one_shared_resource_service(): + shared = _read(SHARED) + uniform = _read(UNIFORM) + amr = _read(AMR) + for authority in ( + "struct ProgramScratchKey", + "struct ProgramScratchSlot", + "struct ProgramScratchRegistry", + "MultiFab& persistent_scratch_", + ): + assert authority in shared + assert authority not in uniform + assert authority not in amr + assert "program_execution_scratch_" not in shared + assert "program_execution_scratch_" not in uniform + assert "program_execution_scratch_" not in amr + assert "const ProgramResourceTopology topology = program_resource_topology();" in shared + assert "const int level = this->level();" in shared + for invariant in ( + "non-negative IR value and sub-slot identities", + "persistent scratch level is out of range", + ): + assert shared.count(invariant) == 1 + + def test_error_schedule_is_shared_not_an_amr_capability_deferral(): amr = _read(AMR) support = _read(ROOT / "python" / "pops" / "runtime" / "amr_program_support.py") diff --git a/tests/python/architecture/test_program_only_temporal_facades.py b/tests/python/architecture/test_program_only_temporal_facades.py index 57bfd9901..122407831 100644 --- a/tests/python/architecture/test_program_only_temporal_facades.py +++ b/tests/python/architecture/test_program_only_temporal_facades.py @@ -47,6 +47,7 @@ IMPLICIT_STEPPER = ROOT / "include/pops/numerics/time/integrators/implicit_stepper.hpp" SYSTEM_IMPL = ROOT / "src/runtime/system/system_impl.hpp" SYSTEM_INSTALL = ROOT / "src/runtime/system/system_install.cpp" +PYTHON_SYSTEM_INSTALL = ROOT / "python/pops/runtime/_system_install.py" BINDINGS_DETAIL = ROOT / "python/bindings/core/bindings_detail.hpp" AMR_BINDING = ROOT / "python/bindings/core/init/init_amr.cpp" LEGACY_AMR_ADVANCE_HEADER = ROOT / "include/pops/numerics/time/amr/advance/amr_advance.hpp" @@ -259,6 +260,26 @@ def test_amr_program_cfl_does_not_require_native_advance_closures(): assert "parent_child_temporal_relation(child)" in refinement_preflight +def test_amr_regrid_cadence_is_decided_by_the_program_context(): + runtime = AMR_RUNTIME.read_text(encoding="utf-8") + context = AMR_PROGRAM_CONTEXT.read_text(encoding="utf-8") + + assert "void regrid_if_due(" not in runtime + assert "int regrid_interval() const noexcept" in runtime + spatial_regrid = _function_body(runtime, " void regrid()") + assert "macro_step" not in spatial_regrid + assert "regrid_every_" not in spatial_regrid + + cadence = _function_body(context, " void regrid_if_due_at_(") + assert "eng_->regrid_interval()" in cadence + assert "macro_step % interval" in cadence + assert "const bool regrid_due" in cadence + assert "interval <= 0" not in cadence + assert "eng_->regrid();" in cadence + assert "eng_->regrid_if_due(" not in cadence + assert cadence.count("materialize_capture_flux_scratch_();") == 1 + + def test_amr_blocks_expose_program_spatial_primitives_without_hidden_step_closures(): runtime = AMR_RUNTIME.read_text(encoding="utf-8") builder = AMR_DSL_BLOCK.read_text(encoding="utf-8") @@ -332,6 +353,22 @@ def test_amr_spatial_runtime_does_not_carry_an_unexecuted_implicit_solve(): assert "resolve_implicit_components_compiled" not in source +def test_uniform_system_rejects_unpublished_newton_diagnostics_before_allocation(): + native = _function_body( + SYSTEM_INSTALL.read_text(encoding="utf-8"), + "void System::add_block(", + ) + python = PYTHON_SYSTEM_INSTALL.read_text(encoding="utf-8") + python_add_equation = _python_function_source(python, "add_equation") + + assert "newton_diagnostics=true is unavailable" in native + assert "no typed implicit Program consumer publishes that report" in native + assert "diagnostics_.newton_reports[name]" not in native + assert python_add_equation.index( + "_reject_unpublished_newton_diagnostics(time" + ) < python_add_equation.index("native_block_scalars(") + + def test_amr_runtime_and_builders_do_not_decode_a_second_time_method(): for path in ( AMR_SYSTEM_HEADER, diff --git a/tests/python/architecture/test_public_api_parity_proof.py b/tests/python/architecture/test_public_api_parity_proof.py new file mode 100644 index 000000000..f352cdfba --- /dev/null +++ b/tests/python/architecture/test_public_api_parity_proof.py @@ -0,0 +1,198 @@ +"""ADC-689 source/wheel public API and typing parity proof.""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import zipfile + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "prove_public_api_parity.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("_public_api_parity_test", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +proof = _load() + +_METADATA = "Metadata-Version: 2.3\nName: PoPS\nVersion: 1.0.0\n" + + +def _distribution_identity() -> dict[str, str]: + return proof._distribution_identity(_METADATA.encode("utf-8"), label="test") + + +def _synthetic_wheel(path: Path, *, omit: str | None = None) -> None: + with zipfile.ZipFile(path, "w") as archive: + for source in sorted(proof.SOURCE_PACKAGE.rglob("*")): + if not source.is_file() or "__pycache__" in source.parts: + continue + relative = source.relative_to(proof.SOURCE_PACKAGE).as_posix() + if relative == omit: + continue + archive.write(source, "pops/" + relative) + archive.writestr( + "pops-1.0.0.dist-info/METADATA", + _METADATA, + ) + + +def _installed_package(root: Path) -> Path: + package = root / "site-packages" / "pops" + shutil.copytree( + proof.SOURCE_PACKAGE, + package, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + return package + + +def _installed_distribution(root: Path) -> Path: + package = _installed_package(root) + distribution = package.parent / "pops-1.0.0.dist-info" + distribution.mkdir() + (distribution / "METADATA").write_text( + _METADATA, + encoding="utf-8", + ) + (distribution / "RECORD").write_text( + "pops/__init__.py,,\n" + "pops-1.0.0.dist-info/METADATA,,\n" + "pops-1.0.0.dist-info/RECORD,,\n", + encoding="utf-8", + ) + return package + + +def test_exact_wheel_and_source_share_public_api_typing_and_lazy_authoring(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + installed = _installed_package(tmp_path) + + evidence = proof.build_proof( + wheel, + installed_package=installed, + installed_distribution=_distribution_identity(), + ) + + assert evidence["schema_version"] == 3 + assert evidence["producer"]["script"] == "scripts/prove_public_api_parity.py" + assert evidence["distribution"] == _distribution_identity() + assert evidence["public_names"] == list(proof.PUBLIC_ROOT) + assert evidence["pure_authoring"] is True + assert evidence["qualified_handles"] is True + assert evidence["py_typed"] is True + assert evidence["typed_payload_files"] > 100 + assert evidence["installed"] is True + assert evidence["installed_distribution"] == evidence["distribution"] + assert evidence["installed_package"] == str(installed.resolve()) + assert evidence["installed_typed_payload_sha256"] == evidence["typed_payload_sha256"] + assert evidence["installed_public_api_sha256"] == evidence["public_api_sha256"] + + +def test_wheel_proof_fails_closed_when_typing_payload_is_missing(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel, omit="_pops.pyi") + installed = _installed_package(tmp_path) + + with pytest.raises(proof.PublicApiParityError, match="typing payload"): + proof.build_proof(wheel, installed_package=installed) + + +def test_installed_proof_rejects_payload_drift_and_source_checkout_alias(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + installed = _installed_package(tmp_path) + (installed / "__init__.py").write_text( + (installed / "__init__.py").read_text(encoding="utf-8") + "\nDRIFT = True\n", + encoding="utf-8", + ) + + with pytest.raises(proof.PublicApiParityError, match="installed Python/typing payload"): + proof.build_proof(wheel, installed_package=installed) + with pytest.raises(proof.PublicApiParityError, match="inside the source checkout"): + proof.build_proof(wheel, installed_package=proof.SOURCE_PACKAGE) + + +def test_installed_proof_rejects_distribution_identity_drift(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + installed = _installed_package(tmp_path) + drifted = {**_distribution_identity(), "version": "1.0.1"} + + with pytest.raises(proof.PublicApiParityError, match="distribution identity"): + proof.build_proof( + wheel, + installed_package=installed, + installed_distribution=drifted, + ) + + +def test_installed_cli_resolves_distribution_after_install_without_checkout_shadowing( + tmp_path, +): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + installed = _installed_distribution(tmp_path) + evidence = tmp_path / "installed-public-api.json" + environment = os.environ.copy() + environment["PYTHONPATH"] = str(installed.parent) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + + completed = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--wheel", + str(wheel), + "--installed", + "--evidence", + str(evidence), + ], + cwd=tmp_path, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + assert completed.returncode == 0, completed.stdout + payload = json.loads(evidence.read_text(encoding="utf-8")) + assert payload["installed"] is True + assert payload["distribution"] == _distribution_identity() + assert payload["installed_distribution"] == payload["distribution"] + assert payload["installed_package"] == str(installed.resolve()) + assert payload["installed_typed_payload_sha256"] == payload["typed_payload_sha256"] + assert payload["installed_public_api_sha256"] == payload["public_api_sha256"] + + +def test_release_workflow_blocks_publication_on_source_wheel_api_parity(): + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + validate = workflow[workflow.index(" validate:") : workflow.index(" release:")] + + assert "scripts/prove_public_api_parity.py" in validate + assert '--wheel "${wheels[0]}"' in validate + assert "--installed" in validate + assert 'pops-final-evidence-public-api.json' in validate + assert '--public-api-evidence "$public_api_evidence"' in validate + assert validate.index("scripts/run_final_gate.py") < validate.index( + "scripts/prove_public_api_parity.py") + assert validate.index("scripts/prove_public_api_parity.py") < validate.index( + "scripts/release_preflight.py") diff --git a/tests/python/architecture/test_qualified_automatic_balance_route_fence.py b/tests/python/architecture/test_qualified_automatic_balance_route_fence.py new file mode 100644 index 000000000..71768e4c8 --- /dev/null +++ b/tests/python/architecture/test_qualified_automatic_balance_route_fence.py @@ -0,0 +1,72 @@ +"""ADC-686: public Balance routes select qualified native evidence fail-closed.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +LEDGER = ROOT / "python" / "pops" / "_balance_contract.py" +MEASURES = ROOT / "python" / "pops" / "diagnostics" / "measures.py" +CONSUMERS = ROOT / "python" / "pops" / "runtime" / "_runtime_consumers.py" +PROGRAM_STATE = ( + ROOT / "include" / "pops" / "runtime" / "program" / "program_runtime_state.hpp" +) +SYSTEM = ROOT / "src" / "runtime" / "system" / "system_program.cpp" +AMR = ROOT / "src" / "runtime" / "amr" / "amr_system.cpp" +SYSTEM_BINDING = ROOT / "python" / "bindings" / "core" / "init" / "init_system.cpp" +AMR_BINDING = ROOT / "python" / "bindings" / "core" / "init" / "init_amr.cpp" + + +def _between(text: str, begin: str, end: str) -> str: + return text.split(begin, 1)[1].split(end, 1)[0] + + +def test_public_ledger_owns_role_and_exact_automatic_term_selection() -> None: + ledger = LEDGER.read_text() + assert "role: Any = None" in ledger + assert "component: int | None = None" in ledger + assert "automatic_terms: tuple[str, ...] = ()" in ledger + assert '{"reflux", "projection"}' in ledger + + measures = MEASURES.read_text() + balance = _between(measures, "class Balance(_Measure):", "class ConservationCheck") + assert "role=ledger.role" in balance + assert '"automatic_terms": list(self.ledger.automatic_terms)' in balance + assert '"balance_component": self.ledger.component' in balance + + +def test_runtime_uses_selected_native_entrypoint_only_for_delegated_terms() -> None: + consumers = CONSUMERS.read_text() + native = _between( + consumers, + "def _native_balance_terms(", + "def _diagnostic_values(", + ) + assert '"_selected_accepted_balance_terms"' in native + assert 'if automatic_terms' in native + assert "native(route, block, component, list(levels), list(automatic_terms))" in native + assert "else native(route)" in native + + for binding in (SYSTEM_BINDING, AMR_BINDING): + assert '"_selected_accepted_balance_terms"' in binding.read_text() + + +def test_native_selector_requires_complete_owner_level_component_evidence() -> None: + state = PROGRAM_STATE.read_text() + selector = _between( + state, + "std::map selected_accepted_balance_terms(", + "void begin_balance_due_window(", + ) + assert "AutomaticBalanceKey key{runtime_block, levels[index], component, term}" in selector + assert "native producer omitted term" in selector + assert "both Program and native producer authority" in selector + assert 'term == "reflux" ? levels.size() - 1 : levels.size()' in selector + + uniform = SYSTEM.read_text() + assert "const int runtime_block = p_->index(block);" in uniform + assert "levels != std::vector{0}" in uniform + + adaptive = AMR.read_text() + assert "const std::size_t runtime_block = p_->block_index_or_throw(block);" in adaptive + assert "p_->runtime->block_n_vars(runtime_block)" in adaptive + assert "p_->runtime->nlev()" in adaptive diff --git a/tests/python/architecture/test_release_contract.py b/tests/python/architecture/test_release_contract.py index 96e565b6b..20ad95614 100644 --- a/tests/python/architecture/test_release_contract.py +++ b/tests/python/architecture/test_release_contract.py @@ -8,6 +8,8 @@ import sys import types +import pytest + ROOT = Path(__file__).resolve().parents[3] @@ -75,7 +77,8 @@ def test_final_and_release_preflights_verify_cpp_duration_catalogs_before_build( def test_release_contract_versions_every_protocol_and_declares_exact_matrix(): generated = _load("_release_contract_test", ROOT / "python" / "pops" / "_generated_release_contract.py") - source = json.loads((ROOT / "schemas" / "release_contract.v1.json").read_text()) + source = json.loads((ROOT / "schemas" / "release_contract.v2.json").read_text()) + assert source["release_contract_schema_version"] == 2 assert generated.PACKAGE_VERSION == "1.0.0" for name in ( "public_api_version", "semantic_ir_version", "normalization_version", @@ -95,6 +98,57 @@ def test_release_contract_versions_every_protocol_and_declares_exact_matrix(): assert "CUDA wheel" in generated.SUPPORTED_MATRIX["not_promised"] +def test_release_contract_authenticates_component_catalog_digests(): + import copy + import hashlib + + generated = _load( + "_release_contract_component_digest_test", + ROOT / "python" / "pops" / "_generated_release_contract.py", + ) + catalog = json.loads((ROOT / "schemas" / "component_catalog.v2.json").read_text()) + canonical = json.dumps( + catalog, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + semantic = copy.deepcopy(catalog) + for family in semantic["route_families"]: + for route in family["routes"]: + route.pop("limitations", None) + route["metadata"].pop("summary", None) + semantic_canonical = json.dumps( + semantic, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + assert generated.COMPONENT_CATALOG_SHA256 == hashlib.sha256(canonical).hexdigest() + assert generated.COMPONENT_CATALOG_SEMANTIC_SHA256 == hashlib.sha256( + semantic_canonical + ).hexdigest() + release = _release_module().contract() + assert release["component_catalog_sha256"] == generated.COMPONENT_CATALOG_SHA256 + assert ( + release["component_catalog_semantic_sha256"] + == generated.COMPONENT_CATALOG_SEMANTIC_SHA256 + ) + + +def test_release_generator_rejects_stale_component_catalog_digest(tmp_path): + generator = _load( + "_release_contract_stale_component_digest_test", + ROOT / "scripts" / "generate_release_contract.py", + ) + payload = json.loads((ROOT / "schemas" / "release_contract.v2.json").read_text()) + payload["component_catalog_sha256"] = "0" * 64 + source = tmp_path / "release_contract.v2.json" + source.write_text(json.dumps(payload), encoding="utf-8") + generator.SOURCE = source + + with pytest.raises( + generator.ContractError, + match="component_catalog_sha256 drifted from component_catalog.v2.json", + ): + generator._load() + + def test_pre_one_compatibility_uses_minor_boundary_and_post_one_uses_major_boundary(): release = _release_module() assert release.package_compatible(requested="0.3.0", available="0.3.9") @@ -110,4 +164,7 @@ def test_release_mode_cannot_run_without_tag_install_and_authenticated_evidence( cwd=ROOT, text=True, capture_output=True, ) assert result.returncode != 0 - assert "requires --tag, --installed and --evidence" in result.stderr + assert ( + "requires --tag, --installed, --evidence and --public-api-evidence" + in result.stderr + ) diff --git a/tests/python/architecture/test_release_matrix_preflight.py b/tests/python/architecture/test_release_matrix_preflight.py index fb3583e7f..dd7352561 100644 --- a/tests/python/architecture/test_release_matrix_preflight.py +++ b/tests/python/architecture/test_release_matrix_preflight.py @@ -14,7 +14,7 @@ CONTRACT = ROOT / "scripts" / "final_release_contract.py" PROOF_SOURCES = ( Path("CMakeLists.txt"), - Path("schemas/release_contract.v1.json"), + Path("schemas/release_contract.v2.json"), Path(".github/actions/setup-kokkos/action.yml"), Path(".github/workflows/ci.yml"), Path(".github/workflows/wheels.yml"), @@ -84,7 +84,7 @@ def test_release_matrix_preflight_refuses_workflow_drift(tmp_path, relative, old def test_release_matrix_preflight_refuses_an_unimplemented_declared_lane(tmp_path): _copy_proof_sources(tmp_path) - path = tmp_path / "schemas" / "release_contract.v1.json" + path = tmp_path / "schemas" / "release_contract.v2.json" payload = json.loads(path.read_text(encoding="utf-8")) payload["supported_matrix"]["wheels"].append( { diff --git a/tests/python/architecture/test_root_output_consumer_lane_fence.py b/tests/python/architecture/test_root_output_consumer_lane_fence.py new file mode 100644 index 000000000..e48dbe16f --- /dev/null +++ b/tests/python/architecture/test_root_output_consumer_lane_fence.py @@ -0,0 +1,46 @@ +"""ADC-683 fences for run-owned native ROOT scientific-output communication.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +COLLECTIVE = ROOT / "include/pops/runtime/output_piece_collective.hpp" +SYSTEM = ROOT / "include/pops/runtime/system.hpp" +AMR = ROOT / "include/pops/runtime/amr_system.hpp" +SYSTEM_BINDING = ROOT / "python/bindings/core/init/init_system.cpp" +AMR_BINDING = ROOT / "python/bindings/core/init/init_amr.cpp" +RUNTIME = ROOT / "python/pops/runtime/_runtime_consumers.py" +STUB = ROOT / "python/pops/_pops.pyi" + + +def test_native_root_output_surface_requires_an_owned_consumer_lane(): + collective = COLLECTIVE.read_text(encoding="utf-8") + system = SYSTEM.read_text(encoding="utf-8") + amr = AMR.read_text(encoding="utf-8") + + assert "WorldCommunicator" not in collective + assert "MPI_COMM_WORLD" not in collective + assert "const ObserverMpiLane& lane" in collective + assert "const ObserverMpiLane& lane" in system + assert "const ObserverMpiLane& lane" in amr + + +def test_python_root_output_bridge_rejects_the_process_world_type(): + system = SYSTEM_BINDING.read_text(encoding="utf-8") + amr = AMR_BINDING.read_text(encoding="utf-8") + stub = STUB.read_text(encoding="utf-8") + + assert "WorldCommunicator" not in system + assert "WorldCommunicator" not in amr + assert "const ObserverMpiLane& lane" in system + assert "const ObserverMpiLane& lane" in amr + assert "lane: _NativeObserverMpiLane" in stub + + +def test_runtime_materializes_and_closes_one_root_output_lane_per_run(): + runtime = RUNTIME.read_text(encoding="utf-8") + + assert 'lane_identity = "scientific-output/root/%s" % run_identity.token' in runtime + assert "self._communicator.duplicate_observer_lane(lane_identity)" in runtime + assert "root_lane.close_collectively()" in runtime + assert "native_communicator = lane_provider()" in runtime diff --git a/tests/python/architecture/test_route_registry_parity.py b/tests/python/architecture/test_route_registry_parity.py index 24e58158f..ca5ba851f 100644 --- a/tests/python/architecture/test_route_registry_parity.py +++ b/tests/python/architecture/test_route_registry_parity.py @@ -20,6 +20,7 @@ DISPATCH_API = ROOT / "include" / "pops" / "runtime" / "config" / "dispatch_tags.hpp" MODEL_API = ROOT / "include" / "pops" / "runtime" / "dynamic" / "model_registry.hpp" MODULE_CAPABILITIES = ROOT / "include" / "pops" / "runtime" / "module_capabilities.hpp" +SCHEME_DISPATCH = ROOT / "include" / "pops" / "runtime" / "builders" / "scheme_dispatch.hpp" def _load(path: Path, name: str): @@ -74,6 +75,31 @@ def test_native_capability_layout_parity_uses_the_generated_route_tokens(): assert '"uniform|amr"' not in source +def test_mc_and_superbee_use_the_generated_prepared_limiter_registry() -> None: + catalog = json.loads(CATALOG.read_text(encoding="utf-8")) + limiter = next(family for family in catalog["route_families"] + if family["name"] == "limiter") + rows = {row["token"]: row for row in limiter["routes"]} + dispatch = SCHEME_DISPATCH.read_text(encoding="utf-8") + capabilities = MODULE_CAPABILITIES.read_text(encoding="utf-8") + + for token, cpp_id, native_entry, cpp_type in ( + ("mc", "kMc", "pops::MC", "MC"), + ("superbee", "kSuperbee", "pops::Superbee", "Superbee"), + ): + row = rows[token] + assert row["cpp_id"] == cpp_id + assert row["native_entry"] == native_entry + assert row["metadata"] == { + "n_ghost": 2, "formal_order": 2, "muscl_compatible": True, + } + assert "X(%s, %s)" % (cpp_id, cpp_type) in dispatch + assert 'capability_route("limiter:%s", "available"' % token in capabilities + + assert 'capability_route("limiter:mc", "unavailable"' not in capabilities + assert 'capability_route("limiter:superbee", "unavailable"' not in capabilities + + def test_one_catalog_row_generates_both_language_surfaces(): generator = _load(GENERATOR, "_component_catalog_generator_contract") catalog = json.loads(CATALOG.read_text(encoding="utf-8")) diff --git a/tests/python/architecture/test_runtime_builder_manifest.py b/tests/python/architecture/test_runtime_builder_manifest.py index a37a08e94..087e0835b 100644 --- a/tests/python/architecture/test_runtime_builder_manifest.py +++ b/tests/python/architecture/test_runtime_builder_manifest.py @@ -32,12 +32,14 @@ REPO_ROOT / "python" / "pops" / "runtime" / "_generated_component_routes.py" ) -# The 13 (transport, flux) leaf TUs that USED to be hand-written and are now generated. They must NOT -# reappear as tracked source files; regenerating them into the source tree would defeat the manifest. +# The historical leaf TUs plus the capability-driven isothermal HLLC/Roe leaves are generated. They +# must not reappear as tracked source files; generating them into the source tree defeats the manifest. GENERATED_LEAF_PATHS = ( "system/base/system_exb.cpp", "system/isothermal/system_isothermal_rusanov.cpp", "system/isothermal/system_isothermal_hll.cpp", + "system/isothermal/system_isothermal_hllc.cpp", + "system/isothermal/system_isothermal_roe.cpp", "system/compressible/system_compressible_rusanov.cpp", "system/compressible/system_compressible_hll.cpp", "system/compressible/system_compressible_hllc.cpp", diff --git a/tests/python/architecture/test_system_layout_transfer_communicator_fence.py b/tests/python/architecture/test_system_layout_transfer_communicator_fence.py new file mode 100644 index 000000000..f7da3877a --- /dev/null +++ b/tests/python/architecture/test_system_layout_transfer_communicator_fence.py @@ -0,0 +1,49 @@ +"""ADC-683 fences for execution-owned System layout-transfer collectives.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SOURCE = ROOT / "src/runtime/system/system_layout_transfer.cpp" + + +def _function(source: str, signature: str) -> str: + start = source.index(signature) + opening_brace = source.index("{", start) + depth = 0 + for offset in range(opening_brace, len(source)): + token = source[offset] + if token == "{": + depth += 1 + elif token == "}": + depth -= 1 + if depth == 0: + return source[start : offset + 1] + raise AssertionError(f"unterminated C++ function {signature}") + + +def test_layout_transfer_accepts_a_live_world_congruent_execution_context(): + source = SOURCE.read_text(encoding="utf-8") + resolver = _function(source, "CommunicatorView resolve_execution_communicator(") + + assert "MPI_COMM_WORLD" not in source + assert "MPI_Comm_f2c" in resolver + assert "MPI_Comm_compare(communicator, field_rank_space.native_handle()" in resolver + assert "relation != MPI_IDENT && relation != MPI_CONGRUENT" in resolver + assert "POPS_EXECUTION_NONCOLLECTIVE_IDENTITY_V1" in resolver + assert "return CommunicatorView{communicator};" in resolver + + +def test_layout_transfer_retains_the_resolved_context_for_every_hot_collective(): + source = SOURCE.read_text(encoding="utf-8") + implementation = source.split("struct PreparedSystemLayoutTransfer::Impl", maxsplit=1)[1] + hot_path = source.split("void PreparedSystemLayoutTransfer::begin_transaction", maxsplit=1)[1] + + assert source.count("world_communicator_view()") == 1 + assert "CommunicatorView communicator;" in implementation + assert "CommunicatorView world;" not in implementation + assert "p_->world" not in hot_path + assert "world_communicator_view()" not in hot_path + assert "parallel_copy(p_->source_snapshot, p_->source_state(), p_->communicator)" in hot_path + assert "collective_elements(local_source_elements, p_->communicator)" in hot_path + assert "collective_elements(local_target_elements, p_->communicator)" in hot_path diff --git a/tests/python/architecture/test_variable_recovery_consumer_cutover.py b/tests/python/architecture/test_variable_recovery_consumer_cutover.py new file mode 100644 index 000000000..7900463b7 --- /dev/null +++ b/tests/python/architecture/test_variable_recovery_consumer_cutover.py @@ -0,0 +1,158 @@ +"""Architecture fence for the bounded ADC-755 runtime-consumer cutover.""" + +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[3] +BLOCK_BUILDER = ROOT / "include/pops/runtime/builders/block/block_builder.hpp" +SYSTEM_FIELDS = ROOT / "src/runtime/system/system_fields.cpp" +PROGRAM_SERVICES = ROOT / "include/pops/runtime/program/program_execution_services.hpp" +PROGRAM_CONTEXT = ROOT / "include/pops/runtime/program/program_context.hpp" +AMR_PROGRAM_CONTEXT = ROOT / "include/pops/runtime/program/amr_program_context.hpp" +FLUX_FAILURE = ROOT / "include/pops/numerics/fv/flux_failure.hpp" +FACE_FLUX = ROOT / "include/pops/numerics/spatial/primitives/face_flux.hpp" +RECOVERY = ROOT / "include/pops/numerics/nonlinear/prepared_variable_recovery.hpp" +SPATIAL_RECOVERY_CONSUMERS = ( + FACE_FLUX, + ROOT / "include/pops/numerics/spatial/operators/cartesian_operator.hpp", + ROOT / "include/pops/numerics/spatial/operators/masked_operator.hpp", + ROOT / "include/pops/numerics/spatial/operators/polar_operator.hpp", + ROOT / "include/pops/numerics/spatial/embedded_boundary/operator.hpp", +) + + +def _between(source: str, begin: str, end: str) -> str: + return source.split(begin, 1)[1].split(end, 1)[0] + + +def test_cell_primitive_conversion_has_one_prepared_fail_closed_authority(): + source = BLOCK_BUILDER.read_text(encoding="utf-8") + conversion = _between( + source, "make_cell_convert(const Model& m)", "\n}\n\n} // namespace pops" + ) + + assert "prepare_model_variable_recovery(m)" in conversion + assert "recover_prepared_variable(" in conversion + assert "outcome.publication_permitted()" in conversion + assert "return recovery_report(outcome)" in conversion + assert "m.to_primitive" not in conversion + + +def test_runtime_materialization_consumes_only_prepared_batch_before_publication(): + source = SYSTEM_FIELDS.read_text(encoding="utf-8") + materialization = _between( + source, + "std::vector System::get_primitive_state", + "\nSolveReport System::solve_fields_in_place_", + ) + + required = materialization.index("if (!s.batch_cons_to_prim)") + recovery = materialization.index("s.batch_cons_to_prim(cons, prim)", required) + refusal = materialization.index("if (!batch.publication_permitted())", recovery) + publication = materialization.index("return prim;", refusal) + assert required < recovery < refusal < publication + assert "variable recovery failed" in materialization + assert "generation-qualified prepared batch recovery consumer" in materialization + assert "s.cons_to_prim(cell_in.data(), cell_out.data())" not in materialization + + +def test_runtime_materialization_has_no_pointwise_compatibility_authority(): + source = SYSTEM_FIELDS.read_text(encoding="utf-8") + materialization = _between( + source, + "std::vector System::get_primitive_state", + "\nSolveReport System::solve_fields_in_place_", + ) + + assert "Compatibility path" not in materialization + assert "if (s.batch_cons_to_prim)" not in materialization + assert "std::vector cell_in" not in materialization + + +def test_type_erased_recovery_report_preserves_actual_method_identity(): + source = RECOVERY.read_text(encoding="utf-8") + report = _between(source, "struct RecoveryReport {", "\n};\n\nstatic_assert") + erasure = _between( + source, + "POPS_HD inline RecoveryReport recovery_report", + "\n}\n\ninline constexpr const char* recovery_status_name", + ) + + assert "RecoveryMethodKind selected_method_kind" in report + assert "RecoveryMethodKind last_method_kind" in report + assert "outcome.selected_method_kind" in erasure + assert "outcome.last_method_kind" in erasure + + +def test_runtime_recovery_failure_names_last_attempted_method(): + source = SYSTEM_FIELDS.read_text(encoding="utf-8") + materialization = _between( + source, + "std::vector System::get_primitive_state", + "\nSolveReport System::solve_fields_in_place_", + ) + + assert "recovery_method_kind_name(recovery.last_method_kind)" in materialization + assert "last_method_index=" in materialization + assert "std::to_string(recovery.last_method)" in materialization + + +def test_runtime_layer_has_no_independent_direct_primitive_recovery(): + runtime_sources = ( + *ROOT.glob("include/pops/runtime/**/*.hpp"), + *ROOT.glob("src/runtime/**/*.cpp"), + ) + bypasses = [] + for path in runtime_sources: + source = path.read_text(encoding="utf-8") + if re.search(r"\b(?:m|model)\.to_primitive\s*\(", source): + bypasses.append(path.relative_to(ROOT).as_posix()) + assert bypasses == [] + + +def test_program_terminal_state_publication_validates_every_candidate_before_first_copy(): + shared = PROGRAM_SERVICES.read_text(encoding="utf-8") + commit = _between( + shared, + "void commit_many(std::initializer_list> commits)", + "\n /// Apply every coupled-source operator", + ) + validation = commit.index("program_execution_validate_commit_candidates_(commits)") + publication = commit.index("lincomb(*target", validation) + assert validation < publication + + uniform = PROGRAM_CONTEXT.read_text(encoding="utf-8") + assert "validate_program_state_publication_candidate(block, *candidate)" in uniform + + amr = AMR_PROGRAM_CONTEXT.read_text(encoding="utf-8") + assert "require_recoverable_block_candidate_(" in amr + assert "AMR Program terminal state publication" in amr + + +def test_face_reconstruction_returns_and_consumes_one_typed_recovery_report(): + reconstruction = FACE_FLUX.read_text(encoding="utf-8") + assert "struct ReconstructedFaceState" in reconstruction + assert "prepare_model_variable_recovery(model)" in reconstruction + assert "recover_prepared_variable(plan" in reconstruction + assert "recovery_report(outcome)" in reconstruction + assert "model.to_primitive" not in reconstruction + + failure_channel = FLUX_FAILURE.read_text(encoding="utf-8") + assert "record_recovery(const RecoveryReport& report" in failure_channel + assert "recovery_evaluation_status(report.status)" in failure_channel + + +def test_every_production_spatial_path_consumes_recovery_before_flux_evaluation(): + bypasses = [] + missing_consumers = [] + for path in SPATIAL_RECOVERY_CONSUMERS: + source = path.read_text(encoding="utf-8") + if re.search(r"\breconstruct_pp\s*<\s*Model\s*>\s*\(", source): + bypasses.append(path.relative_to(ROOT).as_posix()) + if "reconstruct_pp_recovered(" in source and ( + "record_reconstruction_recoveries(" not in source + ): + missing_consumers.append(path.relative_to(ROOT).as_posix()) + assert bypasses == [] + assert missing_consumers == [] diff --git a/tests/python/examples/final/test_hyqmom15_final_example.py b/tests/python/examples/final/test_hyqmom15_final_example.py index 3090b9656..b0e1c1dad 100644 --- a/tests/python/examples/final/test_hyqmom15_final_example.py +++ b/tests/python/examples/final/test_hyqmom15_final_example.py @@ -7,6 +7,7 @@ import sys import numpy as np +import pytest ROOT = Path(__file__).resolve().parents[4] @@ -32,11 +33,25 @@ def test_hyqmom15_example_runs_outputs_and_restarts_bit_identically(tmp_path) -> report_line = next( line for line in completed.stdout.splitlines() if line.startswith("report: ")) report = json.loads(report_line.removeprefix("report: ")) + from pops.time import ALL_PROVISIONAL_STORES + assert report["finite"] is True + assert report["realizable"] is True assert report["n_moments"] == 15 + assert report["particle_number"] == pytest.approx( + report["particle_number_reference"], + rel=report["particle_number_relative_tolerance"], + ) + assert ( + report["particle_number_relative_error"] + <= report["particle_number_relative_tolerance"] + ) assert report["nonrealizable_rollback"] is True assert "hyqmom15_realizability_density" in report["rejection_reason"] assert report["runtime_steps"] == 2 + assert report["rollback_stores"] == [ + store.value for store in ALL_PROVISIONAL_STORES + ] from pops.output import HDF5, ParaView diff --git a/tests/python/examples/final/test_imex_amr_final_example.py b/tests/python/examples/final/test_imex_amr_final_example.py index aacf8f3a4..7658dbb03 100644 --- a/tests/python/examples/final/test_imex_amr_final_example.py +++ b/tests/python/examples/final/test_imex_amr_final_example.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import importlib.util import json import os from pathlib import Path @@ -14,6 +15,15 @@ EXAMPLE = ROOT / "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py" +def _load_example(): + spec = importlib.util.spec_from_file_location("pops_final_imex_amr", EXAMPLE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> None: environment = dict(os.environ) environment["POPS_INCLUDE"] = str(ROOT / "include") @@ -33,6 +43,7 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non assert "bit-identical restart: True" in completed.stdout assert "bit-identical continuation: True" in completed.stdout assert "manual/pops.lib.time.IMEX parity: True" in completed.stdout + assert "rejected-attempt rollback: True" in completed.stdout assert "regrid count:" in completed.stdout report_line, = [line for line in completed.stdout.splitlines() if line.startswith("report: ")] report = json.loads(report_line.removeprefix("report: ")) @@ -40,11 +51,17 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non assert report["checkpoint_restart_bit_identical"] is True assert report["continuation_bit_identical"] is True assert report["manual_preset_bit_identical"] is True + assert report["rejected_attempt_rollback"] is True + assert report["rejected_attempt_error"].startswith( + "step attempt rejected during " + ) assert report["levels"] == 2 assert report["regrid_count"] >= 0 assert report["topology_epoch"] >= 0 assert report["regrid_count_after_continuation"] >= report["regrid_count"] assert report["topology_epoch_after_continuation"] >= report["topology_epoch"] + assert report["program_accepted_state_bytes"] > 0 + assert report["tagging_hysteresis_min_cycles"] == 2 assert report["flux_ledger_levels"] == [0, 1] assert report["synchronization_phases"] == ["reflux", "average_down"] assert report["runtime_steps"] == 1 @@ -53,6 +70,7 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non from pops.output import read_hdf5, read_npz, read_paraview output = tmp_path / "published" + assert not tuple((output / "rejected").rglob("*")) readers = {".h5": read_hdf5, ".npz": read_npz, ".vtu": read_paraview} for suffix, reader in readers.items(): # Scientific writers use the stable ``consumer__clock__step`` stem. Checkpoints are also @@ -64,6 +82,87 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non assert tuple(output.rglob("manual_restart*.npz")) +def test_resolved_amr_lowering_report_covers_every_executed_authority() -> None: + import pops + + example = _load_example() + target = example.build_final_case() + resolved = pops.resolve( + pops.validate(target.authoring.case), + layout=target.layout, + ) + coverage = resolved.lowering_coverage + amr_rows = tuple( + row for row in coverage.rows if row.source.startswith("amr-") + ) + assert amr_rows + assert all(row.disposition == "lowered" and row.targets for row in amr_rows) + + predicate_sources = { + row.source.rsplit(":", 1)[-1] + for row in amr_rows + if row.source.startswith("amr-tagging-predicate:") + } + assert predicate_sources == {"above", "any_of", "below", "gradient_above"} + source_families = { + row.source.split(":", 1)[0] + for row in amr_rows + } + assert { + "amr-bootstrap", + "amr-execution", + "amr-hierarchy", + "amr-regrid", + "amr-subcycling", + "amr-tagging-conflict-policy", + "amr-tagging-graph", + "amr-tagging-hysteresis", + "amr-tagging-predicate", + "amr-transfer-entry", + "amr-transfer-plan", + } <= source_families + transfer_targets = { + target + for row in amr_rows + for target in row.targets + if target.startswith("amr-runtime-transfer-operation:") + } + assert transfer_targets == { + "amr-runtime-transfer-operation:apply_transfer_provider:coarse_fine_fill", + "amr-runtime-transfer-operation:apply_transfer_provider:prolongation", + "amr-runtime-transfer-operation:apply_transfer_provider:restriction", + "amr-runtime-transfer-operation:apply_transfer_provider:temporal_interpolation", + "amr-runtime-transfer-operation:recompute:coarse_fine_fill", + } + assert any( + target == "amr-runtime-clock-relation:0-1:2/1" + for row in amr_rows + for target in row.targets + ) + hysteresis_row, = ( + row for row in amr_rows + if row.source.startswith("amr-tagging-hysteresis:") + ) + assert ( + "amr-runtime-program-accepted-state:tagging_hysteresis_state" + in hysteresis_row.targets + ) + + tagging = resolved.bootstrap_plan.tagging.inspect()["graph"] + assert tagging["refine"]["node_type"] == "any_of" + assert { + child["node_type"] for child in tagging["refine"]["children"] + } == {"above", "gradient_above"} + assert tagging["coarsen"]["node_type"] == "below" + assert tagging["hysteresis"] == { + "schema_version": 1, + "hysteresis_type": "min_cycles", + "min_cycles": 2, + "equality": "hold", + } + assert tagging["conflict_policy"] == "refine_wins" + + def test_normative_example_uses_only_the_final_root_lifecycle() -> None: source = EXAMPLE.read_text(encoding="utf-8") tree = ast.parse(source) @@ -75,6 +174,17 @@ def test_normative_example_uses_only_the_final_root_lifecycle() -> None: assert "pops.run(simulation," in source assert ".run(**" not in source assert "BindInputs" not in source + assert "simulation.program_accepted_state()" in source + for forbidden in ( + "ProgramContext", + "AmrProgramContext", + "SystemStepper", + "_executor", + "_begin_step_transaction", + "_commit_step_transaction", + "_rollback_step_transaction", + ): + assert forbidden not in source assert source.count("case.program(") == 1 assert source.count("case.consumers(") == 1 @@ -95,5 +205,5 @@ def test_normative_example_uses_only_the_final_root_lifecycle() -> None: and node.func.value.id == "pops" and node.func.attr == "run" ] - # Accepted manual step, uninterrupted continuation, restarted continuation and preset parity. - assert len(root_run) == 4 + # Rejected proof, accepted manual step, both continuations and preset parity. + assert len(root_run) == 5 diff --git a/tests/python/examples/final/test_multiphysics_core_example.py b/tests/python/examples/final/test_multiphysics_core_example.py index 7726e1ab3..5efa680f1 100644 --- a/tests/python/examples/final/test_multiphysics_core_example.py +++ b/tests/python/examples/final/test_multiphysics_core_example.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace import importlib.util from pathlib import Path import subprocess @@ -36,7 +37,10 @@ def test_example_script_runs_outputs_and_restart_without_mock_or_fallback(tmp_pa assert completed.returncode == 0, completed.stderr assert "PoPS final multiphysics acceptance:" in completed.stdout + assert "missing mapping refusal: missing mapping provider" in completed.stdout assert "bit-identical restart: step 2" in completed.stdout + refused = output / "refused_missing_mapping" + assert not refused.exists() or not any(path.is_file() for path in refused.rglob("*")) from pops.output import HDF5, ParaView @@ -45,6 +49,48 @@ def test_example_script_runs_outputs_and_restart_without_mock_or_fallback(tmp_pa output / "accepted" / "visualization" / "two_fluid").latest assert hdf5.output_identity.token in completed.stdout assert paraview.output_identity.token in completed.stdout + example = _load_example() + target = example.build_final_case( + cells=8, + output_mode=example._native_output_mode(), + ) + import pops + + resolved = pops.resolve( + target.authoring.case, + layout=target.layout_plan, + layout_providers={target.layout_handle: target.layout_provider}, + ) + diagnostic_output = next( + node + for node in resolved.consumer_graph.nodes + if node.target_uri == "state/two_fluid" + ) + quantities = { + quantity.identity.token: quantity + for quantity in diagnostic_output.diagnostic_quantities + } + diagnostic_rows = hdf5.manifest["snapshot"]["diagnostics"] + assert len(diagnostic_rows) == 6 + assert {row["key"]["state_id"] for row in diagnostic_rows} == set(quantities) + from pops.identity import Identity + + for row in diagnostic_rows: + quantity = quantities[row["key"]["state_id"]] + assert row["key"]["reference"] == quantity.reference.canonical_identity() + assert row["key"]["reduction"] == "integral" + assert row["key"]["level"] == 0 + assert Identity.from_token(row["key"]["layout_identity"]).domain == "layout" + block = quantity.reference.block_ref.local_id + role = quantity.execution["role"] + coefficient = quantity.execution["operations"][0]["coefficient"] + expected_coefficient = -1.0 if (block, role) == ("electrons", "Density") else 1.0 + assert coefficient == expected_coefficient.hex() + if role == "Density": + value = float.fromhex(row["value"]) + assert value < 0.0 if block == "electrons" else value > 0.0 + # State-space units intentionally fail closed until PoPS has a typed unit protocol. + assert row["units"] == "unspecified" checkpoint = output / "accepted_restart.npz" assert checkpoint.is_file() @@ -58,6 +104,48 @@ def test_example_script_runs_outputs_and_restart_without_mock_or_fallback(tmp_pa assert "field_provider_slots" in stored +def test_missing_mapping_provider_refuses_before_plan_or_publication(tmp_path) -> None: + example = _load_example() + refused = tmp_path / "refused_missing_mapping" + + reason = example.require_missing_mapping_provider_refusal( + cells=8, publication_root=refused, + ) + + assert "missing mapping provider" in reason + assert not refused.exists() or not any(path.is_file() for path in refused.rglob("*")) + + +@pytest.mark.parametrize( + ("field", "changed"), + ( + ("bind_identity", "pops.bind.v1::changed"), + ("layout_plan_identity", "pops.layout-plan.v1::changed"), + ("layout_identities", ("pops.handle.v1::changed",)), + ), +) +def test_restart_snapshot_refuses_bind_or_layout_identity_drift(field, changed) -> None: + example = _load_example() + snapshot = example.RuntimeSnapshot( + time=0.0, + macro_step=0, + states={"electrons": np.zeros((3, 1, 1))}, + fields={"electrostatic": np.zeros((1, 1))}, + histories={"electrons.electrons": (np.zeros((3, 1, 1)),)}, + bind_identity="pops.bind.v1::accepted", + layout_plan_identity="pops.layout-plan.v1::accepted", + layout_identities=("pops.handle.v1::accepted",), + program_hash="program", + consumer_graph_identity="consumer-graph", + consumer_cursors={}, + ) + + with pytest.raises(RuntimeError, match=field): + example._require_same_snapshot( + snapshot, replace(snapshot, **{field: changed}), where="strict restart", + ) + + def test_program_has_exact_field_context_and_transactional_implicit_join() -> None: core = _load_example().build_authoring() values = tuple(core.program._values) @@ -75,6 +163,16 @@ def test_program_has_exact_field_context_and_transactional_implicit_join() -> No (field_token.inputs[0].block, field_token.inputs[0].id), (field_token.inputs[1].block, field_token.inputs[1].id), ) + collision_token = next( + value for value in values if value.op == "solve_coupled_implicit" + ) + assert collision_token.attrs["operator"] == "implicit_collision" + assert collision_token.attrs["problem_kind"] == "coupled_implicit_euler" + assert collision_token.attrs["method"] == "newton" + assert collision_token.attrs["max_iter"] == 12 + assert tuple( + block.local_id for block in collision_token.attrs["blocks"] + ) == ("electrons", "ions") solve_actions = { value.inputs[0].op: value.attrs["action"].kind for value in values if value.op == "solve_outcome" @@ -145,10 +243,85 @@ def test_case_resolves_explicit_layout_consumers_and_two_provider_field() -> Non assert resolved.consumer_graph.is_resolved assert sorted(node.kind.value for node in resolved.consumer_graph.nodes) == [ "checkpoint", "scientific_output", "scientific_output"] + diagnostic_output = next( + node + for node in resolved.consumer_graph.nodes + if node.target_uri == "state/two_fluid" + ) + assert len(diagnostic_output.diagnostics) == 6 + assert len(diagnostic_output.diagnostic_quantities) == 6 + expected_diagnostics = { + ("electrons", "Density"), + ("electrons", "MomentumX"), + ("electrons", "MomentumY"), + ("ions", "Density"), + ("ions", "MomentumX"), + ("ions", "MomentumY"), + } + actual_diagnostics = { + ( + quantity.reference.block_ref.local_id, + quantity.execution["role"], + ) + for quantity in diagnostic_output.diagnostic_quantities + } + assert actual_diagnostics == expected_diagnostics + assert { + quantity.layout_id + for quantity in diagnostic_output.diagnostic_quantities + } == {target.layout_handle.qualified_id} + assert all( + quantity.levels == (0,) + and quantity.execution["operations"] == ( + { + "name": "integral", + "reduction": "sum", + "transform": "identity", + "metric_weighted": True, + "coefficient": ( + -1.0 + if quantity.reference.block_ref.local_id == "electrons" + and quantity.execution["role"] == "Density" + else 1.0 + ).hex(), + }, + ) + for quantity in diagnostic_output.diagnostic_quantities + ) provider_pack = resolved.field_plans["electrostatic"].native_options["provider_pack"] assert [row["owner_block"] for row in provider_pack] == ["electrons", "ions"] assert [row["key"] for row in provider_pack] == ["electron_charge", "ion_charge"] field_plan = resolved.field_plans["electrostatic"] + native_options = field_plan.native_options + assert native_options["rhs"] == "composite" + assert native_options["method"] == { + "native_method": "cell_centered_second_order", + "order": 2, + "ghost_depth": 1, + } + assert native_options["bc"] == "explicit" + solver_provider = native_options["solver_provider"] + assert solver_provider["provider"]["provider_id"] == "pops.field-solver.geometric-mg" + assert { + face["type"] + for face in solver_provider["facts"]["boundary"]["faces"] + } == {"periodic"} + nullspace_provider = native_options["nullspace_provider"] + assert ( + nullspace_provider["provider"]["provider_id"] + == "pops.field-nullspace.constant" + ) + assert nullspace_provider["resolution"]["singular"] is True + assert ( + nullspace_provider["resolution"]["native_contract"]["options"]["gauge.value"] + == 0.0 + ) + equation = field_plan.operator.inspect()["physics"]["equation"]["equation"] + assert ( + equation["lhs"]["field_expression"]["type"] + == "pops._ir.expr.Laplacian" + ) + assert equation["rhs"]["protocol"] == "pops.expr.dag.v1" output_route = field_plan.native_options["output_route"] assert output_route["owner_block"] == "electrons" assert output_route["key"] == "electrostatic" diff --git a/tests/python/examples/final/test_scalar_advection_final_example.py b/tests/python/examples/final/test_scalar_advection_final_example.py index 6e9defd48..9311768f9 100644 --- a/tests/python/examples/final/test_scalar_advection_final_example.py +++ b/tests/python/examples/final/test_scalar_advection_final_example.py @@ -4,6 +4,10 @@ import importlib.util from pathlib import Path import sys +from types import SimpleNamespace + +import numpy as np +import pytest ROOT = Path(__file__).resolve().parents[4] @@ -133,6 +137,10 @@ def test_target_has_one_authority_per_concern_and_no_legacy_path(): assert "def preset_ssprk2(" in source assert "read_hdf5(" in source assert "read_paraview(" in source + assert "_scalar_error_norms(paraview)" in source + assert "simulation.program_report()" in source + assert "simulation.amr.explain_regrid()" in source + assert "simulation.amr.explain_checkpoint()" in source assert "simulation.checkpoint(" in source assert "resumed.restart(" in source @@ -147,3 +155,107 @@ def test_handle_reads_are_explicit_before_symbolic_parameter_algebra(): assert "ValueExpr(core.tracer_state)" in source assert "core.case.value(core.refine_threshold)" in source assert "core.case.value(core.coarsen_threshold)" in source + + +def test_reopened_leaf_cell_error_uses_exact_characteristics_and_cell_volumes(): + module = _load_example() + points = np.asarray( + ( + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + ), + dtype=np.float64, + ) + exact = module._analytic_solution( + np.asarray((0.5,)), + np.asarray((0.5,)), + time=0.0, + ) + perturbation = 1.0e-3 + reopened = SimpleNamespace( + manifest={ + "datasets": { + "fields": { + "qualified-state": { + "name": "U", + "association": "cell", + }, + }, + }, + }, + arrays={ + "U": (exact + perturbation).reshape((1, 1)), + "Points": points, + "connectivity": np.asarray((0, 1, 2, 3), dtype=np.int64), + "offsets": np.asarray((4,), dtype=np.int64), + "pops_coverage": np.asarray((0,), dtype=np.uint8), + "vtkGhostType": np.asarray((0,), dtype=np.uint8), + "pops_cell_volume": np.asarray((1.0,), dtype=np.float64), + "TimeValue": np.asarray((0.0,), dtype=np.float64), + }, + ) + + error = module._scalar_error_norms(reopened) + + assert error.time == 0.0 + assert error.active_cells == 1 + assert np.isclose(error.l1, perturbation) + assert np.isclose(error.l2, perturbation) + assert np.isclose(error.linf, perturbation) + assert np.isclose(error.relative_l2, perturbation / exact[0]) + assert error.relative_l2 < module.RELATIVE_L2_TOLERANCE + + +def test_program_evidence_requires_every_level_and_ordered_amr_synchronization(): + module = _load_example() + synchronization = [] + for parent, child in ((0, 1), (1, 2)): + for phase in ("reflux", "average_down"): + synchronization.append({ + "parent_level": parent, + "child_level": child, + "block": 0, + "phase": phase, + "macro_step": 4, + "clock_phase": {"numerator": 1, "denominator": 1}, + }) + report = SimpleNamespace( + installed=True, + flux_ledger=[{"level": level} for level in (0, 1, 2)], + synchronization=synchronization, + ) + + evidence = module._require_multilevel_program_evidence( + report, + expected_levels=(0, 1, 2), + ) + + assert evidence.flux_ledger_levels == (0, 1, 2) + assert evidence.synchronization_relations == ((0, 1), (1, 2)) + assert evidence.synchronization_phases == ("reflux", "average_down") + + +def test_regrid_progress_requires_a_completed_topology_replacement(): + module = _load_example() + before = SimpleNamespace(macro_step=5, regrid_count=2, topology_epoch=3) + + with pytest.raises(RuntimeError, match="did not complete a dynamic regrid"): + module._require_regrid_progress( + before, + SimpleNamespace(macro_step=10, regrid_count=2, topology_epoch=3), + where="unit continuation", + ) + with pytest.raises(RuntimeError, match="did not replace the accepted topology"): + module._require_regrid_progress( + before, + SimpleNamespace(macro_step=10, regrid_count=3, topology_epoch=3), + where="unit continuation", + ) + + module._require_regrid_progress( + before, + SimpleNamespace(macro_step=10, regrid_count=3, topology_epoch=4), + where="unit continuation", + ) diff --git a/tests/python/integration/_final_field_program.py b/tests/python/integration/_final_field_program.py index 85ff832ed..5b74fb8b9 100644 --- a/tests/python/integration/_final_field_program.py +++ b/tests/python/integration/_final_field_program.py @@ -20,6 +20,7 @@ ConflictPolicy, EqualityPolicy, Hysteresis, + PatchLayout, Tag, ) from pops.domain import Rectangle @@ -33,7 +34,7 @@ GradientOutput, MeanValueGauge, ) -from pops.fields.bcs import AllPhysicalBoundaries, BoundaryCondition, Periodic +from pops.fields.bcs import AllPhysicalBoundaries, BoundaryCondition, Dirichlet, Periodic from pops.frames import Cartesian2D from pops.initial import InitialCondition from pops.math import ValueExpr @@ -189,6 +190,9 @@ def resolve_periodic_field_program( cxx: str | None = None, include: str | None = None, strict_restart: bool = False, + anchored_field: bool = False, + patch_layout: PatchLayout | None = None, + clustering: Any = None, ) -> Any: """Return the exact public resolved plan consumed by one native integration compile.""" if target not in {"system", "amr_system"}: @@ -217,11 +221,14 @@ def resolve_periodic_field_program( FieldDiscretization( method=CellCenteredSecondOrder(), boundaries=( - BoundaryCondition(AllPhysicalBoundaries(), Periodic()), + BoundaryCondition( + AllPhysicalBoundaries(), + Dirichlet(0.0) if anchored_field else Periodic(), + ), ), solver=GeometricMG() if field_solver is None else field_solver, - nullspace=ConstantNullspace(), - gauge=MeanValueGauge(0.0), + nullspace=None if anchored_field else ConstantNullspace(), + gauge=None if anchored_field else MeanValueGauge(0.0), hierarchy_policy=( CompositeHierarchySolve() if target == "amr_system" else None ), @@ -290,6 +297,8 @@ def resolve_periodic_field_program( ), transfer=transfer, execution=AMRExecution.synchronous(), + patch_layout=PatchLayout() if patch_layout is None else patch_layout, + clustering=clustering, ) native_options: dict[str, Any] = {} if cxx is not None or include is not None: diff --git a/tests/python/integration/amr/test_amr_composite_field_carrier.py b/tests/python/integration/amr/test_amr_composite_field_carrier.py index 6cf276fa9..239096a88 100644 --- a/tests/python/integration/amr/test_amr_composite_field_carrier.py +++ b/tests/python/integration/amr/test_amr_composite_field_carrier.py @@ -61,7 +61,7 @@ def _field_program(state, rate, field): return program -def _resolve(solver: GeometricMG): +def _resolve(solver: GeometricMG, *, strict_restart: bool = False): model = scalar_advection_field_model("native-composite-fac-carrier-model") x_axis, y_axis = model.frame.axes center_x, center_y = 0.35, 0.55 @@ -98,6 +98,7 @@ def gaussian_integral(center: float) -> float: amplitude=amplitude, inverse_width=inverse_width, ), + strict_restart=strict_restart, ) @@ -136,6 +137,16 @@ def _option_family(configuration, prefix: str): } +def _field_warm_starts(simulation, provider_slot: str) -> tuple[np.ndarray, ...]: + return tuple( + np.asarray( + simulation.field_potential_level_global(provider_slot, level), + dtype=np.float64, + ).copy() + for level in range(simulation.field_provider_levels(provider_slot)) + ) + + @pytest.mark.parametrize( "solver", (GeometricMG(), GeometricMG(fac=CompositeFAC())), @@ -189,20 +200,25 @@ def test_partial_fac_overrides_do_not_inherit_or_replace_geometric_mg_options() def test_fac_overrides_propagate_through_a_refined_final_root_lifecycle( - isolated_native_cache, native_cxx, kokkos_root, + isolated_native_cache, native_cxx, kokkos_root, monkeypatch, tmp_path, ) -> None: del isolated_native_cache, native_cxx, kokkos_root solver = GeometricMG( fac=CompositeFAC(**_FAC_CONFIGURED) ) - resolved = _resolve(solver) + resolved = _resolve(solver, strict_restart=True) artifact = pops.compile(resolved) simulation = pops.bind( artifact, resources={"execution_context": artifact_execution_context(artifact)}, ) - report = pops.run(simulation, t_end=2.0 * _DT, max_steps=2) + report = pops.run( + simulation, + t_end=2.0 * _DT, + max_steps=2, + output_dir=tmp_path / "run-output", + ) assert report.accepted_steps == 2 assert simulation.n_levels() == 2 @@ -221,3 +237,50 @@ def test_fac_overrides_propagate_through_a_refined_final_root_lifecycle( _assert_options( _option_family(provider["solver_configuration"], "fac."), _FAC_CONFIGURED ) + + accepted_warm_starts = _field_warm_starts(simulation, slot) + assert len(accepted_warm_starts) == 2 + checkpoint = simulation.checkpoint(tmp_path / "qualified-field-warm-start") + + restarted = pops.bind( + artifact, + resources={"execution_context": artifact_execution_context(artifact)}, + ) + (restarted_slot,) = restarted.field_provider_slots() + assert restarted_slot == slot + rollback_warm_starts = _field_warm_starts(restarted, restarted_slot) + + from pops.runtime import _amr_checkpoint_contract as checkpoint_contract + + validate_restored_contract = checkpoint_contract.validate_restored_contract + + def fail_after_exact_field_restore(native, payload): + validate_restored_contract(native, payload) + raise RuntimeError("injected post-field-restore validation failure") + + monkeypatch.setattr( + checkpoint_contract, + "validate_restored_contract", + fail_after_exact_field_restore, + ) + with pytest.raises(RuntimeError, match="post-field-restore validation failure"): + restarted.restart(checkpoint) + for actual, expected in zip( + _field_warm_starts(restarted, restarted_slot), + rollback_warm_starts, + strict=True, + ): + np.testing.assert_array_equal(actual, expected) + + monkeypatch.setattr( + checkpoint_contract, + "validate_restored_contract", + validate_restored_contract, + ) + restarted.restart(checkpoint) + for actual, expected in zip( + _field_warm_starts(restarted, restarted_slot), + accepted_warm_starts, + strict=True, + ): + np.testing.assert_array_equal(actual, expected) diff --git a/tests/python/integration/amr/test_amr_install_program.py b/tests/python/integration/amr/test_amr_install_program.py index 66d2eb37b..2f033a4a9 100644 --- a/tests/python/integration/amr/test_amr_install_program.py +++ b/tests/python/integration/amr/test_amr_install_program.py @@ -1,11 +1,15 @@ """The final resolved AMR Program emits only the authenticated AMR install entry.""" + from __future__ import annotations import pytest import pops.lib.time as libtime from pops.codegen.program_codegen import emit_cpp_program -from pops.time import FailRun +from pops.linalg import LinearProblem +from pops.numerics.terms import Flux +from pops.solvers import GMRES +from pops.time import FailRun, FixedDt, Program from tests.python.integration._final_field_program import ( compiler_model, resolve_periodic_field_program, @@ -75,3 +79,91 @@ def test_unknown_program_target_is_rejected_before_emission() -> None: target="bogus", field_plans=resolved.field_plans, ) + + +def test_field_coupled_jacvec_is_materialized_inside_every_amr_level_bundle() -> None: + """Link the public Program operation to the native L0/L1 numerical oracle. + + ``test_amr_named_field.FieldCoupledRhsJacvecMatchesCenteredDifferenceOnEveryLevel`` proves the + exact runtime algebra numerically. This source witness proves that an ordinary resolved public + Program installs that same field-qualified operation in the per-level AMR bundle rather than in + a coarse-only side route. + """ + + def factory(state, rate, field): + del rate + program = Program("amr-field-coupled-jacvec") + temporal = program.state(state) + iterate = program.value("iterate", temporal.n, at=temporal.n.point) + fields = field(iterate, name="iterate-fields").consume(action=FailRun()) + r0 = program.rhs( + name="frozen-rhs", + state=iterate, + fields=fields, + terms=(Flux(),), + ) + operator = program.matrix_free_operator( + "field-coupled-jacobian", domain="state", range_="state", ncomp=1 + ) + + def apply(builder, out, direction): + return builder.rhs_jacvec( + out, + direction, + iterate=iterate, + r0=r0, + c_dt=builder.dt, + eps=1.0e-7, + flux=True, + sources=[], + field_coupled=True, + ) + + operator = program.set_apply(operator, apply) + program.solve( + LinearProblem(operator, temporal.n, at=iterate.point, nullspace=None), + solver=GMRES(max_iter=4, restart=2, rel_tol=1.0e-8), + name="correction", + ).consume(action=FailRun()) + program.commit( + temporal.next, + program.value( + "next", + temporal.n + program.dt * r0, + at=temporal.next.point, + ), + ) + program.step_strategy(FixedDt(0.01)) + return program + + model = scalar_advection_field_model("amr-field-coupled-jacvec-model") + resolved = resolve_periodic_field_program( + model, + factory, + name="amr-field-coupled-jacvec", + block_name="plasma", + target="amr_system", + n=16, + ) + source = emit_cpp_program( + resolved.time, + compiler_model(model), + target="amr_system", + field_plans=resolved.field_plans, + ) + + materialization = source.split("auto _make_level_program", 1)[1] + factory_source, refresh_source = materialization.split("auto _refresh_level_programs", 1) + assert "ctx.evaluate_with_field_state_at(" in factory_source + level_iteration = refresh_source.index("ctx.for_each_program_resource_level([&](int) {") + bundle_insert = refresh_source.index( + "_level_programs->emplace_back(_make_level_program());" + ) + assert level_iteration < bundle_insert + assert "ctx.set_level(" not in refresh_source + assert "ctx.solve_default_field_on_coarse_level(" not in materialization + assert ( + "ctx.solve_fields_from_state_at(" + not in materialization.split("ctx.evaluate_with_field_state_at(", 1)[1].split("});", 1)[0] + ) + assert materialization.count("ctx.evaluate_with_field_state_at(") == 1 diff --git a/tests/python/integration/amr/test_amr_program_parity.py b/tests/python/integration/amr/test_amr_program_parity.py index 32ea45297..e29475eec 100644 --- a/tests/python/integration/amr/test_amr_program_parity.py +++ b/tests/python/integration/amr/test_amr_program_parity.py @@ -33,6 +33,7 @@ acceptance preflights those native requirements once, then every compile/install/run leg is mandatory. Pytest + ``__main__`` guard (CI runs ``python3 ``). """ + import sys from fractions import Fraction @@ -99,8 +100,7 @@ def _nonlinear_model(name="adc508_nonlinear_model"): from pops.math import ddt, div, laplacian, unknown from pops.physics import Model - frame = Rectangle( - "%s-domain" % name, lower=(0.0, 0.0), upper=(1.0, 1.0)).frame(Cartesian2D()) + frame = Rectangle("%s-domain" % name, lower=(0.0, 0.0), upper=(1.0, 1.0)).frame(Cartesian2D()) x_axis, y_axis = frame.axes model = Model(name, frame=frame) state = model.state("U", components=("rho",)) @@ -132,8 +132,9 @@ def _ssprk2_program( refresh_final_field=False, ): """The canonical SSPRK2 (Heun) Program on one block 'plasma' -- the SAME scheme the native explicit - AMR advance uses. solve_fields(); R=rhs(U); U1=U+dt R; solve_fields(U1); R1=rhs(U1); - U <<= 0.5 U + 0.5 (U1 + dt R1).""" + AMR advance uses. solve_field(U); R=rhs(U); U1=U+dt R; solve_field(U1); R1=rhs(U1); + U <<= 0.5 U + 0.5 (U1 + dt R1). Each solve is provider/level/stage-qualified.""" + def factory(state, rate, fields): program = libtime.SSPRK2( state, @@ -147,9 +148,7 @@ def factory(state, rate, fields): # consumes the committed candidate before the commit is published. No private runtime # solve seam is needed after the step. (committed_state,) = tuple(program.commits().values()) - fields(committed_state, name="final_committed_fields").consume( - action=FailRun() - ) + fields(committed_state, name="final_committed_fields").consume(action=FailRun()) return program return resolve_periodic_field_program( @@ -166,7 +165,8 @@ def _midpoint_program(model, name="adc508_midpoint", *, target="amr_system"): """A CUSTOM 2-stage scheme (midpoint RK2): U1 = U + 0.5 dt R(U); U <<= U + dt R(U1). A DIFFERENT combine through the same seam -- proves the Program text drives the integrator.""" midpoint = RungeKuttaTableau( - A=[[], [Fraction(1, 2)]], b=[0, 1], c=[0, Fraction(1, 2)], name="midpoint") + A=[[], [Fraction(1, 2)]], b=[0, 1], c=[0, Fraction(1, 2)], name="midpoint" + ) return resolve_periodic_field_program( model, lambda state, rate, fields: libtime.RungeKutta( @@ -246,9 +246,12 @@ def _system_run(plan, model, u0, nsteps=NSTEPS, dt=DT): return None, "compile (System): %s" % str(exc)[:140] for field, field_plan in plan.field_plans.items(): sim._install_field_plan(field, field_plan) - sim.add_equation("plasma", block_cm, - spatial=engine.Spatial(limiter=FirstOrder(), flux=Rusanov()), - time=engine.Explicit(method="ssprk2")) + sim.add_equation( + "plasma", + block_cm, + spatial=engine.Spatial(limiter=FirstOrder(), flux=Rusanov()), + time=engine.Explicit(method="ssprk2"), + ) sim.set_density("plasma", u0) sim.install_program(compiled.so_path) for _ in range(nsteps): @@ -286,9 +289,12 @@ def _amr_run(plan, model, u0, nsteps=NSTEPS, dt=DT): # install the compiled time Program on the hierarchy. for field, field_plan in plan.field_plans.items(): amr._install_field_plan(field, field_plan) - amr.add_equation("plasma", block_cm, - spatial=engine.Spatial(limiter=FirstOrder(), flux=Rusanov()), - time=engine.Explicit(method="ssprk2")) + amr.add_equation( + "plasma", + block_cm, + spatial=engine.Spatial(limiter=FirstOrder(), flux=Rusanov()), + time=engine.Explicit(method="ssprk2"), + ) amr.set_density("plasma", u0) amr.install_program(compiled.so_path) except RuntimeError as exc: @@ -297,8 +303,7 @@ def _amr_run(plan, model, u0, nsteps=NSTEPS, dt=DT): amr.step(dt) (provider_slot,) = tuple(amr.field_provider_slots()) coarse_potential = np.array(amr.field_potential_global(provider_slot)).reshape(N, N) - return (np.array(amr.density("plasma")), coarse_potential, - float(amr.mass("plasma"))), None + return (np.array(amr.density("plasma")), coarse_potential, float(amr.mass("plasma"))), None def test_single_level_bit_identical_parity(): @@ -314,7 +319,8 @@ def test_single_level_bit_identical_parity(): u0 = _init_density() sys_out, sys_err = _system_run( - _ssprk2_program(model, target="system", refresh_final_field=True), model, u0) + _ssprk2_program(model, target="system", refresh_final_field=True), model, u0 + ) assert sys_out is not None, sys_err amr_model = _euler_model("adc508_parity_ssprk2") amr_out, amr_err = _amr_run( @@ -333,15 +339,21 @@ def test_single_level_bit_identical_parity(): sys_rho = sys_state[0] # density = component 0 drho = float(np.abs(sys_rho - amr_rho).max()) - chk(np.array_equal(sys_rho, amr_rho), - "the evolved coarse density is BIT-IDENTICAL System vs AMR (max|diff| = %.3e)" % drho) + chk( + np.array_equal(sys_rho, amr_rho), + "the evolved coarse density is BIT-IDENTICAL System vs AMR (max|diff| = %.3e)" % drho, + ) + # The two independent iterative solves have different warm-start histories. Validate the same # discrete periodic equation independently instead of comparing their non-unique iterates. def relative_poisson_residual(phi, rho): h = 1.0 / N laplacian = ( - np.roll(phi, -1, axis=0) + np.roll(phi, 1, axis=0) - + np.roll(phi, -1, axis=1) + np.roll(phi, 1, axis=1) - 4.0 * phi + np.roll(phi, -1, axis=0) + + np.roll(phi, 1, axis=0) + + np.roll(phi, -1, axis=1) + + np.roll(phi, 1, axis=1) + - 4.0 * phi ) / (h * h) source = rho - 1.0 residual = -laplacian - source @@ -349,11 +361,16 @@ def relative_poisson_residual(phi, rho): sys_residual = relative_poisson_residual(sys_phi, sys_rho) amr_residual = relative_poisson_residual(amr_phi, amr_rho) - chk(sys_residual < 1e-7 and amr_residual < 1e-7, + chk( + sys_residual < 1e-7 and amr_residual < 1e-7, "both potentials satisfy the same discrete Poisson equation independently " - "(System %.3e, AMR %.3e)" % (sys_residual, amr_residual)) - chk(np.all(np.isfinite(amr_rho)) and float(amr_rho.min()) > 0.0, - "the AMR Program kept a finite, strictly-positive density (min = %.4f)" % float(amr_rho.min())) + "(System %.3e, AMR %.3e)" % (sys_residual, amr_residual), + ) + chk( + np.all(np.isfinite(amr_rho)) and float(amr_rho.min()) > 0.0, + "the AMR Program kept a finite, strictly-positive density (min = %.4f)" + % float(amr_rho.min()), + ) def test_custom_two_stage_runs_and_differs(): @@ -361,49 +378,57 @@ def test_custom_two_stage_runs_and_differs(): and DIFFERS from the SSPRK2 Program -- the Program text drives the integrator, not a hard-coded scheme. Also bit-identical vs the same midpoint Program on System (the duck-typing holds for a second, different combine).""" - print("== custom 2-stage (midpoint RK2) Program on AMR: runs, conserves, differs from SSPRK2 ==") + print( + "== custom 2-stage (midpoint RK2) Program on AMR: runs, conserves, differs from SSPRK2 ==" + ) model = _nonlinear_model("adc508_parity_mid") u0 = _init_density() m0 = float(u0.mean()) # mean density == coarse mass / area (L=1) - mid_amr, err = _amr_run( - _midpoint_program(model, target="amr_system"), model, u0) + mid_amr, err = _amr_run(_midpoint_program(model, target="amr_system"), model, u0) assert mid_amr is not None, err mid_rho, mid_phi, mid_mass = mid_amr # SSPRK2 on the SAME AMR for the differ-check (same model name -> same .so cache key per Program). ss_model = _nonlinear_model("adc508_parity_mid") - ss_amr, err2 = _amr_run( - _ssprk2_program(ss_model, target="amr_system"), ss_model, u0) + ss_amr, err2 = _amr_run(_ssprk2_program(ss_model, target="amr_system"), ss_model, u0) assert ss_amr is not None, err2 ss_rho = ss_amr[0] chk(np.all(np.isfinite(mid_rho)), "the midpoint Program produced a finite state") # Mass conservation (periodic, no flux through the boundary): coarse mass == initial to round-off. - chk(abs(mid_mass - m0) < 1e-9, - "the midpoint Program conserves the coarse mass (|m - m0| = %.2e)" % abs(mid_mass - m0)) + chk( + abs(mid_mass - m0) < 1e-9, + "the midpoint Program conserves the coarse mass (|m - m0| = %.2e)" % abs(mid_mass - m0), + ) # A DIFFERENT scheme must give a DIFFERENT trajectory (proves the Program drives the integrator). diff = float(np.abs(mid_rho - ss_rho).max()) - chk(diff > 1e-12, - "the midpoint scheme DIFFERS from SSPRK2 through the SAME seam (max|diff| = %.3e)" % diff) + chk( + diff > 1e-12, + "the midpoint scheme DIFFERS from SSPRK2 through the SAME seam (max|diff| = %.3e)" % diff, + ) # Bit-identical vs the same midpoint Program on System (the duck-typing holds for a 2nd combine). sys_model = _nonlinear_model("adc508_parity_mid") - sys_out, sys_err = _system_run( - _midpoint_program(sys_model, target="system"), sys_model, u0) + sys_out, sys_err = _system_run(_midpoint_program(sys_model, target="system"), sys_model, u0) assert sys_out is not None, sys_err sys_rho = sys_out[0][0] - chk(np.array_equal(sys_rho, mid_rho), + chk( + np.array_equal(sys_rho, mid_rho), "the midpoint Program is bit-identical System vs AMR (max|diff| = %.3e)" - % float(np.abs(sys_rho - mid_rho).max())) + % float(np.abs(sys_rho - mid_rho).max()), + ) ss_sys_model = _nonlinear_model("adc508_parity_mid") ss_sys_out, ss_sys_err = _system_run( - _ssprk2_program(ss_sys_model, target="system"), ss_sys_model, u0) + _ssprk2_program(ss_sys_model, target="system"), ss_sys_model, u0 + ) assert ss_sys_out is not None, ss_sys_err ss_sys_rho = ss_sys_out[0][0] - chk(np.array_equal(ss_sys_rho, ss_rho), + chk( + np.array_equal(ss_sys_rho, ss_rho), "the SSPRK2 Program is bit-identical System vs AMR (max|diff| = %.3e)" - % float(np.abs(ss_sys_rho - ss_rho).max())) + % float(np.abs(ss_sys_rho - ss_rho).max()), + ) def _amr_run_cfl(plan, model, u0, nsteps=NSTEPS, cfl=0.4): @@ -426,9 +451,12 @@ def _amr_run_cfl(plan, model, u0, nsteps=NSTEPS, cfl=0.4): try: for field, field_plan in plan.field_plans.items(): amr._install_field_plan(field, field_plan) - amr.add_equation("plasma", block_cm, - spatial=engine.Spatial(limiter=FirstOrder(), flux=Rusanov()), - time=engine.Explicit(method="ssprk2")) + amr.add_equation( + "plasma", + block_cm, + spatial=engine.Spatial(limiter=FirstOrder(), flux=Rusanov()), + time=engine.Explicit(method="ssprk2"), + ) amr.set_density("plasma", u0) amr.install_program(compiled.so_path) last_dt = 0.0 @@ -448,24 +476,29 @@ def test_step_cfl_routes_through_installed_program(): a hidden runtime scheme. A custom midpoint Program is compared with the explicit SSPRK2 Program on a nonlinear Burgers flux, so a measurable difference proves the installed Program drove the step. Host/CPU-runnable; self-skips without a compiler / Kokkos.""" - print("== step_cfl routes through the installed AMR Program (fix 1: no silent native bypass) ==") + print( + "== step_cfl routes through the installed AMR Program (fix 1: no silent native bypass) ==" + ) model = _nonlinear_model("adc508_stepcfl") u0 = _init_density() prog_out, err = _amr_run_cfl( - _midpoint_program( - model, "adc508_stepcfl_midpoint", target="amr_system"), + _midpoint_program(model, "adc508_stepcfl_midpoint", target="amr_system"), model, u0, ) assert prog_out is not None, err prog_rho, prog_hash, prog_dt = prog_out chk(prog_hash != "", "step_cfl on an installed-Program AMR system records the program hash") - chk(np.isfinite(prog_dt) and prog_dt > 0.0, - "step_cfl returned a finite, positive CFL dt (%.3e)" % prog_dt) - chk(np.all(np.isfinite(prog_rho)) and float(prog_rho.min()) > 0.0, + chk( + np.isfinite(prog_dt) and prog_dt > 0.0, + "step_cfl returned a finite, positive CFL dt (%.3e)" % prog_dt, + ) + chk( + np.all(np.isfinite(prog_rho)) and float(prog_rho.min()) > 0.0, "the Program-driven step_cfl kept a finite, strictly-positive density (min = %.4f)" - % float(prog_rho.min())) + % float(prog_rho.min()), + ) ss_model = _nonlinear_model("adc508_stepcfl") ss_out, ss_err = _amr_run_cfl( @@ -483,17 +516,19 @@ def test_step_cfl_routes_through_installed_program(): chk(np.isfinite(ss_dt) and ss_dt > 0.0, "SSPRK2 Program returned a finite positive CFL dt") # The evolved densities must differ: the two installed Program bodies own distinct tableaux. diff = float(np.abs(prog_rho - ss_rho).max()) - chk(diff > 1e-14, - "midpoint and SSPRK2 Program-driven step_cfl densities differ (max|diff| = %.3e)" % diff) + chk( + diff > 1e-14, + "midpoint and SSPRK2 Program-driven step_cfl densities differ (max|diff| = %.3e)" % diff, + ) def _run_all(): - fns = [v for k, v in sorted(globals().items()) - if k.startswith("test_") and callable(v)] + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] for fn in fns: fn() - print("\n%s test_amr_program_parity (%d check failures)" - % ("FAIL" if _fails else "PASS", _fails)) + print( + "\n%s test_amr_program_parity (%d check failures)" % ("FAIL" if _fails else "PASS", _fails) + ) return _fails diff --git a/tests/python/integration/amr/test_amr_regrid_on_restart.py b/tests/python/integration/amr/test_amr_regrid_on_restart.py index 88069a0ee..ddd976b99 100644 --- a/tests/python/integration/amr/test_amr_regrid_on_restart.py +++ b/tests/python/integration/amr/test_amr_regrid_on_restart.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json from pathlib import Path import numpy as np @@ -243,7 +244,7 @@ def read_size(): cursor += 8 return value - assert encoded[:8] == b"POPSAST4" + assert encoded[:8] == b"POPSAST5" cursor = 8 level_count = read_size() cursor += level_count * 40 @@ -253,6 +254,13 @@ def read_size(): name_size = read_size() cursor += name_size + 8 assert cursor <= len(encoded), "accepted-state logical clocks are truncated" + cursor += 8 # CellTemporalPartitionKind + provider_size = read_size() + cursor += provider_size + cursor += 3 * 8 # topology epoch, synchronization tick, tick denominator + cell_count = read_size() + cursor += cell_count * 32 # level, cell id, rung, accepted tick (four i64 words) + assert cursor <= len(encoded), "accepted-state temporal partition is truncated" tagging_size = read_size() assert cursor + tagging_size <= len(encoded), "accepted-state tagging image is truncated" return encoded[cursor : cursor + tagging_size] @@ -277,6 +285,73 @@ def _runtime_tagging_hysteresis(runtime): return _accepted_tagging_hysteresis(runtime._executor._s.program_accepted_state()) +def test_authenticated_amr_contract_refusal_rolls_back_native_restart_transaction( + native_cxx, + kokkos_root, + tmp_path, +): + """A real post-apply checkpoint-provider refusal restores the previous accepted image.""" + del kokkos_root + artifact = pops.compile(_resolved(native_cxx)) + source = _bind(artifact) + report = pops.run( + source, + t_end=NSTEPS * DT, + max_steps=NSTEPS, + console=False, + ) + assert report.accepted_steps == NSTEPS + checkpoint = Path(source.checkpoint(tmp_path / "provider-contract-source")) + + # Preserve a fully valid, content-addressed checkpoint envelope while making only its dynamic + # accepted-ledger claim inconsistent with the opaque Program image. Static preflight therefore + # succeeds; the real AMR provider can refuse only after applying the checkpoint inside its + # native restart transaction. + from pops.runtime._checkpoint_manifest import ( + IDENTITY_KEY, + MANIFEST_KEY, + seal_checkpoint_payload, + ) + + with np.load(checkpoint, allow_pickle=False) as stored: + payload = { + name: np.asarray(stored[name]).copy() + for name in stored.files + if name not in {MANIFEST_KEY, IDENTITY_KEY} + } + contract = json.loads(str(payload["amr_accepted_contract"])) + contract["ledger"]["accepted_entries"] = int( + contract["ledger"]["accepted_entries"] + ) + 1 + payload["amr_accepted_contract"] = np.asarray( + json.dumps(contract, sort_keys=True, separators=(",", ":"), allow_nan=False) + ) + seal_checkpoint_payload(source, payload, runtime_kind="amr") + refused_checkpoint = tmp_path / "provider-contract-refusal.npz" + with refused_checkpoint.open("wb") as stream: + np.savez_compressed(stream, **payload) + + restarted = _bind(artifact) + rollback_image = _accepted_image(restarted) + with pytest.raises( + ValueError, + match="restored AMR accepted-state image differs from its authenticated contract", + ): + restarted.restart(refused_checkpoint) + + _assert_same_accepted_image(restarted, rollback_image) + assert restarted._executor.last_restart_regrid_receipt() is None + assert "_checkpoint_restart_python_snapshot" not in restarted._executor.__dict__ + + # The same real provider remains usable after compensation: retrying the unmodified checkpoint + # succeeds and publishes one transformed-hierarchy restart receipt. + restart_identity = restarted.restart(checkpoint) + receipt = restarted._executor.last_restart_regrid_receipt() + assert restart_identity == restarted.last_restart_identity + assert receipt["changed"] is True + assert receipt["before"]["topology_identity"] != receipt["after"]["topology_identity"] + + def test_regrid_on_restart_changes_real_boxes_and_rolls_back_post_regrid_fault( native_cxx, isolated_native_cache, diff --git a/tests/python/integration/amr/test_amr_runtime_inspect.py b/tests/python/integration/amr/test_amr_runtime_inspect.py index 63ee107e5..aa15cedd4 100644 --- a/tests/python/integration/amr/test_amr_runtime_inspect.py +++ b/tests/python/integration/amr/test_amr_runtime_inspect.py @@ -247,7 +247,11 @@ def test_explain_checkpoint_supports_dynamic_regrid(): assert any("selective history replay remains same-rank" in n for n in rep.notes) assert any("explicit weaker continuation" in n for n in rep.notes) assert any("unchanged MPI cardinality" in n for n in rep.notes) - assert any("serial and exact-MPI-world rematerializable" in n for n in rep.notes) + assert any( + "depth-preserving shared-interface flux groups" in n for n in rep.notes + ) + assert any("exact-MPI-world rematerializable" in n for n in rep.notes) + assert any("active-depth changes" in n for n in rep.notes) assert any("cold-restart collective" in n for n in rep.notes) @@ -290,16 +294,11 @@ def test_inspect_before_build_reports_unbuilt_patches_honestly(): assert report.regrid.frozen is True -def test_inspect_explicitly_refuses_field_coupled_rhs_jacvec_above_level_zero(): +def test_inspect_no_longer_lists_the_served_fine_level_field_jacvec_as_a_limitation(): report = AmrSystem(n=16, L=1.0, periodicity=(True, True)).amr.inspect() rows = [row for row in report.limitations if row["feature"] == "amr:field_coupled_rhs_jacvec"] - assert len(rows) == 1 - row = rows[0] - assert row["status"] == "unavailable" - assert "level > 0" in row["limitation"] - assert "AMR level > 0" in row["error_message"] - assert row["available_route"] == "field_coupled rhs_jacvec on AMR level 0" + assert rows == [] # --- compiled static delegation ------------------------------------------------ diff --git a/tests/python/integration/amr/test_amr_weno5_hllc_roe.py b/tests/python/integration/amr/test_amr_weno5_hllc_roe.py index 58476f0b3..d4cc7b78c 100644 --- a/tests/python/integration/amr/test_amr_weno5_hllc_roe.py +++ b/tests/python/integration/amr/test_amr_weno5_hllc_roe.py @@ -3,7 +3,7 @@ DIVERGENCE CORRIGEE (audit GENERICITY_2026-06 §8 "registry des tags") : les branches hllc et roe du dispatch AMR (detail::dispatch_amr_block, amr_dsl_block.hpp) n'avaient PAS de -cas 'weno5' (seulement none/minmod/vanleer) alors que System::make_block (block_builder.hpp) le route. +cas 'weno5' (seulement les routes de halo <= 2) alors que System::make_block (block_builder.hpp) le route. Resultat : un utilisateur AmrSystem demandant un schema compressible weno5+hllc (ou weno5+roe) recevait "limiter inconnu 'weno5'" la ou le MEME modele buildait sous System. Les deux branches AMR portent desormais le cas weno5 (build_amr_block supporte deja Weno5, cable sur diff --git a/tests/python/integration/bindings/test_m1_scalar_advection_pipeline.py b/tests/python/integration/bindings/test_m1_scalar_advection_pipeline.py index f63bbc925..700e988e3 100644 --- a/tests/python/integration/bindings/test_m1_scalar_advection_pipeline.py +++ b/tests/python/integration/bindings/test_m1_scalar_advection_pipeline.py @@ -106,6 +106,23 @@ def test_scalar_advection_final_example_runs_outputs_and_bit_identical_restart(t assert evidence.accepted.macro_step > 0 assert evidence.restored.macro_step == evidence.accepted.macro_step assert evidence.continuous.macro_step == evidence.restarted.macro_step + assert evidence.accepted.regrid_count > 0 + assert evidence.accepted.topology_epoch > 0 + assert evidence.restored.regrid_count == evidence.accepted.regrid_count + assert evidence.restored.topology_epoch == evidence.accepted.topology_epoch + assert evidence.restarted.regrid_count > evidence.restored.regrid_count + assert evidence.restarted.topology_epoch > evidence.restored.topology_epoch + assert evidence.error_norms.active_cells > 0 + assert evidence.error_norms.relative_l2 <= example.RELATIVE_L2_TOLERANCE + expected_levels = tuple(range(len(evidence.continuous.states))) + assert evidence.program_evidence.flux_ledger_levels == expected_levels + assert evidence.program_evidence.synchronization_relations == tuple( + (level, level + 1) for level in expected_levels[:-1] + ) + assert evidence.program_evidence.synchronization_phases == ( + "reflux", + "average_down", + ) assert preset.macro_step == evidence.continuous.macro_step assert preset.program_hash == evidence.continuous.program_hash from pops.output import read_paraview diff --git a/tests/python/integration/bindings/test_name_binding_runtime.py b/tests/python/integration/bindings/test_name_binding_runtime.py index 2e2b4e365..4052c9681 100644 --- a/tests/python/integration/bindings/test_name_binding_runtime.py +++ b/tests/python/integration/bindings/test_name_binding_runtime.py @@ -59,9 +59,7 @@ def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") a = 0.7 - u = m.primitive("u", a + 0.0 * rho) - v = m.primitive("v", a + 0.0 * rho) - m.primitive_vars(rho=rho, u=u, v=v) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[a * rho], y=[a * rho]) m.eigenvalues(x=[a + 0.0 * rho], y=[a + 0.0 * rho]) diff --git a/tests/python/integration/io/m4_native_reopen_proof.py b/tests/python/integration/io/m4_native_reopen_proof.py new file mode 100644 index 000000000..7f8b0173e --- /dev/null +++ b/tests/python/integration/io/m4_native_reopen_proof.py @@ -0,0 +1,155 @@ +"""Mandatory format-native reopen proofs executed only by the explicit M4 gate.""" + +from __future__ import annotations + +import json + +import numpy as np +import pytest + +from pops.identity import make_identity +from pops.model import Handle, OwnerKind, OwnerPath +from pops.output import ( + ArrayPiece, + FieldKey, + FieldPayload, + HDF5Writer, + LevelGeometry, + NPZWriter, + OutputClock, + OutputProvenance, + OutputRequest, + OutputSnapshot, + ParaViewWriter, + ParallelMode, + read_hdf5, +) + + +def _identity(domain: str, name: str): + return make_identity(domain, {"name": name}) + + +def _snapshot_and_request(): + layout = _identity("layout-plan", "m4-native-reopen") + component = _identity("component-manifest", "m4-native-reopen") + owner = OwnerPath.case("m4-native-reopen").child(OwnerKind.BLOCK, "fluid") + state = Handle("rho", kind="state", owner=owner) + key = FieldKey(state, component, layout, 0, "accepted") + values = np.asarray([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) + geometry = LevelGeometry( + layout, + "uniform", + 0, + (0.0, 0.0), + (0.5, 0.5), + (2, 2), + ((0, 0, 2, 2),), + np.zeros((2, 2), dtype=np.bool_), + np.full((2, 2), 0.25, dtype=np.float64), + ) + field = FieldPayload( + key, + "cell", + "kg.m-3", + (), + (2, 2), + (ArrayPiece((0, 0), (2, 2), values, 0, 0, False),), + ) + snapshot = OutputSnapshot( + OutputClock.at("macro", 0.25, 4, stage="accepted"), + OutputProvenance( + _identity("resolved-plan", "m4-native-reopen"), + _identity("bind", "m4-native-reopen"), + _identity("run", "m4-native-reopen"), + "accepted-step-transaction", + ), + (geometry,), + (field,), + {"case": "m4-native-reopen"}, + ) + request = OutputRequest("rho-output", (key,), ParallelMode.SERIAL) + return snapshot, request, values + + +def _publish(writer, target): + snapshot, request, expected = _snapshot_and_request() + session = writer.prepare_session(snapshot, request, target) + session.stage() + receipt = session.publish() + session.finalize() + assert receipt.path == target + return receipt.path, request, expected + + +def _field_dataset(manifest: dict, request: OutputRequest) -> str: + key = request.selection[0].identity.token + dataset = manifest["datasets"]["fields"][key] + if isinstance(dataset, str): + return dataset + pieces = dataset["pieces"] + assert len(pieces) == 1 + return pieces[0]["name"] + + +def test_npz_reopens_with_numpy_without_a_pops_reader(tmp_path): + path, request, expected = _publish(NPZWriter(), tmp_path / "native.npz") + + with np.load(path, allow_pickle=False) as archive: + manifest = json.loads(str(archive["pops_output_manifest"])) + dataset = _field_dataset(manifest, request) + np.testing.assert_array_equal(archive[dataset], expected) + assert set(archive.files) == set(manifest["arrays"]) | { + "pops_output_manifest" + } + assert manifest["snapshot"]["clock"]["time"] == float.hex(0.25) + + +def test_hdf5_reopens_with_h5py_without_a_pops_reader(tmp_path): + import h5py + + path, request, expected = _publish(HDF5Writer(), tmp_path / "native.h5") + + with h5py.File(path, "r") as output: + manifest = json.loads(str(output.attrs["pops_output_manifest"])) + dataset = _field_dataset(manifest, request) + np.testing.assert_array_equal(output[dataset][...], expected) + assert set(output.attrs) == {"pops_output_manifest"} + assert manifest["snapshot"]["clock"]["time"] == float.hex(0.25) + + +def test_hdf5_authenticated_reader_rejects_native_dataset_tampering(tmp_path): + import h5py + + path, request, _expected = _publish(HDF5Writer(), tmp_path / "tampered.h5") + with h5py.File(path, "r+") as output: + manifest = json.loads(str(output.attrs["pops_output_manifest"])) + dataset = _field_dataset(manifest, request) + output[dataset][0, 0] = np.float64(99.0) + + with pytest.raises(ValueError, match="parallel piece failed verification"): + read_hdf5(path) + + +def test_paraview_reopens_with_vtk_without_a_pops_reader(tmp_path): + from vtkmodules.vtkIOXML import vtkXMLUnstructuredGridReader + + path, _request, expected = _publish( + ParaViewWriter(collection=False), tmp_path / "native.vtu" + ) + + reader = vtkXMLUnstructuredGridReader() + reader.SetFileName(str(path)) + reader.Update() + grid = reader.GetOutput() + assert grid.GetNumberOfCells() == 4 + assert grid.GetNumberOfPoints() == 9 + rho = grid.GetCellData().GetArray("rho") + assert rho is not None + assert [rho.GetTuple1(index) for index in range(4)] == expected.ravel().tolist() + assert grid.GetCellData().GetArray("field_0000") is None + assert [ + grid.GetCellData().GetArray("pops_level").GetTuple1(index) + for index in range(4) + ] == [0.0, 0.0, 0.0, 0.0] + assert grid.GetFieldData().GetArray("TimeValue").GetTuple1(0) == 0.25 diff --git a/tests/python/integration/io/test_amr_history_checkpoint.py b/tests/python/integration/io/test_amr_history_checkpoint.py index e85ef4339..7b6d0e69b 100644 --- a/tests/python/integration/io/test_amr_history_checkpoint.py +++ b/tests/python/integration/io/test_amr_history_checkpoint.py @@ -126,7 +126,13 @@ def _ab2_program(model, name="adc631_ckpt_ab2"): return P -def _state3_program(model, name="adc631_ckpt_state3", *, step_strategy=None): +def _state3_program( + model, + name="adc631_ckpt_state3", + *, + step_strategy=None, + balance_replay_proof=False, +): """A 3-slot STATE ring (max lag 2, Interval(2) -> stores slots {0,2}, replays slot 1). The commit is the strictly affine recurrence U^{n+1} = U^n + dt*_C*U^n -- it depends only on U^n, @@ -143,11 +149,59 @@ def _state3_program(model, name="adc631_ckpt_state3", *, step_strategy=None): # Strictly affine growth (reads U.n only), + a zero-weight prev(2) read that declares the 3-slot # ring without breaking the single-step reconstructability of the replay. nxt = P.value("Un", U.n + P.dt * _C * U.n + 0.0 * U.prev(2), at=U.next.point) + balance_due_contract = None + if balance_replay_proof: + from pops.diagnostics import BalanceLedger + from pops.identity import make_identity + from pops.output._balance_due_contract import ( + BalanceDueConsumer, + BalanceDueContract, + BalanceDueRoute, + ) + + total = P.sum(U.n) + ledger = BalanceLedger("amr-selective-replay") + P.record_balance( + ledger, + storage_change=total, + outward_boundary_flux=0.0 * total, + sources=0.0 * total, + reflux=0.0 * total, + projection=0.0 * total, + ) + route = ledger.route_identity(U.block) + balance_due_contract = BalanceDueContract( + make_identity("consumer-graph", {"test": "amr-selective-replay"}), + ( + BalanceDueRoute( + route, + ( + BalanceDueConsumer( + make_identity( + "consumer-manifest", + {"test": "amr-selective-replay"}, + ), + pops.time.every(2, clock=P.clock), + ), + ), + ), + ), + ) P.commit(U.next, nxt) P.step_strategy(pops.time.FixedDt(DT) if step_strategy is None else step_strategy) + if balance_due_contract is not None: + return P, balance_due_contract return P +def _state3_balance_program(model): + return _state3_program( + model, + name="adc686_ckpt_state3_balance", + balance_replay_proof=True, + ) + + def _state5_program(model, name="adc631_ckpt_state5"): """A 5-slot strictly affine ring with two independently replayed Interval(2) gaps.""" P = pops.Program(name) @@ -221,8 +275,17 @@ def _build(program_factory, regrid_every=2, program_cadence=None): "test_amr_history_checkpoint requires install_program/history_names bindings" ) model = _passive_source_model("%s_model" % program_factory.__name__.lstrip("_")) - program = program_factory(model) - compiled = compile_problem(model=model, time=program, target="amr_system") + authored = program_factory(model) + if isinstance(authored, tuple): + program, balance_due_contract = authored + else: + program, balance_due_contract = authored, None + compiled = compile_problem( + model=model, + time=program, + target="amr_system", + balance_due_contract=balance_due_contract, + ) block_cm = compile_block_model(model, target="amr_system") amr.add_equation( "blk", @@ -377,6 +440,35 @@ def test_state3_interval_replay_bit_identical(): ) +def test_state3_selective_replay_compiles_balance_off(): + print("== (2b) selective replay re-steps a Balance Program outside a public-step window ==") + out, err = _run_case( + _state3_balance_program, + nsteps=6, + half=3, + label="state3-balance", + regrid_every=0, + ) + assert out is not None, err + ref, got, cont_rings, rest_rings, stored_info, report = out + chk( + bool(stored_info) + and all( + requested == stored and len(stored) < depth and mode == "policy" and fp == [] + for depth, requested, stored, mode, fp in stored_info.values() + ), + "the Balance Program retains selective storage and therefore exercises replay", + ) + chk( + report is not None and any(h["recomputed_slots"] >= 1 for h in report.histories), + "restart re-executed the compiled Balance Program for an omitted slot", + ) + chk( + _rings_equal(cont_rings, rest_rings) and np.array_equal(ref, got), + "Balance is compiled off only during replay; restart and continuation remain bit-identical", + ) + + def test_state3_replay_window_straddling_regrid_bit_identical(): print("== (3) ckpt at m=6 straddles regrid step 4 -> explicit dense safety storage ==") out, err = _run_case(_state3_program, nsteps=10, half=6, label="straddle", regrid_every=4) @@ -522,6 +614,7 @@ def accepted_levels(system): def main(): test_ab2_dense_checkpoint_bit_identical() test_state3_interval_replay_bit_identical() + test_state3_selective_replay_compiles_balance_off() test_state3_replay_window_straddling_regrid_bit_identical() test_state5_multiple_anchor_gaps_replay_by_index_bit_identical() test_amr_variable_dt_stride_checkpoint_closes_like_continuous_run() diff --git a/tests/python/integration/io/test_hdf5_parallel.py b/tests/python/integration/io/test_hdf5_parallel.py index a5b7c1858..6643a7367 100644 --- a/tests/python/integration/io/test_hdf5_parallel.py +++ b/tests/python/integration/io/test_hdf5_parallel.py @@ -119,15 +119,15 @@ def snapshot(pieces): return snapshot((local_piece,)), snapshot(serial_pieces), key, global_values -def _parallel_hdf5_world(test_name: str): +def _parallel_hdf5_lane(test_name: str): try: import h5py # noqa: F401 -- serial native reopen verification except ImportError: _missing_mpi_requirement("collective HDF5 requires h5py") if getattr(_pops, "__has_parallel_hdf5__", False) is not True: _missing_mpi_requirement("collective HDF5 requires the compiled C++ parallel-HDF5 route") - communicator = _pops.mpi_world() - if world_size(communicator) == 1 and os.environ.get(_MPI_CHILD) != "1": + world = _pops.mpi_world() + if world_size(world) == 1 and os.environ.get(_MPI_CHILD) != "1": mpiexec = shutil.which("mpiexec") or shutil.which("mpirun") if mpiexec is None: _missing_mpi_requirement( @@ -154,12 +154,24 @@ def _parallel_hdf5_world(test_name: str): ) assert result.returncode == 0, result.stdout + result.stderr return None - assert world_size(communicator) >= 2, "MPI child did not start with two ranks" - return communicator + assert world_size(world) >= 2, "MPI child did not start with two ranks" + return world.duplicate_observer_lane("pytest-hdf5-" + test_name) -def test_collective_hdf5_roundtrip_matches_serial(tmp_path): - communicator = _parallel_hdf5_world(test_collective_hdf5_roundtrip_matches_serial.__name__) +@pytest.fixture +def parallel_hdf5_lane(request): + lane = _parallel_hdf5_lane(request.node.name) + if lane is None: + yield None + return + try: + yield lane + finally: + lane.close_collectively() + + +def test_collective_hdf5_roundtrip_matches_serial(tmp_path, parallel_hdf5_lane): + communicator = parallel_hdf5_lane if communicator is None: return rank = world_rank(communicator) @@ -237,10 +249,10 @@ def test_collective_hdf5_roundtrip_matches_serial(tmp_path): assert failure is None, failure -def test_collective_hdf5_refuses_rank_local_metadata_before_write(tmp_path): - communicator = _parallel_hdf5_world( - test_collective_hdf5_refuses_rank_local_metadata_before_write.__name__ - ) +def test_collective_hdf5_refuses_rank_local_metadata_before_write( + tmp_path, parallel_hdf5_lane, +): + communicator = parallel_hdf5_lane if communicator is None: return rank = world_rank(communicator) @@ -265,10 +277,10 @@ def test_collective_hdf5_refuses_rank_local_metadata_before_write(tmp_path): assert not tuple(shared_root.glob(".*must-not-exist*.tmp")) -def test_collective_hdf5_refuses_divergent_target_before_write(tmp_path): - communicator = _parallel_hdf5_world( - test_collective_hdf5_refuses_divergent_target_before_write.__name__ - ) +def test_collective_hdf5_refuses_divergent_target_before_write( + tmp_path, parallel_hdf5_lane, +): + communicator = parallel_hdf5_lane if communicator is None: return rank = world_rank(communicator) @@ -290,10 +302,10 @@ def test_collective_hdf5_refuses_divergent_target_before_write(tmp_path): assert not tuple(shared_root.glob("*must-not-exist.h5")) -def test_native_collective_hdf5_binding_failure_is_all_rank_consensus(tmp_path): - communicator = _parallel_hdf5_world( - test_native_collective_hdf5_binding_failure_is_all_rank_consensus.__name__ - ) +def test_native_collective_hdf5_binding_failure_is_all_rank_consensus( + tmp_path, parallel_hdf5_lane, +): + communicator = parallel_hdf5_lane if communicator is None: return rank = world_rank(communicator) diff --git a/tests/python/integration/io/test_time_history_checkpoint.py b/tests/python/integration/io/test_time_history_checkpoint.py index 791a12d9d..73a56bb73 100644 --- a/tests/python/integration/io/test_time_history_checkpoint.py +++ b/tests/python/integration/io/test_time_history_checkpoint.py @@ -668,8 +668,7 @@ def _passive_source_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/io/test_uniform_selective_history_checkpoint.py b/tests/python/integration/io/test_uniform_selective_history_checkpoint.py index cd2a46edd..03c54d462 100644 --- a/tests/python/integration/io/test_uniform_selective_history_checkpoint.py +++ b/tests/python/integration/io/test_uniform_selective_history_checkpoint.py @@ -79,11 +79,46 @@ def _program(model): - """Five-slot affine state history with two independently replayable gaps.""" + """Five-slot affine history plus a sparse Balance producer guarded during replay.""" + from pops.diagnostics import BalanceLedger + from pops.output._balance_due_contract import ( + BalanceDueConsumer, + BalanceDueContract, + BalanceDueRoute, + ) + program = pops.Program("uniform_selective_state5") _case, states = program_states(program, model, ("blk",)) state = states["blk"] program.keep_history(state, depth=4, checkpoint_policy=Interval(2)) + total = program.sum(state.n) + ledger = BalanceLedger("uniform-selective-replay") + program.record_balance( + ledger, + storage_change=total, + outward_boundary_flux=0.0 * total, + sources=0.0 * total, + reflux=0.0 * total, + projection=0.0 * total, + ) + route = ledger.route_identity(state.block) + balance_due_contract = BalanceDueContract( + make_identity("consumer-graph", {"test": "uniform-selective-replay"}), + ( + BalanceDueRoute( + route, + ( + BalanceDueConsumer( + make_identity( + "consumer-manifest", + {"test": "uniform-selective-replay"}, + ), + pops.time.every(2, clock=program.clock), + ), + ), + ), + ), + ) next_state = program.value( "Un", state.n @@ -93,7 +128,7 @@ def _program(model): ) program.commit(state.next, next_state) program.step_strategy(pops.time.FixedDt(DT_SEQUENCE[0])) - return program + return program, balance_due_contract def _initial_state(): @@ -183,8 +218,13 @@ def test_uniform_interval_history_variable_dt_restart_is_bit_identical(): model = passive_source_model( "uniform_selective_history_model", coefficient=COEFFICIENT ) - program = _program(model) - compiled = compile_problem(model=model, time=program, target="system") + program, balance_due_contract = _program(model) + compiled = compile_problem( + model=model, + time=program, + target="system", + balance_due_contract=balance_due_contract, + ) compiled_block = compile_block_model(model, target="system") initial = _initial_state() diff --git a/tests/python/integration/mpi/probe_amr_rank_change_restart.py b/tests/python/integration/mpi/probe_amr_rank_change_restart.py index cf4325baf..9881f5bee 100644 --- a/tests/python/integration/mpi/probe_amr_rank_change_restart.py +++ b/tests/python/integration/mpi/probe_amr_rank_change_restart.py @@ -333,8 +333,8 @@ def _assert_snapshot( ) -def _accepted_tagging_hysteresis(payload: Any) -> bytes: - """Extract the opaque persistent-tagging bytes from accepted-state v4.""" +def _accepted_tagging_hysteresis_span(payload: Any) -> tuple[bytes, int]: + """Extract the opaque persistent-tagging bytes and their authenticated offset.""" encoded = ( bytes(payload) if isinstance(payload, (bytes, bytearray, memoryview)) @@ -350,8 +350,8 @@ def read_size() -> int: cursor += 8 return value - if encoded[:8] != b"POPSAST4": - raise AssertionError("checkpoint does not contain accepted-state v4") + if encoded[:8] != b"POPSAST5": + raise AssertionError("checkpoint does not contain accepted-state v5") cursor = 8 level_count = read_size() clock_bytes = level_count * 40 @@ -364,10 +364,24 @@ def read_size() -> int: if cursor + name_size + 8 > len(encoded): raise AssertionError("accepted-state logical-clock map is truncated") cursor += name_size + 8 + cursor += 8 # CellTemporalPartitionKind + provider_size = read_size() + cursor += provider_size + cursor += 3 * 8 # topology epoch, synchronization tick, tick denominator + cell_count = read_size() + cursor += cell_count * 32 # level, cell id, rung, accepted tick (four i64 words) + if cursor > len(encoded): + raise AssertionError("accepted-state temporal partition is truncated") tagging_size = read_size() if cursor + tagging_size > len(encoded): raise AssertionError("accepted-state persistent-tagging payload is truncated") - return encoded[cursor : cursor + tagging_size] + return encoded[cursor : cursor + tagging_size], cursor + + +def _accepted_tagging_hysteresis(payload: Any) -> bytes: + """Extract the opaque persistent-tagging bytes from accepted-state v5.""" + tagging, _ = _accepted_tagging_hysteresis_span(payload) + return tagging def _assert_active_tagging_hysteresis(encoded: bytes) -> None: @@ -516,9 +530,32 @@ def _capture(checkpoint: Path, evidence: Path | None, *, bit_identical: bool) -> published = Path(runtime.checkpoint(checkpoint)) barrier(_COMM) if int(_COMM.rank) == 0: - source_owners, tagging_hysteresis = _checkpoint_source_authorities(published) + try: + source_owners, tagging_hysteresis = _checkpoint_source_authorities(published) + authority_row = { + "ok": True, + "owners": list(source_owners), + "tagging_hex": tagging_hysteresis.hex(), + "error": "", + } + except Exception as exc: # noqa: BLE001 -- publish the root refusal to every rank + authority_row = { + "ok": False, + "owners": [], + "tagging_hex": "", + "error": "%s: %s" % (type(exc).__name__, exc), + } else: - source_owners, tagging_hysteresis = (), b"" + authority_row = {"ok": None, "owners": [], "tagging_hex": "", "error": ""} + authority_rows = allgather_value(_COMM, authority_row) + root_authority = authority_rows[0] + if root_authority.get("ok") is not True: + raise RuntimeError( + "rank-change checkpoint authority inspection failed collectively: %s" + % root_authority.get("error", "missing rank-0 status") + ) + source_owners = tuple(int(owner) for owner in root_authority["owners"]) + tagging_hysteresis = bytes.fromhex(root_authority["tagging_hex"]) if not bit_identical: _advance(runtime, CONTINUATION_STEPS) @@ -531,16 +568,27 @@ def _capture(checkpoint: Path, evidence: Path | None, *, bit_identical: bool) -> ) if evidence is None: raise ValueError("relaxed rank-change capture requires an evidence path") + evidence_error = "" if int(_COMM.rank) == 0: - _write_evidence( - evidence, - checkpoint_metadata=checkpoint_metadata, - checkpoint_arrays=checkpoint_arrays, - final_metadata=final_metadata, - final_arrays=final_arrays, - source_owners=source_owners, - tagging_hysteresis=tagging_hysteresis, - initial_mass=initial_mass, + try: + _write_evidence( + evidence, + checkpoint_metadata=checkpoint_metadata, + checkpoint_arrays=checkpoint_arrays, + final_metadata=final_metadata, + final_arrays=final_arrays, + source_owners=source_owners, + tagging_hysteresis=tagging_hysteresis, + initial_mass=initial_mass, + ) + except Exception as exc: # noqa: BLE001 -- propagate root-only I/O failure + evidence_error = "%s: %s" % (type(exc).__name__, exc) + evidence_errors = allgather_value(_COMM, evidence_error) + root_evidence_error = str(evidence_errors[0]) + if root_evidence_error: + raise RuntimeError( + "rank-change evidence publication failed collectively: %s" + % root_evidence_error ) barrier(_COMM) if int(_COMM.rank) == 0: @@ -667,16 +715,29 @@ def _capture_divergent(checkpoint: Path) -> None: native = getattr(executor, "_s", None) if native is None: raise AssertionError("rank-change probe cannot reach its bound native AMR engine") - original = bytes(native.program_accepted_state()) - tagging = _accepted_tagging_hysteresis(original) - _assert_active_tagging_hysteresis(tagging) - tagging_offset = original.find(tagging) - if tagging_offset < 0: - raise AssertionError("accepted-state image lost its nested persistent-tagging payload") - if int(_COMM.rank) == 1: - divergent = bytearray(original) - divergent[tagging_offset + len(tagging) - 1] ^= 1 - native.restore_program_accepted_state(bytes(divergent)) + prepare_error = "" + try: + original = bytes(native.program_accepted_state()) + tagging, tagging_offset = _accepted_tagging_hysteresis_span(original) + _assert_active_tagging_hysteresis(tagging) + if int(_COMM.rank) == 1: + divergent = bytearray(original) + divergent[tagging_offset + len(tagging) - 1] ^= 1 + native.restore_program_accepted_state(bytes(divergent)) + except Exception as exc: # noqa: BLE001 -- coordinate local preparation failures + prepare_error = "%s: %s" % (type(exc).__name__, exc) + prepare_rows = allgather_value( + _COMM, + {"rank": int(_COMM.rank), "error": prepare_error}, + ) + prepare_failures = tuple( + row for row in prepare_rows if str(row.get("error", "")) + ) + if prepare_failures: + raise RuntimeError( + "divergent accepted-state preparation failed collectively: %r" + % (prepare_failures,) + ) caught = False message = "" diff --git a/tests/python/integration/mpi/probe_catalyst_live_mpi.py b/tests/python/integration/mpi/probe_catalyst_live_mpi.py index d2a0619bb..b79fb29da 100644 --- a/tests/python/integration/mpi/probe_catalyst_live_mpi.py +++ b/tests/python/integration/mpi/probe_catalyst_live_mpi.py @@ -12,6 +12,7 @@ duplicated observer lane, one rank-local piece of a canonical distributed ``ObserverFrame``, and the production ``CatalystPythonProvider`` lifecycle. """ + from __future__ import annotations import json @@ -27,7 +28,7 @@ from typing import Any -_PIPELINE_SOURCE = r'''# script-version: 2.0 +_PIPELINE_SOURCE = r"""# script-version: 2.0 import os from pathlib import Path import threading @@ -67,11 +68,10 @@ def catalyst_execute(info): % (step, rank, size), encoding="utf-8", ) -''' +""" -_MPI_IMAGE = re.compile( - r"^lib(?:mpi|pmpi|mpicxx)(?:\.[0-9]+)*(?:\.dylib|\.so(?:\.[0-9]+)*)$") +_MPI_IMAGE = re.compile(r"^lib(?:mpi|pmpi|mpicxx)(?:\.[0-9]+)*(?:\.dylib|\.so(?:\.[0-9]+)*)$") _ACTIVE_MPI_ENV = { "libmpi": "POPS_ACTIVE_MPI_LIBRARY", "libpmpi": "POPS_ACTIVE_PMPI_LIBRARY", @@ -103,7 +103,8 @@ def _loaded_shared_libraries() -> tuple[Path, ...]: rows.append(Path(candidate).resolve()) return tuple(sorted(set(rows))) raise RuntimeError( - "Catalyst MPI loaded-library authentication is unsupported on %s" % sys.platform) + "Catalyst MPI loaded-library authentication is unsupported on %s" % sys.platform + ) def _authenticate_loaded_mpi_stack() -> tuple[str, ...]: @@ -112,7 +113,7 @@ def _authenticate_loaded_mpi_stack() -> tuple[str, ...]: prefix_text = os.environ.get("CONDA_PREFIX") if not prefix_text: raise RuntimeError("Catalyst MPI probe requires an active CONDA_PREFIX") - active_lib = (Path(prefix_text).resolve() / "lib") + active_lib = Path(prefix_text).resolve() / "lib" images = _loaded_shared_libraries() mpi_images = tuple(path for path in images if _MPI_IMAGE.fullmatch(path.name)) if not mpi_images: @@ -121,7 +122,8 @@ def _authenticate_loaded_mpi_stack() -> tuple[str, ...]: if foreign: raise RuntimeError( "Catalyst loaded a second MPI implementation outside the active Conda prefix: %s" - % ", ".join(map(str, foreign))) + % ", ".join(map(str, foreign)) + ) for family, variable in _ACTIVE_MPI_ENV.items(): configured = os.environ.get(variable) if not configured: @@ -130,12 +132,14 @@ def _authenticate_loaded_mpi_stack() -> tuple[str, ...]: if not expected.is_file() or not expected.is_relative_to(active_lib): raise RuntimeError("Catalyst MPI probe received an invalid %s" % variable) family_pattern = re.compile( - r"^%s(?:\.[0-9]+)*(?:\.dylib|\.so(?:\.[0-9]+)*)$" % re.escape(family)) + r"^%s(?:\.[0-9]+)*(?:\.dylib|\.so(?:\.[0-9]+)*)$" % re.escape(family) + ) loaded = tuple(path for path in mpi_images if family_pattern.fullmatch(path.name)) if loaded != (expected,): raise RuntimeError( "Catalyst must load exactly %s for %s, found %s" - % (expected, family, ", ".join(map(str, loaded)) or "none")) + % (expected, family, ", ".join(map(str, loaded)) or "none") + ) return tuple(str(path) for path in mpi_images) @@ -167,17 +171,14 @@ def _collective_agree(world: Any, phase: str, error: BaseException | None) -> No or (row["error"] is not None and not isinstance(row["error"], str)) for owner, row in enumerate(rows) ): - raise RuntimeError( - "Catalyst MPI %s returned malformed rank evidence" % phase) + raise RuntimeError("Catalyst MPI %s returned malformed rank evidence" % phase) failures = [ "rank %d: %s" % (owner, row["error"]) for owner, row in enumerate(rows) if row["error"] is not None ] if failures: - raise RuntimeError( - "Catalyst MPI %s failed collectively: %s" % (phase, "; ".join(failures)) - ) + raise RuntimeError("Catalyst MPI %s failed collectively: %s" % (phase, "; ".join(failures))) def _shared_probe_directory(world: Any) -> Path: @@ -230,7 +231,8 @@ def _wait_for_client_evidence( if failure.is_file(): raise RuntimeError( "Catalyst Live client failed: %s" - % failure.read_text(encoding="utf-8").strip()) + % failure.read_text(encoding="utf-8").strip() + ) if time.monotonic() >= deadline: raise TimeoutError("timed out waiting for Catalyst Live %s" % name) time.sleep(0.01) @@ -302,7 +304,8 @@ def identity(domain: str, name: str) -> Any: {"test": "real-catalyst-live-mpi"}, ) request = OutputRequest( - "catalyst-live-mpi", (key,), ParallelMode.COLLECTIVE, rank=rank, size=size) + "catalyst-live-mpi", (key,), ParallelMode.COLLECTIVE, rank=rank, size=size + ) return ObserverFrame(snapshot, request) @@ -316,11 +319,13 @@ def main() -> None: size = int(world.size) if size != 2: raise RuntimeError( - "real Catalyst live MPI probe requires mpiexec -n 2 (observed %d ranks)" % size) + "real Catalyst live MPI probe requires mpiexec -n 2 (observed %d ranks)" % size + ) if int(world.thread_level) < 3: # MPI_THREAD_MULTIPLE has the standard value 3. raise RuntimeError( "real Catalyst live MPI probe requires MPI_THREAD_MULTIPLE; PoPS reports %d" - % int(world.thread_level)) + % int(world.thread_level) + ) import_error = None try: @@ -356,22 +361,26 @@ def main() -> None: raise RuntimeError("distributed Catalyst frame construction returned no frames") lane = world.duplicate_observer_lane("real-catalyst-live-mpi") + lane_close_authorized = False session = None try: from pops.output.observers import Catalyst, ObserverRun context = SimpleNamespace( - communicator=SimpleNamespace(identity="MPI_COMM_WORLD", handle=world)) + communicator=SimpleNamespace(identity="MPI_COMM_WORLD", handle=world) + ) session_error = None try: declaration = Catalyst(pipeline=str(pipeline)) - session = declaration.open_runtime_session( - {"worker_communicator": lane}, context) + session = declaration.open_runtime_session({"worker_communicator": lane}, context) authority = session.authority - if authority.get("threading") != "dedicated_collective" \ - or authority.get("worker_mpi") is not True: + if ( + authority.get("threading") != "dedicated_collective" + or authority.get("worker_mpi") is not True + ): raise RuntimeError( - "real Catalyst session did not authenticate a collective MPI worker") + "real Catalyst session did not authenticate a collective MPI worker" + ) except BaseException as caught: # noqa: BLE001 - make provider failures collective session_error = caught _collective_agree(world, "session construction", session_error) @@ -394,25 +403,67 @@ def main() -> None: ) worker = PostCommitObserverWorker( - thread_name="real-catalyst-live-mpi-worker") - queue = PostCommitObserverQueue( - session, - run, - consumer_id="real-catalyst-live-mpi", - worker_communicator=lane, - shared_worker=worker, + thread_name="real-catalyst-live-mpi-worker", + run_identity=run.run_identity, ) + queue_error = None + try: + queue = PostCommitObserverQueue( + session, + run, + consumer_id="real-catalyst-live-mpi", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + except BaseException as caught: # noqa: BLE001 - agree before provider entry + queue_error = caught + _collective_agree(world, "post-commit queue construction", queue_error) + if queue is None: + raise RuntimeError("Catalyst MPI queue construction returned no queue") + + initialize_prepared = False + initialize_error = None + try: + queue.prepare_initialize() + initialize_prepared = True + except BaseException as caught: # noqa: BLE001 - WORLD gates provider entry + initialize_error = caught + try: + _collective_agree(world, "post-commit initialization enqueue", initialize_error) + except BaseException as agreement_error: # noqa: BLE001 - cancel before arm + if initialize_prepared: + try: + queue.cancel_initialize(agreement_error) + except BaseException as cleanup_error: # noqa: BLE001 - retain primary + add_note = getattr(agreement_error, "add_note", None) + if callable(add_note): + add_note( + "prepared initialization cancellation also failed: %s" + % _error_text(cleanup_error) + ) + raise + queue.arm_initialize() + initialize_error = None + try: + queue.complete_initialize() + except BaseException as caught: # noqa: BLE001 - completion must agree on WORLD + initialize_error = caught + _collective_agree(world, "post-commit initialization completion", initialize_error) + queue.submit(frames[0]) queue.flush() if len(frames) == 2: + def validate_extract(evidence: Any) -> None: - if not isinstance(evidence, dict) \ - or evidence.get("source") != "mesh" \ - or not isinstance(evidence.get("port"), int): + if ( + not isinstance(evidence, dict) + or evidence.get("source") != "mesh" + or not isinstance(evidence.get("port"), int) + ): raise RuntimeError("Catalyst Live client extract evidence is invalid") - _wait_for_client_evidence( - world, "client-extract-requested.json", validate_extract) + _wait_for_client_evidence(world, "client-extract-requested.json", validate_extract) queue.submit(frames[1]) queue.flush() @@ -427,18 +478,56 @@ def validate_frame(evidence: Any) -> None: } if evidence != expected: raise RuntimeError( - "Catalyst Live client frame evidence differs: %r" % evidence) + "Catalyst Live client frame evidence differs: %r" % evidence + ) + + _wait_for_client_evidence(world, "client-frame.json", validate_frame) + + finalize_prepare_error = None + try: + reports = queue.prepare_close() + except BaseException as caught: # noqa: BLE001 - agree before finalization + finalize_prepare_error = caught + _collective_agree(world, "post-commit finalization preparation", finalize_prepare_error) + + finalize_prepared = False + finalize_enqueue_error = None + try: + queue.prepare_complete_close() + finalize_prepared = True + except BaseException as caught: # noqa: BLE001 - WORLD gates provider entry + finalize_enqueue_error = caught + try: + _collective_agree(world, "post-commit finalization enqueue", finalize_enqueue_error) + except BaseException as agreement_error: # noqa: BLE001 - cancel before arm + if finalize_prepared: + try: + queue.cancel_complete_close(agreement_error) + except BaseException as cleanup_error: # noqa: BLE001 - retain primary + add_note = getattr(agreement_error, "add_note", None) + if callable(add_note): + add_note( + "prepared finalization cancellation also failed: %s" + % _error_text(cleanup_error) + ) + raise + queue.arm_complete_close() + finalize_error = None + try: + reports = queue.complete_close() + except BaseException as caught: # noqa: BLE001 - never retry provider entry + finalize_error = caught + _collective_agree(world, "post-commit finalization completion", finalize_error) - _wait_for_client_evidence( - world, "client-frame.json", validate_frame) - reports = queue.close() worker.close() worker = None - if len(reports) != len(frames) \ - or any(report.status != "delivered" for report in reports): + if len(reports) != len(frames) or any( + report.status != "delivered" for report in reports + ): raise RuntimeError( "Catalyst worker did not deliver every collective frame: %r" - % [(report.status, report.reason) for report in reports]) + % [(report.status, report.reason) for report in reports] + ) for frame, report in zip(frames, reports, strict=True): receipt = report.receipt if receipt is None or receipt.frame_identity != frame.identity: @@ -447,25 +536,20 @@ def validate_frame(evidence: Any) -> None: raise RuntimeError("Catalyst receipt exposes an unexpected provider") if receipt.detail.get("implementation") != "paraview": raise RuntimeError("Catalyst did not load the ParaView implementation") - marker = marker_dir / ( - "execute-step-%04d-rank-%04d.txt" % (frame.macro_step, rank)) + marker = marker_dir / ("execute-step-%04d-rank-%04d.txt" % (frame.macro_step, rank)) expected = ( "step=%d\nrank=%d\nsize=2\nfield=U\nlive=enabled\n" - "worker=background\n" % (frame.macro_step, rank)) + "worker=background\n" % (frame.macro_step, rank) + ) if marker.read_text(encoding="utf-8") != expected: raise RuntimeError( "Catalyst live pipeline marker does not authenticate step %d rank %d" - % (frame.macro_step, rank)) + % (frame.macro_step, rank) + ) mpi_images = _authenticate_loaded_mpi_stack() except BaseException as caught: # noqa: BLE001 - backend already agrees on its lane delivery_error = caught finally: - if queue is not None: - try: - queue.close() - except BaseException as caught: # noqa: BLE001 - retain the primary failure - if delivery_error is None: - delivery_error = caught if worker is not None: try: worker.close() @@ -473,15 +557,17 @@ def validate_frame(evidence: Any) -> None: if delivery_error is None: delivery_error = caught _collective_agree(world, "post-commit worker delivery", delivery_error) + lane_close_authorized = True if len(frames) == 2: + def validate_closed(evidence: Any) -> None: if evidence != {"received": True}: - raise RuntimeError( - "Catalyst Live client close evidence differs: %r" % evidence) + raise RuntimeError("Catalyst Live client close evidence differs: %r" % evidence) _wait_for_client_evidence(world, "client-closed.json", validate_closed) finally: - lane.close_collectively() + if lane_close_authorized: + lane.close_collectively() world.barrier() marker_set_error = None @@ -489,14 +575,15 @@ def validate_closed(evidence: Any) -> None: try: expected_markers = { "execute-step-%04d-rank-%04d.txt" % (frame.macro_step, owner) - for frame in frames for owner in range(size) + for frame in frames + for owner in range(size) } - actual_markers = { - path.name for path in marker_dir.glob("execute-step-*-rank-*.txt")} + actual_markers = {path.name for path in marker_dir.glob("execute-step-*-rank-*.txt")} if actual_markers != expected_markers: raise RuntimeError( "Catalyst pipeline marker set differs from both MPI ranks: %r" - % sorted(actual_markers)) + % sorted(actual_markers) + ) except BaseException as caught: # noqa: BLE001 - report before peers continue marker_set_error = caught _collective_agree(world, "complete pipeline marker set", marker_set_error) diff --git a/tests/python/integration/mpi/test_async_balance_cadence_mpi.py b/tests/python/integration/mpi/test_async_balance_cadence_mpi.py new file mode 100644 index 000000000..0ba380290 --- /dev/null +++ b/tests/python/integration/mpi/test_async_balance_cadence_mpi.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Real MPI qualification of sparse Balance cadence and detached async snapshots. + +Both Uniform and two-level AMR execute the public +``Case -> Program.cadence -> compile -> mpi_world -> bind -> run`` route. The Program closes one +stride-3 window every third accepted macro-step, while async Balance consumers fire every two and +three accepted steps. Held windows must therefore publish exact zero ledgers and due windows must +publish an exact signed five-term ledger built from five real collective Program reductions. The +fixture explicitly authors that accounting split; it proves transport, signs, residual closure and +rank agreement, not automatic extraction of AMR reflux or projection terms. A separate every-step +async field series proves that each worker receives the accepted field image captured on its own +tick, never the latest native state. +""" +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from fractions import Fraction +from pathlib import Path +import os +import shutil +import tempfile +from typing import Any + +from _compile_once import compile_resolved_plan_once +from tests.python.support.requirements import require_mpi_or_skip + + +try: + import numpy as np + + import pops + from pops import _pops + from pops._native_collectives import ( + allgather_value, + barrier, + broadcast_value, + rank as world_rank, + size as world_size, + ) + from pops.amr import ( + AMRExecution, + AMRHierarchy, + AMRRegrid, + AMRTagging, + AMRTransfer, + Buffer, + ConflictPolicy, + EqualityPolicy, + Hysteresis, + PatchLayout, + Tag, + ) + from pops.codegen import Production + from pops.diagnostics import Balance, BalanceLedger + from pops.domain import Rectangle + from pops.frames import Cartesian2D + from pops.identity import make_identity + from pops.initial import InitialCondition + from pops.layouts import AMR, Uniform + from pops.lib.amr import StateTransfer + from pops.lib.initial import Gaussian + from pops.math import ValueExpr, ddt, div + from pops.mesh import CartesianGrid, PeriodicAxes + from pops.numerics import ( + DiscretizationPlan, + FiniteVolume, + reconstruction, + riemann, + variables, + ) + from pops.output import ( + AsyncScientificOutput, + ConsumerGraph, + NPZ, + ParallelMode, + read_npz, + ) + from pops.params import RuntimeParam + from pops.projection import ConservativeCellAverage + from pops.time import FixedDt, every +except Exception as exc: # noqa: BLE001 -- optional outside the required MPI lane + require_mpi_or_skip("async Balance MPI runtime import failed: %s" % exc) + + +ROOT = Path(__file__).resolve().parents[4] +N = 8 +DT = 1.0e-2 +NSTEPS = 6 +COMM = _pops.mpi_world() +RANK = world_rank(COMM) +SIZE = world_size(COMM) + + +if getattr(_pops, "__has_mpi__", False) is not True: + require_mpi_or_skip("async Balance cadence requires a native MPI build") +if SIZE != 2: + require_mpi_or_skip("async Balance cadence requires exactly mpiexec -n 2") + + +def _collective_local(label: str, operation: Any) -> Any: + result = None + error = None + try: + result = operation() + except BaseException as exc: # noqa: BLE001 -- publish every local cause before proceeding + error = "%s: %s" % (type(exc).__name__, exc) + errors = allgather_value(COMM, error) + failures = [ + "rank %d: %s" % (rank, value) + for rank, value in enumerate(errors) + if value is not None + ] + if failures: + raise RuntimeError("%s failed: %s" % (label, "; ".join(failures))) + return result + + +@contextmanager +def _shared_directory() -> Iterator[Path]: + local = tempfile.mkdtemp(prefix="pops-async-balance-mpi-") if RANK == 0 else None + root = Path(broadcast_value(COMM, local, root=0)) + barrier(COMM) + try: + yield root + finally: + barrier(COMM) + if RANK == 0: + shutil.rmtree(root, ignore_errors=True) + barrier(COMM) + + +def _authored_case(*, adaptive: bool) -> tuple[pops.Case, Any]: + label = "amr" if adaptive else "uniform" + frame = Rectangle( + "async-balance-%s-domain" % label, + lower=(0.0, 0.0), + upper=(1.0, 1.0), + ).frame(Cartesian2D()) + x_axis, y_axis = frame.axes + model = pops.Model("async-balance-%s-model" % label, frame=frame) + state = model.state("U", components=("rho",)) + (rho,) = state + flux = model.flux( + "zero_flux", + frame=frame, + state=state, + components={x_axis: (0.0 * rho,), y_axis: (0.0 * rho,)}, + waves={x_axis: (0.0 * rho,), y_axis: (0.0 * rho,)}, + ) + rate = model.rate("zero_rate", equation=ddt(state) == -div(flux)) + numerics = DiscretizationPlan() + numerics.rates.add( + rate, + FiniteVolume( + flux=flux, + variables=variables.Conservative(state), + reconstruction=reconstruction.FirstOrder(), + riemann=riemann.Rusanov(), + ), + ) + + case = pops.Case("async-balance-%s-case" % label) + block = case.block("tracer", model=model) + evolved = block[state] + case.numerics(numerics, block=block) + program = pops.Program("async-balance-%s-program" % label) + temporal = program.state(evolved) + accepted = program.value( + "accepted_growth", + temporal.n + program.dt * Fraction(1, 2) * temporal.n, + at=temporal.next.point, + ) + increment = program.value( + "accepted_increment", + accepted - temporal.n, + at=temporal.next.point, + ) + # This is an explicitly authored accounting fixture, not an automatic AMR-term extractor. + # Every term owns a real native Program.sum so the installed mpiexec route enters five + # collectives. The signed split closes the actual accepted storage increment exactly: + # storage + outward - sources - reflux - projection + # = q - q - q - q - (-2q) = 0. + storage_change = program.sum(increment) + outward_boundary_flux = -program.sum(increment) + sources = program.sum(increment) + reflux = program.sum(increment) + projection = -2.0 * program.sum(increment) + ledger = BalanceLedger("accepted-mass") + program.record_balance( + ledger, + storage_change=storage_change, + outward_boundary_flux=outward_boundary_flux, + sources=sources, + reflux=reflux, + projection=projection, + ) + program.commit(temporal.next, accepted) + program.cadence(stride=3) + program.step_strategy(FixedDt(DT)) + case.program(program) + + every_step = every(1, clock=program.clock) + every_two = every(2, clock=program.clock) + every_three = every(3, clock=program.clock) + root_npz = NPZ(mode=ParallelMode.ROOT) + case.consumers(ConsumerGraph.from_consumers(( + AsyncScientificOutput( + format=root_npz, + schedule=every_step, + fields=(evolved,), + target="state_every_1", + queue_capacity=1, + ), + AsyncScientificOutput( + format=root_npz, + schedule=every_two, + diagnostics=(Balance(ledger, block=block, cadence=every_two),), + target="balance_every_2", + queue_capacity=1, + ), + AsyncScientificOutput( + format=root_npz, + schedule=every_three, + diagnostics=(Balance(ledger, block=block, cadence=every_three),), + target="balance_every_3", + queue_capacity=1, + ), + ))) + case.initials.add(InitialCondition( + state=evolved, + value=Gaussian( + frame=frame, + center={x_axis: 0.5, y_axis: 0.5}, + background=1.0, + amplitude=0.5, + inverse_width=40.0, + ), + projection=ConservativeCellAverage(), + )) + grid = CartesianGrid( + frame=frame, + cells=(N, N), + periodic=PeriodicAxes(frame.axes), + ) + if not adaptive: + return case, Uniform(grid) + + threshold = case.param(RuntimeParam( + "async_balance_refine_threshold", + default=1.1, + )) + transfer = AMRTransfer() + transfer.state(evolved, StateTransfer()) + return case, AMR( + grid=grid, + hierarchy=AMRHierarchy(max_levels=2, ratios=(2,)), + tagging=AMRTagging( + rules=( + Tag(ValueExpr(evolved) > case.value(threshold)), + Buffer(cells=1), + ), + hysteresis=Hysteresis(0, EqualityPolicy.HOLD), + conflict_policy=ConflictPolicy.REFINE_WINS, + ), + regrid=AMRRegrid(schedule=every(100, clock=program.clock)), + transfer=transfer, + execution=AMRExecution.synchronous(), + patch_layout=PatchLayout( + distribute_coarse=True, + coarse_max_grid=4, + ), + ) + + +def _artifact(*, adaptive: bool) -> Any: + label = "amr" if adaptive else "uniform" + case, layout = _collective_local( + label + " authoring", + lambda: _authored_case(adaptive=adaptive), + ) + resolved = _collective_local( + label + " resolution", + lambda: pops.resolve( + pops.validate(case), + layout=layout, + backend=Production(), + compile_options={"include": str(ROOT / "include")}, + ), + ) + return compile_resolved_plan_once( + COMM, + resolved, + route="async-balance-" + label, + compile_artifact=pops.compile, + ) + + +def _snapshots(path: Path) -> dict[int, Any]: + result = {} + for artifact in path.rglob("*.npz"): + reopened = read_npz(artifact) + step = int(reopened.manifest["snapshot"]["clock"]["macro_step"]) + if step in result: + raise AssertionError("duplicate output at accepted step %d under %s" % (step, path)) + result[step] = reopened + return result + + +def _coarse_values(reopened: Any) -> np.ndarray: + snapshot = reopened.manifest["snapshot"] + field = next(row for row in snapshot["fields"] if row["key"]["level"] == 0) + token = make_identity("output-field", field["key"]).token + pieces = reopened.manifest["datasets"]["fields"][token]["pieces"] + return np.concatenate([ + np.asarray(reopened.arrays[piece["name"]]).ravel() + for piece in sorted(pieces, key=lambda row: (row["lower"], row["upper"])) + ]) + + +def _balance(reopened: Any) -> tuple[float, dict[str, float]]: + (payload,) = reopened.manifest["snapshot"]["diagnostics"] + return ( + float.fromhex(payload["value"]), + {name: float.fromhex(value) for name, value in payload["terms"].items()}, + ) + + +def _require_exact_signed_balance( + label: str, + step: int, + value: float, + terms: dict[str, float], +) -> None: + q = terms["storage_change"] + expected = { + "storage_change": q, + "outward_boundary_flux": -q, + "sources": q, + "reflux": q, + "projection": -2.0 * q, + } + residual = ( + terms["storage_change"] + + terms["outward_boundary_flux"] + - terms["sources"] + - terms["reflux"] + - terms["projection"] + ) + if q <= 0.0 or terms != expected or residual != 0.0 or value != residual: + raise AssertionError( + "%s due step %d did not preserve its exact signed five-term Balance: " + "value=%r terms=%r" % (label, step, value, terms) + ) + + +def _verify(root: Path, *, adaptive: bool) -> None: + if RANK != 0: + return + label = "amr" if adaptive else "uniform" + case_root = root / label + states = _snapshots(case_root / "state_every_1") + every_two = _snapshots(case_root / "balance_every_2") + every_three = _snapshots(case_root / "balance_every_3") + if set(states) != set(range(1, NSTEPS + 1)): + raise AssertionError("%s every-step async series is incomplete: %r" % (label, states)) + if set(every_two) != {2, 4, 6}: + raise AssertionError("%s every(2) Balance cadence differs: %r" % (label, every_two)) + if set(every_three) != {3, 6}: + raise AssertionError("%s every(3) Balance cadence differs: %r" % (label, every_three)) + + images = {step: _coarse_values(reopened) for step, reopened in states.items()} + if not np.array_equal(images[1], images[2]): + raise AssertionError("%s stride held state changed before step 3" % label) + if np.array_equal(images[2], images[3]): + raise AssertionError("%s due stride window did not advance at step 3" % label) + if not np.array_equal(images[3], images[4]) \ + or not np.array_equal(images[4], images[5]): + raise AssertionError("%s stride held state changed between steps 3 and 6" % label) + if np.array_equal(images[5], images[6]): + raise AssertionError("%s due stride window did not advance at step 6" % label) + + expected_terms = { + "storage_change", + "outward_boundary_flux", + "sources", + "reflux", + "projection", + } + for step in (2, 4): + value, terms = _balance(every_two[step]) + if value != 0.0 or set(terms) != expected_terms \ + or any(term != 0.0 for term in terms.values()): + raise AssertionError( + "%s held step %d did not publish the exact zero Balance ledger" + % (label, step) + ) + for series, steps in ((every_two, (6,)), (every_three, (3, 6))): + for step in steps: + value, terms = _balance(series[step]) + if set(terms) != expected_terms: + raise AssertionError("%s due step %d omitted a Balance term" % (label, step)) + _require_exact_signed_balance(label, step, value, terms) + if _balance(every_two[6]) != _balance(every_three[6]): + raise AssertionError( + "%s independent due consumers disagreed on the accepted step-6 Balance" % label + ) + + +def _run_case(root: Path, *, adaptive: bool) -> None: + label = "amr" if adaptive else "uniform" + artifact = _artifact(adaptive=adaptive) + runtime = pops.bind( + artifact, + resources={"execution_context": pops.ExecutionContext.mpi_world(artifact)}, + ) + levels = allgather_value(COMM, int(runtime.n_levels())) + expected_levels = 2 if adaptive else 1 + if levels != (expected_levels,) * SIZE: + raise AssertionError("%s hierarchy differs across ranks: %r" % (label, levels)) + native_cadence = allgather_value( + COMM, + ( + int(runtime._executor._s.program_substeps()), + int(runtime._executor._s.program_stride()), + ), + ) + if native_cadence != ((1, 3),) * SIZE: + raise AssertionError( + "%s did not bind the public Program cadence: %r" % (label, native_cadence) + ) + report = pops.run( + runtime, + t_end=NSTEPS * DT, + max_steps=NSTEPS, + output_dir=root / label, + ) + reports = allgather_value( + COMM, + ( + report.accepted_steps, + report.run_identity.token, + report.bind_identity.token, + ), + ) + if any(row != reports[0] for row in reports[1:]) or reports[0][0] != NSTEPS: + raise AssertionError("%s run report differs across ranks: %r" % (label, reports)) + accepted_balance = tuple( + row + for row in runtime.inspect().to_dict()["instance"]["accepted_diagnostics"] + if row["key"]["reduction"] == "discrete_balance" + ) + accepted_by_rank = allgather_value(COMM, accepted_balance) + if not accepted_balance or any(row != accepted_by_rank[0] for row in accepted_by_rank[1:]): + raise AssertionError( + "%s accepted Balance registry differs across ranks: %r" % (label, accepted_by_rank) + ) + barrier(COMM) + _collective_local(label + " output verification", lambda: _verify(root, adaptive=adaptive)) + + +def main() -> None: + with _shared_directory() as root: + os.environ["POPS_CACHE_DIR"] = str(root / "cache") + _run_case(root, adaptive=False) + _run_case(root, adaptive=True) + if RANK == 0: + print("PASS test_async_balance_cadence_mpi") + + +if __name__ == "__main__": + main() diff --git a/tests/python/integration/mpi/test_external_amr_field_solver_mpi.py b/tests/python/integration/mpi/test_external_amr_field_solver_mpi.py new file mode 100644 index 000000000..76cee84a8 --- /dev/null +++ b/tests/python/integration/mpi/test_external_amr_field_solver_mpi.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +"""Two-rank runtime proof for the external FieldTopology@2 + FieldSolver@2 AMR bridge. + +The oracle launches one public ``resolve -> compile -> bind -> run`` route with a genuinely +distributed coarse level and fine level. The same component pair survives a layout-changing +regrid, is rematerialized under exact communicator consensus, rolls back one typed collective +failure, and refuses a rank-local candidate divergence without publishing it. +""" +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager +import hashlib +import json +from pathlib import Path +import shutil +import sys +import tempfile +from typing import Any + +import numpy as np +import pops +from pops import _pops, interfaces +from pops._native_collectives import allgather_value, barrier, broadcast_value +from pops.amr import PatchLayout +from pops.external import build_source_package_manifest, load +from pops.fields import ExternalFieldSolver +from pops.lib.amr import BergerRigoutsos +from pops.lib.initial import Gaussian + +from _compile_once import compile_resolved_plan_once +from tests.python.integration._final_field_program import ( + resolve_periodic_field_program, + scalar_advection_field_model, +) +from tests.python.integration.native_loader.test_external_field_solver_runtime import ( + _manifest, + _moving_amr_program, + _mpi_faulted_solver_source, + _topology_source, +) + + +_COMM = _pops.mpi_world() +_fails = 0 + + +def chk(condition: Any, label: str) -> None: + """Record one all-rank assertion and keep the script's exit status collective.""" + global _fails + flags = tuple(bool(value) for value in allgather_value(_COMM, bool(condition))) + passed = all(flags) + if int(_COMM.rank) == 0: + print(" [%s] %s" % ("OK " if passed else "XX ", label), flush=True) + if not passed: + _fails += 1 + + +def _require_two_rank_world() -> None: + if int(_COMM.size) != 2: + raise RuntimeError( + "external AMR field bridge proof requires exactly mpiexec -n 2; size=%d" + % int(_COMM.size) + ) + + +@contextmanager +def _shared_temporary_directory() -> Iterator[Path]: + root = ( + tempfile.mkdtemp(prefix="pops-external-amr-field-mpi-") + if int(_COMM.rank) == 0 + else None + ) + shared = Path(broadcast_value(_COMM, root, root=0)) + try: + yield shared + finally: + barrier(_COMM) + if int(_COMM.rank) == 0: + shutil.rmtree(shared, ignore_errors=True) + barrier(_COMM) + + +def _publish_component( + shared: Path, + *, + name: str, + interface: Any, + source_factory: Callable[[Any], str], + manifest_parameters: tuple[dict[str, str], ...] = (), + instance_parameters: dict[str, Any] | None = None, +) -> Any: + """Publish source once, then load the exact package on every rank.""" + root = shared / name + alias = name.replace("-", "_") + manifest = _manifest(name, interface, manifest_parameters) + source_name = name + ".cpp" + manifest_path = root / (name + ".pops.json") + publication: tuple[bool, str] | None = None + if int(_COMM.rank) == 0: + try: + root.mkdir() + source = source_factory(manifest).encode() + (root / source_name).write_bytes(source) + package = build_source_package_manifest( + components={alias: manifest}, + payloads={source_name: ("source", source)}, + ) + manifest_path.write_text(json.dumps(package), encoding="utf-8") + except Exception as exc: # noqa: BLE001 -- broadcast before peers enter the loader + publication = (False, "%s: %s" % (type(exc).__name__, exc)) + else: + publication = (True, "") + publication = broadcast_value(_COMM, publication, root=0) + if not publication[0]: + raise RuntimeError("rank 0 component publication failed: " + publication[1]) + + component = None + load_error = "" + try: + factory = load(manifest_path).require(alias, interface=interface) + component = factory( + **({} if instance_parameters is None else instance_parameters) + ) + except Exception as exc: # noqa: BLE001 -- aggregate before any later collective + load_error = "%s: %s" % (type(exc).__name__, exc) + errors = tuple(allgather_value(_COMM, load_error)) + if any(errors): + raise RuntimeError( + "component package load differs across ranks: " + + "; ".join( + "rank %d: %s" % (rank, error) + for rank, error in enumerate(errors) + if error + ) + ) + if component is None: + raise RuntimeError("component package loader returned no instance") + return component + + +def _world_digest(value: Any) -> tuple[str, ...]: + payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return tuple(allgather_value(_COMM, hashlib.sha256(payload).hexdigest())) + + +def _level_state(runtime: Any, level: int) -> np.ndarray: + return np.asarray( + runtime.block_level_state_global("material", level), dtype=np.float64 + ).copy() + + +def _accepted_snapshot(runtime: Any, slot: str) -> dict[str, Any]: + return { + "time": runtime.time(), + "step": runtime.macro_step(), + "levels": tuple(_level_state(runtime, level) for level in range(runtime.n_levels())), + "potential": np.asarray( + runtime.field_potential_global(slot), dtype=np.float64 + ).copy(), + "boxes": tuple(runtime.patch_boxes()), + "owners": tuple( + tuple(runtime._executor.level_owner_ranks(level)) + for level in range(runtime.n_levels()) + ), + "providers": runtime.inspect().to_dict()["instance"]["field_providers"], + } + + +def _snapshot_is_exact(runtime: Any, slot: str, expected: dict[str, Any]) -> bool: + actual = _accepted_snapshot(runtime, slot) + return ( + actual["time"] == expected["time"] + and actual["step"] == expected["step"] + and actual["boxes"] == expected["boxes"] + and actual["owners"] == expected["owners"] + and actual["providers"] == expected["providers"] + and np.array_equal(actual["potential"], expected["potential"]) + and len(actual["levels"]) == len(expected["levels"]) + and all( + np.array_equal(value, reference) + for value, reference in zip( + actual["levels"], expected["levels"], strict=True + ) + ) + ) + + +def _set_marker(path: Path, present: bool) -> None: + if int(_COMM.rank) == 0: + if present: + path.write_text("fault", encoding="utf-8") + elif path.exists(): + path.unlink() + barrier(_COMM) + + +def test_external_amr_field_bridge_executes_and_refuses_collectively() -> None: + _require_two_rank_world() + if int(_COMM.rank) == 0: + print("== external AMR FieldTopology@2 + FieldSolver@2 under two-rank MPI ==") + with _shared_temporary_directory() as shared: + collective_fault = shared / "collective-fault" + divergent_fault = shared / "rank-local-fault" + topology = _publish_component( + shared, + name="mpi-amr-topology", + interface=interfaces.FieldTopology, + source_factory=lambda manifest: _topology_source( + manifest, + require_multilevel=True, + require_distributed=True, + periodic_axes=0, + ), + ) + solver = _publish_component( + shared, + name="mpi-amr-solver", + interface=interfaces.FieldSolver, + source_factory=lambda manifest: _mpi_faulted_solver_source( + manifest, + collective_fault_marker=collective_fault, + divergent_fault_marker=divergent_fault, + ), + manifest_parameters=({"name": "answer", "kind": "runtime"},), + instance_parameters={"answer": 7}, + ) + provider = ExternalFieldSolver( + topology=topology, + solver=solver, + relative_tolerance=1.0e-11, + absolute_tolerance=0.0, + max_iterations=23, + ) + model = scalar_advection_field_model("external-amr-field-mpi") + x_axis, y_axis = model.frame.axes + resolved = resolve_periodic_field_program( + model, + _moving_amr_program, + name="external-amr-field-mpi", + block_name="material", + target="amr_system", + n=8, + regrid_every=2, + field_solver=provider, + initial_profile=Gaussian( + frame=model.frame, + center={x_axis: 0.25, y_axis: 0.5}, + background=0.8, + amplitude=4.0, + inverse_width=80.0, + ), + components=(topology, solver), + anchored_field=True, + patch_layout=PatchLayout(distribute_coarse=True, coarse_max_grid=4), + clustering=BergerRigoutsos(maximum_box_size=4), + ) + threshold, = ( + runtime_slot.handle + for runtime_slot in resolved.bind_schema.runtime_slots + if runtime_slot.handle.local_id + == "external-amr-field-mpi_refine_threshold" + ) + artifact = compile_resolved_plan_once( + _COMM, + resolved, + route="external-amr-field-mpi", + compile_artifact=pops.compile, + ) + runtime = pops.bind( + artifact, + params={threshold: 1.2}, + resources={"execution_context": pops.ExecutionContext.mpi_world(artifact)}, + ) + slot, = runtime.field_provider_slots() + chk(runtime.n_levels() == 2, "bind materializes a two-level AMR hierarchy") + owners = tuple( + tuple(runtime._executor.level_owner_ranks(level)) for level in (0, 1) + ) + local_patch_counts = tuple( + allgather_value( + _COMM, + len(runtime._executor.output_state_local_pieces("material", level)), + ) + for level in (0, 1) + ) + chk( + all(set(level_owners) == {0, 1} for level_owners in owners) + and all(all(count > 0 for count in counts) for counts in local_patch_counts), + "both L0 and L1 own real local patches on both MPI ranks", + ) + + boxes_initial = tuple(runtime.patch_boxes()) + first = pops.run(runtime, t_end=8.0e-2, max_steps=1, console=False) + first_provider = runtime.inspect().to_dict()["instance"]["field_providers"][0] + first_layout = first_provider["materialized_layout_identity"] + chk( + first.accepted_steps == 1 + and first_provider["materialized"] + and len(set(_world_digest(first_provider))) == 1, + "the first composite solve publishes one exact provider report on every rank", + ) + + regrids_before = runtime.amr.explain_regrid().regrid_count + second = pops.run(runtime, t_end=2.4e-1, max_steps=2, console=False) + second_provider = runtime.inspect().to_dict()["instance"]["field_providers"][0] + chk( + second.accepted_steps == 2 + and runtime.amr.explain_regrid().regrid_count > regrids_before + and tuple(runtime.patch_boxes()) != boxes_initial + and second_provider["materialized_layout_identity"] != first_layout + and len(set(_world_digest(second_provider))) == 1, + "a layout-changing regrid rematerializes the exact component pair collectively", + ) + + _set_marker(collective_fault, True) + before_collective_failure = _accepted_snapshot(runtime, slot) + collective_error = None + try: + pops.run(runtime, t_end=3.2e-1, max_steps=1, console=False) + except RuntimeError as exc: + collective_error = str(exc) + collective_errors = tuple(allgather_value(_COMM, collective_error)) + chk( + len(set(collective_errors)) == 1 + and collective_errors[0] is not None + and "invalid_evaluation action=fail_run" in collective_errors[0], + "one typed FieldSolver failure reaches every rank with the same FailRun outcome", + ) + chk( + _snapshot_is_exact(runtime, slot, before_collective_failure), + "collective FailRun restores levels, potential, clock, topology and provider evidence", + ) + _set_marker(collective_fault, False) + retry = pops.run(runtime, t_end=3.2e-1, max_steps=1, console=False) + chk( + retry.accepted_steps == 1 and runtime.macro_step() == 4, + "the exact accepted state remains retryable after collective rollback", + ) + + _set_marker(divergent_fault, True) + before_divergence = _accepted_snapshot(runtime, slot) + divergent_error = None + try: + pops.run(runtime, t_end=4.0e-1, max_steps=1, console=False) + except RuntimeError as exc: + divergent_error = str(exc) + divergent_errors = tuple(allgather_value(_COMM, divergent_error)) + chk( + len(set(divergent_errors)) == 1 + and divergent_errors[0] is not None + and "provider report differs between MPI ranks" in divergent_errors[0], + "a rank-local non-finite candidate is refused by exact report consensus", + ) + chk( + _snapshot_is_exact(runtime, slot, before_divergence), + "rank-divergent refusal publishes no field, state, clock or topology mutation", + ) + + +def _run_all() -> int: + functions = [ + value + for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value) + ] + for function in functions: + function() + if int(_COMM.rank) == 0: + print( + "\n%s test_external_amr_field_solver_mpi (%d check failures)" + % ("FAIL" if _fails else "PASS", _fails), + flush=True, + ) + return _fails + + +if __name__ == "__main__": + sys.exit(1 if _run_all() else 0) diff --git a/tests/python/integration/mpi/test_scientific_output_mpi.py b/tests/python/integration/mpi/test_scientific_output_mpi.py index e01111287..ef734e669 100644 --- a/tests/python/integration/mpi/test_scientific_output_mpi.py +++ b/tests/python/integration/mpi/test_scientific_output_mpi.py @@ -76,6 +76,10 @@ from pops.projection import ConservativeCellAverage from pops.output._writers.hdf5 import _collective_temporary_owner from pops.time import FixedDt, StagePoint, TimePoint, every + from vtkmodules.vtkIOXML import ( + vtkXMLPUnstructuredGridReader, + vtkXMLUnstructuredGridReader, + ) except Exception as exc: # noqa: BLE001 -- optional outside the required MPI lane require_mpi_or_skip("scientific-output MPI/HDF5 runtime import failed: %s" % exc) @@ -128,6 +132,8 @@ def _shared_directory() -> Path: def _validate_native_binding_error_consensus(root: Path) -> None: """One malformed rank must fail before HDF5 while every peer receives the same cause.""" + lane = COMM.duplicate_observer_lane( + "scientific-output-mpi-hdf5-binding-validation") values = ( [[1.0, 2.0], [3.0, 4.0]] if RANK == 0 @@ -135,21 +141,24 @@ def _validate_native_binding_error_consensus(root: Path) -> None: ) error = None try: - _pops._write_parallel_hdf5( - COMM, - str(root / "binding-must-not-enter-hdf5.h5"), - "{}", - {"geometry/0000/coverage": np.zeros((2, 2), dtype=np.bool_)}, - ({ - "dataset": "fields/0000/values", - "dtype": np.dtype(np.float64).str, - "shape": (2, 2), - "pieces": ({"lower": (0, 0), "upper": (2, 2), "values": values},), - },), - ) - except RuntimeError as exc: - error = str(exc) - errors = allgather_value(COMM, error) + try: + _pops._write_parallel_hdf5( + lane, + str(root / "binding-must-not-enter-hdf5.h5"), + "{}", + {"geometry/0000/coverage": np.zeros((2, 2), dtype=np.bool_)}, + ({ + "dataset": "fields/0000/values", + "dtype": np.dtype(np.float64).str, + "shape": (2, 2), + "pieces": ({"lower": (0, 0), "upper": (2, 2), "values": values},), + },), + ) + except RuntimeError as exc: + error = str(exc) + errors = allgather_value(lane, error) + finally: + lane.close_collectively() if not all(item is not None and "binding input validation" in item for item in errors): raise AssertionError("rank-local binding fault did not reach all ranks: %r" % (errors,)) if len(set(errors)) != 1: @@ -691,22 +700,80 @@ def validate() -> None: if tuple(sorted(all_leaf_paths)) != leaves: raise AssertionError("PVTU catalogues do not cover every emitted VTU leaf exactly") - # The exact PoPS reopen above is mandatory and authenticates every component. When the - # independently maintained VTK Python reader is installed in the MPI lane, also prove that - # the standard PVTU is directly consumable without any PoPS-specific adapter. - try: - from vtkmodules.vtkIOXML import vtkXMLPUnstructuredGridReader - except ImportError: - vtkXMLPUnstructuredGridReader = None - if vtkXMLPUnstructuredGridReader is not None: - for pvtu_path in parallel: - reader = vtkXMLPUnstructuredGridReader() - reader.SetFileName(str(pvtu_path)) - reader.Update() - grid = reader.GetOutput() - if grid.GetNumberOfCells() < 1 \ - or grid.GetCellData().GetArray("U") is None: - raise AssertionError("the native VTK reader could not consume the PVTU") + # Traverse from the standard PVD itself, then reopen every referenced PVTU and every + # rank-local VTU with the independently maintained VTK readers. This is mandatory in the + # M4 lane: absence of VTK is a required-test failure, never an optional local success. + catalog_paths = tuple( + (collections[-1].parent / node.attrib["file"]).resolve() + for node in datasets + ) + if catalog_paths != tuple(path.resolve() for path in parallel): + raise AssertionError("native PVD traversal differs from the exact temporal series") + native_leaf_paths = [] + for macro_step, (dataset, pvtu_path) in enumerate( + zip(datasets, catalog_paths, strict=True), start=1): + expected_time = macro_step * DT + if float(dataset.attrib["timestep"]) != expected_time: + raise AssertionError("native PVD traversal lost the physical output time") + + reopened_parallel = read_paraview_parallel(pvtu_path) + native_parallel = vtkXMLPUnstructuredGridReader() + native_parallel.SetFileName(str(pvtu_path)) + native_parallel.Update() + if native_parallel.GetErrorCode() != 0: + raise AssertionError("the native VTK reader rejected the PVTU") + parallel_grid = native_parallel.GetOutput() + + expected_cells = 0 + for leaf_path in reopened_parallel.paths: + native_leaf_paths.append(leaf_path.resolve()) + xml_piece = ET.parse(leaf_path).getroot().find( + "./UnstructuredGrid/Piece") + if xml_piece is None: + raise AssertionError("rank-local VTU has no UnstructuredGrid piece") + leaf_cells = int(xml_piece.attrib["NumberOfCells"]) + leaf_points = int(xml_piece.attrib["NumberOfPoints"]) + expected_cells += leaf_cells + + native_leaf = vtkXMLUnstructuredGridReader() + native_leaf.SetFileName(str(leaf_path)) + native_leaf.Update() + if native_leaf.GetErrorCode() != 0: + raise AssertionError("the native VTK reader rejected a rank-local VTU") + leaf_grid = native_leaf.GetOutput() + if leaf_grid.GetNumberOfCells() != leaf_cells \ + or leaf_grid.GetNumberOfPoints() != leaf_points: + raise AssertionError( + "native VTU geometry differs from the rank-local XML piece") + for name in ("U", "pops_level", "vtkGhostType"): + array = leaf_grid.GetCellData().GetArray(name) + if array is None or array.GetNumberOfTuples() != leaf_cells: + raise AssertionError( + "native VTU reader lost rank-local cell array %s" % name) + time_value = leaf_grid.GetFieldData().GetArray("TimeValue") + if time_value is None \ + or time_value.GetNumberOfTuples() != 1 \ + or time_value.GetTuple1(0) != expected_time: + raise AssertionError( + "native VTU reader lost the rank-local physical output time") + + if parallel_grid.GetNumberOfCells() != expected_cells: + raise AssertionError( + "native PVTU reader did not assemble every rank-local VTU cell") + for name in ("U", "pops_level", "vtkGhostType"): + array = parallel_grid.GetCellData().GetArray(name) + if array is None or array.GetNumberOfTuples() != expected_cells: + raise AssertionError( + "native PVTU reader lost assembled cell array %s" % name) + public_field = parallel_grid.GetCellData().GetArray("U") + if public_field.GetNumberOfComponents() != 1 \ + or public_field.GetComponentName(0) != "rho": + raise AssertionError( + "native PVTU reader lost the user-authored U/rho field name") + + if tuple(sorted(native_leaf_paths)) != tuple(path.resolve() for path in leaves): + raise AssertionError( + "native PVD/PVTU traversal did not reopen every rank-local VTU exactly once") observed_ranks = set() observed_steps = set() diff --git a/tests/python/integration/native_loader/test_compile_module_trace.py b/tests/python/integration/native_loader/test_compile_module_trace.py index 6b68907e8..daa6fafc6 100644 --- a/tests/python/integration/native_loader/test_compile_module_trace.py +++ b/tests/python/integration/native_loader/test_compile_module_trace.py @@ -2,7 +2,7 @@ """ADC-557 real-compiler acceptance: the standard flow lowers the final model once. A final ``pops.physics.Model`` compiled through the internal ``compile_problem`` seam (no -manual ``m.to_module()``) yields a handle that carries the operator-first Module as the lowered-module +manual ``m.lower()``) yields a handle that carries the operator-first Module as the lowered-module trace (``compiled.inspect()``) and a compile-time ``module_hash`` for drift detection. The bounded native ``ModelSpec`` bridge is rejected before compilation because it has no canonical Module authority; a missing trace can therefore never be fabricated. diff --git a/tests/python/integration/native_loader/test_external_component_package.py b/tests/python/integration/native_loader/test_external_component_package.py index 1e6e79b57..be9b13df2 100644 --- a/tests/python/integration/native_loader/test_external_component_package.py +++ b/tests/python/integration/native_loader/test_external_component_package.py @@ -1,8 +1,10 @@ """Collected native package test: compile, audit, install, load and call the real ABI consumer.""" from __future__ import annotations -import json +import importlib.machinery import importlib.util +import json +import os import subprocess import sys from dataclasses import replace @@ -26,10 +28,12 @@ compile_component, load, ) +from pops.identity import make_identity from pops.model import ComponentManifest from pops.output import ( CoarseOnly, ConsumerGraph, ExternalWriter, ParallelMode, ScientificOutput, ) +from pops.runtime._consumer_transaction import ConsumerPublicationError from pops.runtime._runtime_consumers import RuntimeConsumerPublisher from pops.time import every, on_start @@ -38,6 +42,27 @@ EXAMPLE = ROOT / "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py" +def _require_installed_component_package_proof() -> None: + if os.environ.get("POPS_PROVE_INSTALLED_COMPONENT_PACKAGE") != "1": + return + package_root = Path(pops.__file__).resolve().parent + wheel_include = (package_root / "include").resolve() + assert wheel_include.is_dir() + assert (wheel_include / "pops_headers.manifest").is_file() + assert Path(pops_include()).resolve() == wheel_include + + from pops import _pops + + native_path = Path(_pops.__file__).resolve() + assert native_path.parent == package_root + assert any( + native_path.name.endswith(suffix) + for suffix in importlib.machinery.EXTENSION_SUFFIXES + ) + assert _pops.__has_kokkos__ is True + assert _pops.__native_loader_contract__["schema_version"] == 1 + + def _manifest(*, generic: bool = True, device: str = "cpu") -> ComponentManifest: interface = interfaces.NumericalFlux return ComponentManifest( @@ -352,6 +377,7 @@ def _writer_source(manifest: ComponentManifest) -> bytes: def test_source_component_executes_through_generic_native_loader_and_flux_consumer(tmp_path): + _require_installed_component_package_proof() manifest = _manifest() source = _source(manifest) (tmp_path / "average.cpp").write_bytes(source) @@ -534,10 +560,18 @@ def _load_example(): return module -def _writer_case(example, artifacts, *, adaptive: bool): +def _writer_case( + example, + artifacts, + *, + adaptive: bool, + paired_start_transaction: bool = False, +): from pops.layouts import Uniform from pops.output import SelectedLevels + if adaptive and paired_start_transaction: + raise ValueError("paired Writer transaction is a Uniform-only test route") core = example.build_authoring(output_root="unused") core.numerics.boundaries.add(example.build_transport_boundaries(core)) core.case.numerics(core.numerics, block=core.tracer) @@ -570,7 +604,11 @@ def _writer_case(example, artifacts, *, adaptive: bool): outputs.append(ScientificOutput( format=ExternalWriter( artifacts[-1], extension=".popsbin", mode=output_mode), - schedule=every(1, clock=core.program.clock), + schedule=( + on_start(clock=core.program.clock) + if paired_start_transaction + else every(1, clock=core.program.clock) + ), fields=(core.tracer_state,), levels=SelectedLevels(0, 1) if adaptive else CoarseOnly(), target="amr-writer" if adaptive else "uniform-writer", @@ -611,7 +649,108 @@ def _bind_writer_case(example, core, layout, artifacts, initial_state=None): return simulation -def test_qualified_writer_runs_through_uniform_and_amr_runtime_transactions(tmp_path): +def _begin_direct_consumer_run(runtime, request): + """Open the run-scoped observer/ROOT lane required by direct transaction tests.""" + engine = runtime._executor + run_identity = make_identity( + "run", + { + "runtime": runtime._runtime_plan.identity.token, + "time": float(engine.time()).hex(), + "macro_step": int(engine.macro_step()), + }, + ) + runtime._publisher.begin_post_commit_consumers(run_identity) + request.addfinalizer( + lambda: runtime._publisher.close_live_visualizations( + run_identity, raise_on_failure=False + ) + ) + return run_identity + + +def test_real_writer_collision_compensates_the_complete_consumer_graph_transaction( + tmp_path, request +): + example = _load_example() + first = _compile_writer(tmp_path / "transaction-one", "transaction_writer_one") + second = _compile_writer(tmp_path / "transaction-two", "transaction_writer_two") + core, layout, initial_state = _writer_case( + example, + (first, second), + adaptive=False, + paired_start_transaction=True, + ) + runtime = _bind_writer_case( + example, + core, + layout, + (first, second), + initial_state, + ) + output_root = tmp_path / "transaction-output" + runtime._output_root = output_root + run_identity = _begin_direct_consumer_run(runtime, request) + + accepted_before = { + "time": runtime.time(), + "macro_step": runtime.macro_step(), + "state": np.asarray( + runtime.state_global("tracer"), dtype=np.float64 + ).copy(), + "cursors": runtime.consumer_cursors.to_data(), + "reports": tuple(runtime._consumer_reports), + } + transactions = runtime._stage_consumers(at_start=True) + assert len(transactions) == 1 + transaction = transactions[0] + prepared = tuple(row[1] for row in transaction._prepared) + assert len(prepared) == 2 + targets = tuple(row.target for row in prepared) + assert all(target is not None for target in targets) + first_target, collision_target = targets + collision_bytes = b"pre-existing user-owned publication" + collision_target.write_bytes(collision_bytes) + + with pytest.raises(ConsumerPublicationError, match="FileExistsError") as failure: + transaction.accept() + + report = failure.value.report + assert report.status == "failed" + assert report.published == () + assert report.cursors.to_data() == accepted_before["cursors"] + assert len(report.staged_effects) == 2 + assert report.rolled_back_effects == tuple(reversed(report.staged_effects)) + assert not first_target.exists() + assert collision_target.read_bytes() == collision_bytes + assert not tuple(output_root.rglob(".*.writer-stage*")) + assert not tuple(output_root.rglob("*.component-published")) + assert runtime.time() == accepted_before["time"] + assert runtime.macro_step() == accepted_before["macro_step"] + assert np.array_equal( + np.asarray(runtime.state_global("tracer"), dtype=np.float64), + accepted_before["state"], + ) + assert runtime.consumer_cursors.to_data() == accepted_before["cursors"] + assert tuple(runtime._consumer_reports) == accepted_before["reports"] + + collision_target.unlink() + accepted_reports = runtime._fire_consumers(at_start=True) + assert len(accepted_reports) == 1 + assert accepted_reports[0].status == "accepted" + assert len(accepted_reports[0].published) == 2 + assert runtime.consumer_cursors.to_data() != accepted_before["cursors"] + published = tuple(sorted(output_root.rglob("*.popsbin"))) + assert len(published) == 2 + assert all("fields=1" in path.read_text(encoding="utf-8") for path in published) + assert not tuple(output_root.rglob(".*.writer-stage*")) + assert not tuple(output_root.rglob("*.component-published")) + runtime._publisher.close_live_visualizations(run_identity) + + +def test_qualified_writer_runs_through_uniform_and_amr_runtime_transactions( + tmp_path, request +): example = _load_example() first = _compile_writer(tmp_path / "source-one", "writer_one") second = _compile_writer(tmp_path / "source-two", "writer_two") @@ -624,6 +763,7 @@ def test_qualified_writer_runs_through_uniform_and_amr_runtime_transactions(tmp_ # Rejection owns and discards the verified native temporary without publishing it. runtime._output_root = tmp_path / "uniform-output" + run_identity = _begin_direct_consumer_run(runtime, request) transactions = runtime._stage_consumers(at_start=True) assert len(transactions) == 1 stage_dir = runtime._output_root / "reject-stage" @@ -665,6 +805,8 @@ def test_qualified_writer_runs_through_uniform_and_amr_runtime_transactions(tmp_ _retain_output_recoveries=runtime._retain_output_recoveries, )) + runtime._publisher.close_live_visualizations(run_identity) + run_report = pops.run( uniform, t_end=1.0e-4, max_steps=1, output_dir=tmp_path / "uniform-run") diff --git a/tests/python/integration/native_loader/test_external_field_solver_runtime.py b/tests/python/integration/native_loader/test_external_field_solver_runtime.py index 8b44a208e..320e6af16 100644 --- a/tests/python/integration/native_loader/test_external_field_solver_runtime.py +++ b/tests/python/integration/native_loader/test_external_field_solver_runtime.py @@ -9,12 +9,15 @@ from pops import interfaces from pops.external import build_source_package_manifest, load from pops.fields import ExternalFieldSolver +from pops.lib.initial import Gaussian from pops.model import ComponentManifest from pops.time import FailRun, FixedDt from tests.python.integration._final_field_program import ( passive_field_model, resolve_periodic_field_program, + scalar_advection_field_model, ) +from tests.python.support.native_execution_context import artifact_execution_context def _manifest(name, interface, parameters=()): @@ -33,7 +36,7 @@ def _manifest(name, interface, parameters=()): "dimension": 2, "scalar": "float64", "device": "cpu", - "features": [], + "features": ["mpi"], }]}, entry_points={"interface_table": "pops_component_interface_v1"}, ) @@ -58,13 +61,22 @@ def _component( return factory(**({} if instance_parameters is None else instance_parameters)) -def _topology_source(manifest): +def _topology_source( + manifest, + *, + require_multilevel=False, + require_distributed=False, + periodic_axes=3, +): return f'''#include #include #include +#include namespace {{ struct State {{ int prepare_count; int topology_count; }}; +std::string previous_multilevel_layout; +std::string previous_multilevel_signature; PopsComponentStatusV1 ok() {{ return {{sizeof(PopsComponentStatusV1), 0, POPS_COMPONENT_CONTINUE_V1, nullptr}}; @@ -89,27 +101,80 @@ def _topology_source(manifest): !request || !result || !request->topology.topology_recipe_identity || !request->topology.source_layout_identity || !request->topology.materialized_layout_identity || - request->topology.dimension != 2 || request->topology.periodic_axes != 3 || + request->topology.dimension != 2 || + request->topology.periodic_axes != {periodic_axes} || request->topology.patch_count == 0 || request->local_patch_count > request->topology.patch_count) return 3; + bool saw_level_zero = false; + bool saw_level_one = false; + bool saw_owner_zero = false; + bool saw_owner_one = false; + std::string topology_signature; + for (std::size_t patch = 0; patch < request->topology.patch_count; ++patch) {{ + const auto& metadata = request->topology.patches[patch]; + saw_level_zero = saw_level_zero || metadata.level == 0; + saw_level_one = saw_level_one || metadata.level == 1; + saw_owner_zero = saw_owner_zero || metadata.owner_rank == 0; + saw_owner_one = saw_owner_one || metadata.owner_rank == 1; + topology_signature += std::to_string(metadata.level) + ":" + + std::to_string(metadata.owner_rank) + ":" + std::to_string(metadata.lower[0]) + ":" + + std::to_string(metadata.lower[1]) + ":" + std::to_string(metadata.upper[0]) + ":" + + std::to_string(metadata.upper[1]) + ";"; + }} + const bool multilevel = saw_level_one; + if ({str(require_multilevel).lower()} && !saw_level_zero) return 6; + if ({str(require_distributed).lower()} && + (!saw_level_zero || !saw_level_one || !saw_owner_zero || !saw_owner_one)) return 11; + if ({str(require_multilevel).lower()} && !previous_multilevel_signature.empty() && + topology_signature != previous_multilevel_signature && + previous_multilevel_layout == request->topology.materialized_layout_identity) return 10; + if ({str(require_multilevel).lower()}) {{ + previous_multilevel_signature = topology_signature; + previous_multilevel_layout = request->topology.materialized_layout_identity; + }} + if ({str(require_multilevel).lower()} && !{str(require_distributed).lower()} && + request->local_patch_count != request->topology.patch_count) return 8; + if ({str(require_distributed).lower()} && + (request->local_patch_count == 0 || + request->local_patch_count >= request->topology.patch_count)) return 8; + bool saw_masked_coarse_cell = false; + bool saw_active_fine_cell = false; + int local_owner = -1; for (std::size_t local = 0; local < request->local_patch_count; ++local) {{ const auto& patch = request->local_patches[local]; - if (patch.metadata_index >= request->topology.patch_count || - patch.material_representation != POPS_FIELD_MATERIAL_FULL_V1 || - patch.material_coverage.data || patch.cut_cell_volume_fraction.data || + const bool full = patch.material_representation == POPS_FIELD_MATERIAL_FULL_V1; + const bool binary = + patch.material_representation == POPS_FIELD_MATERIAL_BINARY_COVERAGE_V1; + if (patch.metadata_index >= request->topology.patch_count || (!full && !binary) || + ({str(require_multilevel).lower()} && multilevel && !binary) || + (full && patch.material_coverage.data) || + (binary && (!patch.material_coverage.data || + patch.material_coverage.size != patch.material_mask.size)) || + patch.cut_cell_volume_fraction.data || patch.material_ids.data || patch.material_mask.size != patch.component_labels.size) return 4; const auto& metadata = request->topology.patches[patch.metadata_index]; + if ({str(require_distributed).lower()} && + (local_owner == -1 ? (local_owner = metadata.owner_rank, false) + : local_owner != metadata.owner_rank)) return 12; if (metadata.dimension != 2 || metadata.cell_spacing[0] <= 0.0 || metadata.cell_spacing[1] <= 0.0 || !metadata.layout_identity || !metadata.patch_identity || std::strcmp(metadata.layout_identity, request->topology.source_layout_identity) != 0) return 5; for (std::size_t point = 0; point < patch.material_mask.size; ++point) {{ - patch.material_mask.data[point] = 1; - patch.component_labels.data[point] = 1; + const auto active = binary ? patch.material_coverage.data[point] : 1; + if (active > 1) return 7; + saw_masked_coarse_cell = + saw_masked_coarse_cell || (metadata.level == 0 && active == 0); + saw_active_fine_cell = + saw_active_fine_cell || (metadata.level > 0 && active == 1); + patch.material_mask.data[point] = active; + patch.component_labels.data[point] = active == 1 ? 1 : 0; }} }} + if ({str(require_multilevel).lower()} && multilevel && + (!saw_masked_coarse_cell || !saw_active_fine_cell)) return 9; static const PopsTopologyLabelV2 labels[] = {{ {{sizeof(PopsTopologyLabelV2), 1, "material", "external-test-topology"}} }}; @@ -147,13 +212,21 @@ def _topology_source(manifest): ''' -def _solver_source(manifest, *, solution_expression="7.0"): +def _solver_source( + manifest, + *, + solution_expression="7.0", + solve_count_statement="++state->solve_count;", + iterations_expression="state->solve_count", + extra_includes="", +): expected_parameters_json = json.dumps( {"answer": 7}, sort_keys=True, separators=(",", ":"), ensure_ascii=True) return f'''#include #include #include #include +{extra_includes} namespace {{ struct State {{ int prepare_count; int solve_count; }}; @@ -199,7 +272,7 @@ def _solver_source(manifest, *, solution_expression="7.0"): !request->boundary_contract_json || std::strstr(request->boundary_contract_json, "identity") == nullptr) return 3; - ++state->solve_count; + {solve_count_statement} for (std::size_t local = 0; local < request->local_patch_count; ++local) {{ const auto& patch = request->local_patches[local]; if (patch.metadata_index >= request->topology.patch_count || @@ -216,18 +289,18 @@ def _solver_source(manifest, *, solution_expression="7.0"): for (std::size_t j = 0; j < patch.solution.extents[1]; ++j) {{ for (std::size_t i = 0; i < patch.solution.extents[0]; ++i) {{ const std::size_t point = j * patch.solution.extents[0] + i; - if (mask[point] != 1 || labels[point] != 1) return 5; + if (mask[point] > 1 || labels[point] != (mask[point] == 1 ? 1 : 0)) return 5; const auto index = static_cast(i) * patch.solution.axis_strides[0] + static_cast(j) * patch.solution.axis_strides[1]; - solution[index] = {solution_expression}; + if (mask[point] == 1) solution[index] = {solution_expression}; }} }} }} report->status = POPS_SOLVE_SOLVED_V2; report->action = POPS_SOLVE_ACTION_NONE_V2; - report->iterations = state->solve_count; + report->iterations = {iterations_expression}; report->relative_residual = 0.0; report->reference_residual_norm = 1.0; report->residual_norm = 0.0; @@ -261,11 +334,56 @@ def _solver_source(manifest, *, solution_expression="7.0"): ''' -def _nonfinite_solver_source(manifest): +def _externally_faulted_solver_source(manifest, fault_marker): return _solver_source( manifest, - solution_expression="std::numeric_limits::quiet_NaN()", + solution_expression=( + "std::filesystem::exists(%s) " + "? std::numeric_limits::quiet_NaN() : 7.0" + % json.dumps(str(fault_marker)) + ), + solve_count_statement="", + iterations_expression="1", + extra_includes="#include ", + ) + + +def _mpi_faulted_solver_source( + manifest, + *, + collective_fault_marker, + divergent_fault_marker, + divergent_owner=1, +): + """Return one MPI component with typed collective and rank-local fault switches.""" + source = _solver_source( + manifest, + solution_expression=( + "(std::filesystem::exists(%s) && request->local_patch_count != 0 && " + "request->topology.patches[request->local_patches[0].metadata_index].owner_rank " + "== %d) ? std::numeric_limits::quiet_NaN() : 7.0" + % (json.dumps(str(divergent_fault_marker)), divergent_owner) + ), + solve_count_statement="++state->solve_count;", + iterations_expression="state->solve_count", + extra_includes="#include ", ) + solved = " report->status = POPS_SOLVE_SOLVED_V2;" + collective = f''' if (std::filesystem::exists( + {json.dumps(str(collective_fault_marker))})) {{ + report->status = POPS_SOLVE_INVALID_EVALUATION_V2; + report->action = POPS_SOLVE_ACTION_FAIL_RUN_V2; + report->iterations = state->solve_count; + report->relative_residual = 1.0; + report->reference_residual_norm = 1.0; + report->residual_norm = 1.0; + report->reason = "forced collective MPI failure"; + return 0; + }} +{solved}''' + if source.count(solved) != 1: + raise AssertionError("test FieldSolver source no longer has one solved-report seam") + return source.replace(solved, collective) def _program(state, rate, field): @@ -277,6 +395,15 @@ def _program(state, rate, field): return program +def _moving_amr_program(state, rate, field): + from pops.lib.time import ForwardEuler + + program = ForwardEuler( + state, rate=rate, fields=field, solve_action=FailRun()) + program.step_strategy(FixedDt(8.0e-2)) + return program + + def test_external_field_pair_executes_and_reports_materialized_topology(tmp_path): topology = _component( tmp_path, name="topology", interface=interfaces.FieldTopology, @@ -299,6 +426,7 @@ def test_external_field_pair_executes_and_reports_materialized_topology(tmp_path simulation = pops.bind( artifact, initial_state={"material": np.ones((1, 8, 8), dtype=np.float64)}, + resources={"execution_context": artifact_execution_context(artifact)}, ) slot, = simulation.field_provider_slots() before = simulation.inspect().to_dict()["instance"]["field_providers"] @@ -340,15 +468,96 @@ def test_external_field_pair_executes_and_reports_materialized_topology(tmp_path assert simulation.inspect().to_dict()["instance"]["field_providers"] == providers -def test_external_field_solver_rejects_converged_nonfinite_solution_without_publishing( +def test_external_field_pair_executes_binary_coverage_across_amr_regrid(tmp_path): + topology = _component( + tmp_path, + name="amr-topology", + interface=interfaces.FieldTopology, + source_factory=lambda manifest: _topology_source( + manifest, require_multilevel=True, periodic_axes=0 + ), + ) + solver = _component( + tmp_path, + name="amr-solver", + interface=interfaces.FieldSolver, + source_factory=_solver_source, + manifest_parameters=({"name": "answer", "kind": "runtime"},), + instance_parameters={"answer": 7}, + ) + provider = ExternalFieldSolver( + topology=topology, + solver=solver, + relative_tolerance=1.0e-11, + absolute_tolerance=0.0, + max_iterations=23, + ) + model = scalar_advection_field_model("external-amr-field-runtime") + x_axis, y_axis = model.frame.axes + center_x, center_y = 0.25, 0.5 + background = 0.8 + amplitude = 4.0 + inverse_width = 80.0 + # A compact super-threshold region moves far enough to replace the fine layout at step 2. + resolved = resolve_periodic_field_program( + model, + _moving_amr_program, + name="external-amr-field-runtime", + block_name="material", + target="amr_system", + n=8, + regrid_every=2, + field_solver=provider, + initial_profile=Gaussian( + frame=model.frame, + center={x_axis: center_x, y_axis: center_y}, + background=background, + amplitude=amplitude, + inverse_width=inverse_width, + ), + components=(topology, solver), + anchored_field=True, + ) + + threshold, = ( + slot.handle for slot in resolved.bind_schema.runtime_slots + if slot.handle.local_id == "external-amr-field-runtime_refine_threshold" + ) + artifact = pops.compile(resolved) + simulation = pops.bind( + artifact, + params={threshold: 1.2}, + resources={"execution_context": artifact_execution_context(artifact)}, + ) + slot, = simulation.field_provider_slots() + providers = simulation.inspect().to_dict()["instance"]["field_providers"] + assert providers[0]["provider_slot"] == slot + assert providers[0]["solver_configuration"]["hierarchy_policy"]["policy_id"] == ( + "pops.field-hierarchy.composite" + ) + assert simulation.n_levels() == 2 + boxes_before = tuple(simulation.patch_boxes()) + regrids_before = simulation.amr.explain_regrid().regrid_count + report = pops.run(simulation, t_end=2.4e-1, max_steps=3) + assert report.accepted_steps == 3 + assert report.final_time == pytest.approx(2.4e-1) + assert simulation.amr.explain_regrid().regrid_count > regrids_before + assert tuple(simulation.patch_boxes()) != boxes_before + + +def test_real_prepared_field_solver_failure_rolls_back_runtime_instance_and_retries( tmp_path, ): + fault_marker = tmp_path / "external-field-solver-fault" + fault_marker.write_text("force a non-finite component result", encoding="utf-8") topology = _component( tmp_path, name="nonfinite-topology", interface=interfaces.FieldTopology, source_factory=_topology_source) solver = _component( tmp_path, name="nonfinite-solver", interface=interfaces.FieldSolver, - source_factory=_nonfinite_solver_source, + source_factory=lambda manifest: _externally_faulted_solver_source( + manifest, fault_marker + ), manifest_parameters=({"name": "answer", "kind": "runtime"},), instance_parameters={"answer": 7}) provider = ExternalFieldSolver( @@ -360,13 +569,41 @@ def test_external_field_solver_rejects_converged_nonfinite_solution_without_publ target="system", n=8, field_solver=provider, components=(topology, solver)) + artifact = pops.compile(resolved) simulation = pops.bind( - pops.compile(resolved), + artifact, initial_state={"material": np.ones((1, 8, 8), dtype=np.float64)}, + resources={"execution_context": artifact_execution_context(artifact)}, ) + communicator = simulation._install_plan.execution_context.communicator + if communicator.identity == "MPI_COMM_WORLD": + from pops._native_collectives import size + + assert size(communicator.handle) == 1 + assert simulation._publisher._size == 1 + assert not simulation.consumer_graph.nodes + entry_runtime_fence = simulation._failed_run_effect_fence() + entry_publisher_fence = simulation._publisher.failed_run_effect_fence() slot, = simulation.field_provider_slots() - before = np.asarray(simulation.field_potential_global(slot)).copy() - assert before.size == 64 and np.all(before == 0.0) + accepted_before = { + "time": simulation.time(), + "macro_step": simulation.macro_step(), + "state": np.asarray( + simulation.state_global("material"), dtype=np.float64 + ).copy(), + "potential": np.asarray( + simulation.field_potential_global(slot), dtype=np.float64 + ).copy(), + "cursors": simulation.consumer_cursors.to_data(), + "reports": tuple(simulation._consumer_reports), + "temporal": json.dumps( + simulation._executor._temporal_restart_state.to_data(), + sort_keys=True, + ), + "providers": simulation.inspect().to_dict()["instance"]["field_providers"], + } + assert accepted_before["potential"].size == 64 + assert np.all(accepted_before["potential"] == 0.0) with pytest.raises( RuntimeError, @@ -374,6 +611,63 @@ def test_external_field_solver_rejects_converged_nonfinite_solution_without_publ ): pops.run(simulation, t_end=1.0e-4, max_steps=1) - after = np.asarray(simulation.field_potential_global(slot)) - np.testing.assert_array_equal(after, before) + np.testing.assert_array_equal( + np.asarray(simulation.state_global("material"), dtype=np.float64), + accepted_before["state"], + ) + after = np.asarray(simulation.field_potential_global(slot), dtype=np.float64) + np.testing.assert_array_equal(after, accepted_before["potential"]) assert np.all(np.isfinite(after)) + assert simulation.time() == accepted_before["time"] + assert simulation.macro_step() == accepted_before["macro_step"] + assert simulation.consumer_cursors.to_data() == accepted_before["cursors"] + assert tuple(simulation._consumer_reports) == accepted_before["reports"] + assert json.dumps( + simulation._executor._temporal_restart_state.to_data(), + sort_keys=True, + ) == accepted_before["temporal"] + assert ( + simulation.inspect().to_dict()["instance"]["field_providers"] + == accepted_before["providers"] + ) + failed = simulation._executor._last_step_transaction_report + assert (failed.status, failed.phase, failed.action) == ( + "failed", + "solve", + "fail_run", + ) + assert failed.committed_effects == () + assert failed.staged_effects + assert failed.rolled_back_effects == failed.staged_effects + failed_identity = simulation.last_run_identity + assert failed_identity.domain == "run" + assert simulation._failed_run_effect_fence() == entry_runtime_fence + assert simulation._publisher.failed_run_effect_fence() == entry_publisher_fence + assert failed_identity.token not in simulation._publisher._closed_observer_runs + assert failed_identity.token not in simulation._publisher._observer_run_phases + + assert fault_marker.is_file() + fault_marker.unlink() + retry = pops.run(simulation, t_end=1.0e-4, max_steps=1) + assert retry.accepted_steps == 1 + assert retry.run_identity == failed_identity + assert simulation.last_run_identity == failed_identity + assert simulation.time() == 1.0e-4 + assert simulation.macro_step() == 1 + np.testing.assert_array_equal( + np.asarray(simulation.state_global("material"), dtype=np.float64), + accepted_before["state"], + ) + potential = np.asarray(simulation.field_potential_global(slot), dtype=np.float64) + assert potential.size == 64 + assert np.all(np.isfinite(potential)) + assert np.all(potential == 0.0) + accepted = simulation._executor._last_step_transaction_report + assert (accepted.status, accepted.phase, accepted.action) == ( + "accepted", + "commit", + "commit", + ) + assert accepted.staged_effects + assert accepted.committed_effects == accepted.staged_effects + assert accepted.rolled_back_effects == () diff --git a/tests/python/integration/native_loader/test_external_interface_backend.py b/tests/python/integration/native_loader/test_external_interface_backend.py index 07617eaa9..cba2ff1bb 100644 --- a/tests/python/integration/native_loader/test_external_interface_backend.py +++ b/tests/python/integration/native_loader/test_external_interface_backend.py @@ -5,23 +5,64 @@ import json from pathlib import Path +import pytest + from pops import interfaces from pops import _generated_component_interfaces as generated +from pops.model import ComponentManifest def test_all_required_native_families_are_generated_data_only_contracts(): expected = { - "numerical_flux", "ghost_boundary", "field_boundary_closure", "tagger", - "clustering", "transfer", "field_solver", "writer", "field_topology", + "numerical_flux", "ghost_boundary", "boundary_flux", "field_boundary_closure", "tagger", + "clustering", "transfer", "reflux", "field_solver", "writer", "field_topology", } resolved = {name: interfaces.resolve(name) for name in expected} assert set(resolved) == expected assert len({value.abi_id for value in resolved.values()}) == len(expected) + assert {name: value.abi_id for name, value in resolved.items()} == { + "numerical_flux": 0, + "ghost_boundary": 1, + "field_boundary_closure": 2, + "tagger": 3, + "clustering": 4, + "transfer": 5, + "reflux": 6, + "field_solver": 7, + "writer": 8, + "field_topology": 9, + "boundary_flux": 10, + } assert all(value.table_symbol == "pops_component_interface_v1" for value in resolved.values()) assert all(value.operations for value in resolved.values()) +def test_reflux_is_exact_generated_id_6_and_incomplete_conformer_is_refused(): + interface = interfaces.Reflux + assert interface.abi_id == 6 + assert interface.uri == "pops://interfaces/reflux" + assert interface.cpp_table == "PopsRefluxApiV1" + assert interface.operations == ("apply_interface_batch",) + + incomplete_signature = interface.signature_declaration() + incomplete_signature["operations"] = () + manifest = ComponentManifest( + uri="pops://external.test/reflux/incomplete", + component_type="reflux", + version="1.0.0", + facets=interface.facets, + signature={"native_interface": incomplete_signature}, + interfaces=interface.manifest_declarations(), + target={"variants": [{ + "dimension": 2, "scalar": "float64", "device": "cpu", "features": [], + }]}, + entry_points={"interface_table": "pops_component_interface_v1"}, + ) + with pytest.raises(ValueError, match="does not carry the generated native interface identity"): + interface.require_manifest(manifest) + + def test_python_native_component_boundary_has_no_ffi_or_test_owned_backend(): root = Path(__file__).resolve().parents[4] production = ( @@ -95,6 +136,7 @@ def test_boundary_handle_native_routes_are_generated_from_exact_interfaces(): "corner_resolver": ("ghost_boundary", "apply_region_batch"), "numerical_closure": ("ghost_boundary", "apply_region_batch"), "conservative_flux": ("numerical_flux", "evaluate_faces"), + "boundary_flux_provider": ("boundary_flux", "transform_faces"), "residual_operator": ("field_boundary_closure", "residual"), "linearization_operator": ("field_boundary_closure", "jvp"), } diff --git a/tests/python/integration/native_loader/test_normalized_program_execution.py b/tests/python/integration/native_loader/test_normalized_program_execution.py index d0e7ca6fc..2bcc69a70 100644 --- a/tests/python/integration/native_loader/test_normalized_program_execution.py +++ b/tests/python/integration/native_loader/test_normalized_program_execution.py @@ -53,8 +53,7 @@ def _require_native() -> None: def _authoring() -> tuple[Any, Any, Any, Any]: model = Model("normalized-execution-model") (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/native_loader/test_prepared_krylov_method_component.py b/tests/python/integration/native_loader/test_prepared_krylov_method_component.py index 69f695d1a..ea4bd0a41 100644 --- a/tests/python/integration/native_loader/test_prepared_krylov_method_component.py +++ b/tests/python/integration/native_loader/test_prepared_krylov_method_component.py @@ -34,8 +34,7 @@ def _passive_model(name: str): model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/native_loader/test_prepared_nullspace_component.py b/tests/python/integration/native_loader/test_prepared_nullspace_component.py index b3cb24f68..1b65103c8 100644 --- a/tests/python/integration/native_loader/test_prepared_nullspace_component.py +++ b/tests/python/integration/native_loader/test_prepared_nullspace_component.py @@ -34,8 +34,7 @@ def _passive_model(name: str): model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/native_loader/test_prepared_preconditioner_component.py b/tests/python/integration/native_loader/test_prepared_preconditioner_component.py index 86e69580a..cd0b4c11e 100644 --- a/tests/python/integration/native_loader/test_prepared_preconditioner_component.py +++ b/tests/python/integration/native_loader/test_prepared_preconditioner_component.py @@ -34,8 +34,7 @@ def _passive_model(name: str): model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py new file mode 100644 index 000000000..39e665402 --- /dev/null +++ b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py @@ -0,0 +1,108 @@ +"""An axial component survives the public compile-to-bind boundary pipeline.""" + +from __future__ import annotations + +import numpy as np +import pops +import pops.lib.time as libtime +import pytest +from pops.boundary import TransportBoundarySet +from pops.boundary.transport import SlipWall +from pops.domain import Rectangle +from pops.frames import Cartesian2D, Z_AXIS +from pops.layouts import Uniform +from pops.math import ddt, div +from pops.mesh import CartesianGrid +from pops.numerics import DiscretizationPlan, FiniteVolume, reconstruction, riemann, variables +from pops.physics import Axial, Density, Momentum +from pops.representations import Conservative +from pops.spaces import CellState +from pops.time import FixedDt +from tests.python.support.native_execution_context import artifact_execution_context + + +pytestmark = [pytest.mark.compiler, pytest.mark.native_loader] + + +def _axial_wall_case() -> tuple[pops.Case, Uniform]: + frame = Rectangle( + "axial-wall-square", lower=(0.0, 0.0), upper=(1.0, 1.0) + ).frame(Cartesian2D()) + x_axis, y_axis = frame.axes + model = pops.Model("axial-wall-model", frame=frame) + state = model.state( + "U", + components=("rho", "mx", "my", "bz"), + representation=Conservative(), + space=CellState(frame=frame), + roles={ + "rho": Density(), + "mx": Momentum(axis=x_axis), + "my": Momentum(axis=y_axis), + "bz": Axial(axis=Z_AXIS), + }, + ) + rho, mx, my, bz = state + flux = model.flux( + "identity-flux", + frame=frame, + state=state, + components={ + x_axis: (rho, mx, my, bz), + y_axis: (rho, mx, my, bz), + }, + waves={ + x_axis: (1.0, 1.0, 1.0, 1.0), + y_axis: (1.0, 1.0, 1.0, 1.0), + }, + ) + rate = model.rate("transport", equation=ddt(state) == -div(flux)) + numerics = DiscretizationPlan() + numerics.rates.add( + rate, + FiniteVolume( + flux=flux, + variables=variables.Conservative(state), + reconstruction=reconstruction.FirstOrder(), + riemann=riemann.Rusanov(), + ), + ) + + case = pops.Case("axial-slip-wall-pipeline") + block = case.block("fluid", model=model) + numerics.boundaries.add( + TransportBoundarySet( + { + boundary: SlipWall(state=block[state]) + for boundary in frame.boundaries.all + } + ) + ) + case.numerics(numerics, block=block) + program = libtime.ForwardEuler(block[state], rate=rate) + program.step_strategy(FixedDt(0.01)) + case.program(program) + return case, Uniform(CartesianGrid(frame=frame, cells=(4, 4))) + + +def test_axial_role_compiles_binds_and_round_trips_native_metadata( + isolated_native_cache, native_cxx, kokkos_root, +) -> None: + del isolated_native_cache, native_cxx, kokkos_root + case, layout = _axial_wall_case() + artifact = pops.compile(pops.resolve(pops.validate(case), layout=layout)) + initial = np.ones((4, 4, 4), dtype=np.float64) + runtime = pops.bind( + artifact, + initial_state={"fluid": initial}, + resources={"execution_context": artifact_execution_context(artifact)}, + ) + + assert list(runtime._executor._s.variable_roles("fluid", "conservative")) == [ + "density", + "momentum_x", + "momentum_y", + "axial_z", + ] + installed = runtime._executor._boundary_authorities["fluid"] + assert [face["type"] for face in installed["faces"]] == ["slip_wall"] * 4 diff --git a/tests/python/integration/runtime/test_multi_layout_runtime.py b/tests/python/integration/runtime/test_multi_layout_runtime.py index 48a72f56c..f1a165473 100644 --- a/tests/python/integration/runtime/test_multi_layout_runtime.py +++ b/tests/python/integration/runtime/test_multi_layout_runtime.py @@ -372,6 +372,168 @@ def __getattr__(self, name): assert len(inspection.instance["installed_components"]) == 1 +def test_uniform_amr_and_multi_layout_share_complete_runtime_instance_contract( + compiled_multi_layout, +): + from pops.runtime._runtime_instance import RuntimeInstance + from tests.python.integration.runtime.test_dsl_runtime_params import ( + DT as SINGLE_LAYOUT_DT, + _resolved_analytic_initial_parameter_case, + ) + + executions = [] + for label, target in (("uniform", "system"), ("amr", "amr_system")): + resolved, amplitude = _resolved_analytic_initial_parameter_case( + target=target + ) + artifact = pops.compile(resolved) + runtime = pops.bind( + artifact, + params={amplitude: 1.0}, + resources={"execution_context": artifact_execution_context(artifact)}, + ) + report = pops.run( + runtime, + t_end=SINGLE_LAYOUT_DT, + max_steps=1, + console=False, + ) + executions.append((label, runtime, artifact, report)) + + ( + multi, + multi_artifact, + coarse_layout_id, + fine_layout_id, + mapping_id, + _u_fine, + _u_coarse, + ) = _bind( + compiled_multi_layout + ) + multi_report = pops.run( + multi, + t_end=DT, + max_steps=1, + console=False, + ) + executions.append(("multi-layout", multi, multi_artifact, multi_report)) + + expected_layout_counts = {"uniform": 1, "amr": 1, "multi-layout": 2} + expected_level_counts = {"uniform": 1, "amr": 2, "multi-layout": 1} + expected_runtime_kinds = { + "uniform": "uniform", + "amr": "adaptive", + "multi-layout": "uniform", + } + expected_final_times = { + "uniform": SINGLE_LAYOUT_DT, + "amr": SINGLE_LAYOUT_DT, + "multi-layout": DT, + } + schemas = set() + + for label, runtime, artifact, report in executions: + assert type(runtime) is RuntimeInstance + assert type(report) is pops.RunReport + assert report.accepted_steps == 1 + assert report.rejected_steps == 0 + assert report.final_time == expected_final_times[label] + assert report.final_macro_step == 1 + assert report.stop_reason is pops.RunStopReason.TARGET_TIME_REACHED + assert report.run_identity == runtime.last_run_identity + assert report.bind_identity == runtime.bind_identity + assert report.execution_identity == runtime._execution_context.identity + assert report.artifact_identity == artifact.artifact_identity + assert report.artifact_identity == runtime.bound_snapshot.artifact_identity + assert report.field_providers == () + assert runtime.time() == report.final_time + assert runtime.macro_step() == report.final_macro_step + assert runtime.n_levels() == expected_level_counts[label] + + inspection = runtime.inspect().to_dict() + instance = inspection["instance"] + program = runtime.program_report().to_dict() + report_data = report.to_data() + assert inspection["runtime"] == expected_runtime_kinds[label] + assert inspection["clock"] == { + "time": runtime.time(), + "macro_step": runtime.macro_step(), + } + assert inspection["blocks"] == list(runtime.block_names()) + assert inspection["bound_snapshot"] == runtime.bound_snapshot.to_dict() + assert instance["bind_identity"] == runtime.bind_identity.to_data() + assert instance["artifact_identity"] == artifact.artifact_identity.to_data() + assert instance["consumer_graph"] == runtime.consumer_graph.to_data() + assert instance["consumer_cursors"] == runtime.consumer_cursors.to_data() + assert instance["last_run_identity"] == report.run_identity.to_data() + assert len(instance["layout_plan"]["layouts"]) == expected_layout_counts[label] + assert program["installed"] is True + assert program["program_hash"] == runtime.installed_program_hash() + assert len(program["block_map"]) == len(runtime.block_names()) + assert tuple(sorted(program["block_map"])) == tuple( + range(len(runtime.block_names())) + ) + assert all( + type(row["count"]) is int and 0 <= row["count"] <= row["limit"] + for row in program["params"] + ) + assert inspection["program"]["installed"] == program["installed"] + assert inspection["program"]["hash"] == program["program_hash"] + for name in ( + "step_transaction", + "block_map", + "params", + "diagnostics", + "histories", + "cache", + "profiler", + "clocks", + "level_relations", + "flux_ledger", + "synchronization", + "temporal", + ): + assert inspection["program"][name] == program[name] + assert all( + np.isfinite(runtime.integral(block)) + for block in runtime.block_names() + ) + + native_transaction = runtime._executor._last_step_transaction_report + assert ( + native_transaction.status, + native_transaction.phase, + native_transaction.action, + ) == ("accepted", "commit", "commit") + assert native_transaction.staged_effects + assert ( + native_transaction.committed_effects + == native_transaction.staged_effects + ) + assert native_transaction.rolled_back_effects == () + schemas.add( + ( + tuple(sorted(report_data)), + tuple(sorted(inspection)), + tuple(sorted(instance)), + tuple(sorted(program)), + ) + ) + + assert len(schemas) == 1 + multi_program = multi.program_report() + assert len(multi_program.program_hash) == 64 + assert tuple(sorted(multi_program.block_map)) == (0, 1) + assert {row["program_block"] for row in multi_program.params} == {0, 1} + assert {row["block"] for row in multi_program.params} == {"coarse", "tracer"} + assert {row["layout_id"] for row in multi_program.params} == { + coarse_layout_id, + fine_layout_id, + } + assert multi._executor.mapping_report() == {mapping_id: 1} + + def test_multi_layout_checkpoint_restart_restores_every_layout_and_mapping_count( compiled_multi_layout, tmp_path ): diff --git a/tests/python/integration/runtime/test_multiblock_implicit_phase.py b/tests/python/integration/runtime/test_multiblock_implicit_phase.py index 9c4aa3bb1..84a598000 100644 --- a/tests/python/integration/runtime/test_multiblock_implicit_phase.py +++ b/tests/python/integration/runtime/test_multiblock_implicit_phase.py @@ -23,6 +23,7 @@ from pops.projection import ConservativeCellAverage from pops.solvers import LocalNewton from pops.time import CoupledImplicitEuler, FixedDt, RejectAttempt +from tests.python.support.native_execution_context import artifact_execution_context from tests.python.support.requirements import repo_include @@ -273,7 +274,10 @@ def test_generated_native_multiblock_implicit_phase_uses_exact_name_routes( assert 'ctx.require_cartesian_generated_operator(0, "named_source");' in generated assert 'ctx.require_cartesian_generated_operator(1, "named_source");' in generated - simulation = pops.bind(artifact) + simulation = pops.bind( + artifact, + resources={"execution_context": artifact_execution_context(artifact)}, + ) pops.run(simulation, t_end=DT, max_steps=1) electron = np.asarray(simulation.get_state("electrons")) ion = np.asarray(simulation.get_state("ions")) diff --git a/tests/python/integration/runtime/test_shared_interface_runtime.py b/tests/python/integration/runtime/test_shared_interface_runtime.py index 738f1f182..14fa3615f 100644 --- a/tests/python/integration/runtime/test_shared_interface_runtime.py +++ b/tests/python/integration/runtime/test_shared_interface_runtime.py @@ -4,7 +4,9 @@ import importlib.util import json from pathlib import Path +import re import sys +from types import SimpleNamespace import numpy as np import pops @@ -12,6 +14,7 @@ from pops import interfaces from pops.external import build_source_package_manifest, compile_component, load +from pops.linalg import LinearOperatorProperties, LinearProblem from pops.mesh import CartesianGrid from pops.mesh.boundaries import ( BlockInterfaceSide, @@ -20,8 +23,10 @@ from pops.model import ComponentManifest from pops.numerics import DiscretizationPlan, reconstruction, riemann, variables from pops.numerics.spatial import FiniteVolume +from pops.numerics.terms import Flux from pops.output import Checkpoint, ConsumerGraph, RegridOnRestart -from pops.time import FixedDt, StagePoint, TimePoint, every +from pops.solvers import GMRES +from pops.time import FailRun, FixedDt, StagePoint, TimePoint, every ROOT = Path(__file__).resolve().parents[4] @@ -37,7 +42,8 @@ def _load_example(): return module -def _flux_component(tmp_path: Path): +def _flux_source_component(tmp_path: Path): + tmp_path.mkdir(parents=True, exist_ok=True) interface = interfaces.NumericalFlux manifest = ComponentManifest( uri="pops://external.test/shared-interface/average", @@ -134,9 +140,15 @@ def _flux_component(tmp_path: Path): components={"average": manifest}, payloads={source_name: ("source", source)}) package_path = tmp_path / "shared-average.pops.json" package_path.write_text(json.dumps(package), encoding="utf-8") - component = load(package_path).require( + return load(package_path).require( "average", interface=interfaces.NumericalFlux)() - return compile_component(component, include=str(ROOT / "include")) + + +def _flux_component(tmp_path: Path): + return compile_component( + _flux_source_component(tmp_path), + include=str(ROOT / "include"), + ) def _program(left_state, right_state, rate): @@ -192,6 +204,51 @@ def _ssprk2_program(left_state, right_state, rate): return program +def _implicit_pair_program(left_state, right_state, rate, packed_state=None): + del rate + program = pops.Program("shared_interface_implicit_pair") + left = program.state(left_state) + right = program.state(right_state) + stage = StagePoint("shared_implicit_stage", {"main": TimePoint(program.clock, 0)}) + left_iterate = program.value("left_iterate", left.n, at=stage) + right_iterate = program.value("right_iterate", right.n, at=stage) + left_r0 = program.rhs("left_r0", state=left_iterate, terms=(Flux(),)) + right_r0 = program.rhs("right_r0", state=right_iterate, terms=(Flux(),)) + operator = program.matrix_free_operator( + "shared_interface_jacobian", domain="state", range_="state", ncomp=2 + ) + + def apply(builder, out, direction): + builder.rhs_jacvec( + out, direction, iterate=left_iterate, r0=left_r0, c_dt=builder.dt, + sources=(), field_coupled=False, + ) + return builder.rhs_jacvec( + out, direction, iterate=right_iterate, r0=right_r0, c_dt=builder.dt, + sources=(), field_coupled=False, + ) + + program.set_apply(operator, apply) + if packed_state is not None: + packed = program.state(packed_state) + program.solve( + LinearProblem( + operator, + packed.n, + properties=LinearOperatorProperties.general(), + nullspace=None, + ), + solver=GMRES(max_iter=8, restart=4, rel_tol=1.0e-12), + name="shared_interface_correction", + ).consume(action=FailRun()) + left_next = program.value("left_next", left.n + program.dt * left_r0, at=left.next.point) + right_next = program.value("right_next", right.n + program.dt * right_r0, at=right.next.point) + program.commit(left.next, left_next) + program.commit(right.next, right_next) + program.step_strategy(FixedDt(1.0e-3)) + return program + + def _shared_interface_accepted_image(runtime): native = runtime._executor._s levels = int(runtime.n_levels()) @@ -338,12 +395,16 @@ def numerics(state): ) -def test_runtime_instance_executes_dynamic_three_level_shared_flux(tmp_path, monkeypatch): +def _shared_interface_amr_authoring( + tmp_path, + *, + component_root=None, + component=None, + program_factory=_ssprk2_program, + with_checkpoint=True, + with_implicit_solve=False, +): from pops.amr import ( - AMRClockRelation, - AMRExecution, - AMRHierarchy, - AMRRegrid, AMRTagging, AMRTransfer, Buffer, @@ -355,11 +416,12 @@ def test_runtime_instance_executes_dynamic_three_level_shared_flux(tmp_path, mon from pops.boundary import TransportBoundarySet from pops.boundary.transport import Inflow, Outflow from pops.initial import InitialCondition - from pops.layouts import AMR from pops.lib.amr import StateTransfer from pops.lib.initial import BindArray - from pops.math import ValueExpr + from pops.math import ValueExpr, ddt, div from pops.projection import ConservativeCellAverage + from pops.representations import Conservative + from pops.spaces import CellState example = _load_example() core = example.build_authoring(output_root=tmp_path / "unused") @@ -386,7 +448,10 @@ def numerics(state): left_numerics = numerics(core.tracer_state) right_numerics = numerics(right_state) - component = _flux_component(tmp_path) + component_root = tmp_path if component_root is None else Path(component_root) + component_root.mkdir(parents=True, exist_ok=True) + if component is None: + component = _flux_component(component_root) ConservativeInterface( "tracer_to_right", left=BlockInterfaceSide(core.tracer_state, boundaries.x_max), @@ -407,23 +472,86 @@ def numerics(state): value=BindArray(), projection=ConservativeCellAverage(), )) - program = _ssprk2_program(core.tracer_state, right_state, core.rate) + packed_state = None + packed_initial = None + if with_implicit_solve: + packed_model = pops.Model("shared_interface_packed_vector", frame=core.frame) + packed = packed_model.state( + "U", + components=("left_direction", "right_direction"), + representation=Conservative(), + space=CellState(frame=core.frame), + ) + left_direction, right_direction = packed + zero_flux = packed_model.flux( + "zero_packed_transport", + frame=core.frame, + state=packed, + components={ + axis: (0.0 * left_direction, 0.0 * right_direction) + for axis in core.frame.axes + }, + waves={axis: (0.0, 0.0) for axis in core.frame.axes}, + ) + packed_rate = packed_model.rate( + "zero_packed_rate", equation=ddt(packed) == -div(zero_flux) + ) + packed_block = core.case.block( + "implicit_vector", model=packed_model, states=(packed,) + ) + packed_state = packed_block[packed] + packed_numerics = DiscretizationPlan() + packed_numerics.rates.add( + packed_rate, + FiniteVolume( + flux=zero_flux, + variables=variables.Conservative(packed), + reconstruction=reconstruction.FirstOrder(), + riemann=riemann.Rusanov(), + ), + ) + packed_numerics.boundaries.add(TransportBoundarySet({ + boundary: Outflow(state=packed_state) + for boundary in ( + boundaries.x_min, + boundaries.x_max, + boundaries.y_min, + boundaries.y_max, + ) + })) + core.case.numerics(packed_numerics, block=packed_block) + core.case.initials.add(InitialCondition( + state=packed_state, + value=BindArray(), + projection=ConservativeCellAverage(), + )) + packed_initial = np.empty((2, 8, 8), dtype=np.float64) + packed_initial[0, :, :] = 0.25 + packed_initial[1, :, :] = -0.125 + program = program_factory( + core.tracer_state, right_state, core.rate, packed_state + ) + else: + program = program_factory(core.tracer_state, right_state, core.rate) core.case.program(program) - core.case.consumers( - ConsumerGraph.from_consumers( - ( - Checkpoint( - schedule=every(10_000, clock=program.clock), - target="unused/shared-interface-restart", - hierarchy=RegridOnRestart(), - ), + if with_checkpoint: + core.case.consumers( + ConsumerGraph.from_consumers( + ( + Checkpoint( + schedule=every(10_000, clock=program.clock), + target="unused/shared-interface-restart", + hierarchy=RegridOnRestart(), + ), + ) ) ) - ) transfer = AMRTransfer() transfer.state(core.tracer_state, StateTransfer()) transfer.state(right_state, StateTransfer()) + if packed_state is not None: + transfer.state(packed_state, StateTransfer()) tagging = AMRTagging( rules=( Tag(ValueExpr(core.tracer_state) > core.case.value(core.refine_threshold)), @@ -433,23 +561,6 @@ def numerics(state): hysteresis=Hysteresis(min_cycles=0, equality=EqualityPolicy.HOLD), conflict_policy=ConflictPolicy.REFINE_WINS, ) - resolved = pops.resolve( - pops.validate(core.case), - layout=AMR( - grid=CartesianGrid(frame=core.frame, cells=(8, 8)), - hierarchy=AMRHierarchy(max_levels=3, ratios=(2, 2)), - tagging=tagging, - regrid=AMRRegrid(schedule=every(100, clock=program.clock)), - transfer=transfer, - execution=AMRExecution.subcycled(( - AMRClockRelation(0, 1, 2), - AMRClockRelation(1, 2, 2), - )), - ), - components=(component,), - compile_options={"include": str(ROOT / "include")}, - ) - artifact = pops.compile(resolved) left_initial = np.zeros((1, 8, 8), dtype=np.float64) right_initial = np.zeros((1, 8, 8), dtype=np.float64) # The first public refined route requires an already matched fine interface: refine one @@ -458,7 +569,7 @@ def numerics(state): # implied by this proof. left_initial[0, :, -1:] = 1.0 # Keep the two traces distinct: the shared component must publish its average flux to both - # consumers. Equal traces would let a one-sided publication pass by coincidence. + # consumers. Equal traces would let a one-sided publication pass by coincidence. right_initial[0, :, :1] = 3.0 params = { core.case.resolve(handle, block=block): value @@ -474,7 +585,154 @@ def numerics(state): core.case.resolve(core.refine_threshold): 0.10, core.case.resolve(core.coarsen_threshold): 0.04, }) + return SimpleNamespace( + example=example, + core=core, + right=right, + right_state=right_state, + component=component, + program=program, + transfer=transfer, + tagging=tagging, + left_initial=left_initial, + right_initial=right_initial, + packed_state=packed_state, + packed_initial=packed_initial, + params=params, + ) + + +def _resolve_shared_interface_amr( + authoring, *, max_levels, patch_layout=None, frozen=False +): + from pops.amr import ( + AMRClockRelation, + AMRExecution, + AMRHierarchy, + AMRRegrid, + ) + from pops.layouts import AMR + + if not isinstance(max_levels, int) or max_levels < 2: + raise ValueError("shared-interface AMR proof requires at least two levels") + return pops.resolve( + pops.validate(authoring.core.case), + layout=AMR( + grid=CartesianGrid(frame=authoring.core.frame, cells=(8, 8)), + hierarchy=AMRHierarchy( + max_levels=max_levels, + ratios=tuple(2 for _ in range(max_levels - 1)), + ), + tagging=authoring.tagging, + regrid=( + AMRRegrid.frozen() if frozen else + AMRRegrid(schedule=every(100, clock=authoring.program.clock)) + ), + transfer=authoring.transfer, + execution=AMRExecution.subcycled( + tuple( + AMRClockRelation(level, level + 1, 2) + for level in range(max_levels - 1) + ) + ), + patch_layout=patch_layout, + ), + components=(authoring.component,), + compile_options={"include": str(ROOT / "include")}, + ) + + +def test_frozen_two_level_shared_interface_implicit_pair_compiles_native_route(tmp_path): + authoring = _shared_interface_amr_authoring( + tmp_path, + program_factory=_implicit_pair_program, + with_checkpoint=False, + ) + resolved = _resolve_shared_interface_amr(authoring, max_levels=2, frozen=True) + assert resolved.resolved_hierarchy.plan.level_count == 2 + assert resolved.capabilities["shared_interfaces"] == { + "implicit_jacvec_pair": True, + } + from pops.codegen._shared_interface_evidence import ( + _ResolvedSharedInterfaceCodegenEvidence, + _issue_shared_interface_codegen_evidence, + ) + + with pytest.raises( + TypeError, match="issued only from an exact resolved plan" + ): + _ResolvedSharedInterfaceCodegenEvidence() + evidence = _issue_shared_interface_codegen_evidence(resolved) + assert type(evidence) is _ResolvedSharedInterfaceCodegenEvidence + with pytest.raises(ValueError, match="belongs to another Program graph"): + evidence.require(pops.Program("foreign_program"), target="amr_system") + + artifact = pops.compile(resolved) + + assert artifact.target == "amr_system" + assert artifact.program is not None + generated_path = artifact.program.dump_cpp(tmp_path / "implicit_pair.cpp") + source = Path(generated_path).read_text(encoding="utf-8") + assert source.count("ctx.rhs_jacvec_pair_into_at(") == 1 + assert source.count("ctx.copy_component_span(") >= 7 + assert "ctx.rhs_core_into_at(" not in source + assert "PreparedOperatorConcurrency::Exclusive" in source + group_identity = re.search(r"ctx\.rhs_group\((\d+),", source) + assert group_identity is not None + left_r0 = next( + value for value in resolved.time._values if value.name == "left_r0" + ) + from pops.codegen.program_emit_solve import _rhs_evaluation_identity + + assert str(_rhs_evaluation_identity(resolved.time, left_r0)) == group_identity.group(1) + + +def test_frozen_two_level_generated_program_executes_shared_interface_implicit_pair(tmp_path): + authoring = _shared_interface_amr_authoring( + tmp_path, + program_factory=_implicit_pair_program, + with_checkpoint=False, + with_implicit_solve=True, + ) + resolved = _resolve_shared_interface_amr(authoring, max_levels=2, frozen=True) + artifact = pops.compile(resolved) interface = resolved.blocks[0].numerics.boundaries[0].interfaces[0] + runtime = authoring.example._bind_artifact( + artifact, + initial_values={ + authoring.core.tracer_state: authoring.left_initial, + authoring.right_state: authoring.right_initial, + authoring.packed_state: authoring.packed_initial, + }, + params=authoring.params, + ) + + assert runtime.n_levels() == 2 + initial_packed = np.asarray(runtime.get_state("implicit_vector")).copy() + report = pops.run(runtime, t_end=1.0e-3, max_steps=1, console=False) + + assert report.accepted_steps == 1 + for level in range(2): + assert runtime._executor._s._interface_evaluation_count( + interface.qualified_id, level + ) > 1 + solved_packed = np.asarray(runtime.get_state("implicit_vector")) + assert np.isfinite(solved_packed).all() + np.testing.assert_array_equal(solved_packed, initial_packed) + + +def test_runtime_instance_executes_dynamic_three_level_shared_flux(tmp_path, monkeypatch): + authoring = _shared_interface_amr_authoring(tmp_path) + example = authoring.example + core = authoring.core + right_state = authoring.right_state + left_initial = authoring.left_initial + right_initial = authoring.right_initial + params = authoring.params + resolved = _resolve_shared_interface_amr(authoring, max_levels=3) + artifact = pops.compile(resolved) + interface = resolved.blocks[0].numerics.boundaries[0].interfaces[0] + # Dynamic shared interfaces cannot create a missing route after bind: the complete configured # prefix must already be materialized by the authenticated bootstrap transaction. with pytest.raises( @@ -491,7 +749,7 @@ def numerics(state): # A shared hierarchy does not imply that one endpoint's boundary tags are mirrored to its peer. # With only the left x-high band tagged, the materialized L1 layout cannot tile the right x-low - # face. The incremental finalizer must reject that incomplete pair before bind freezes. + # face. The incremental finalizer must reject that incomplete pair before bind freezes. with pytest.raises(ValueError, match="does not tile its declared physical face"): example._bind_artifact( artifact, @@ -551,19 +809,7 @@ def numerics(state): # The three-level route above proves arbitrary-depth execution. Use the independently compiled # two-level route for the restart transaction: replacing its only fine transition is the exact # dynamic topology capability currently authenticated by the interface scheduler. - restart_resolved = pops.resolve( - pops.validate(core.case), - layout=AMR( - grid=CartesianGrid(frame=core.frame, cells=(8, 8)), - hierarchy=AMRHierarchy(max_levels=2, ratios=(2,)), - tagging=tagging, - regrid=AMRRegrid(schedule=every(100, clock=program.clock)), - transfer=transfer, - execution=AMRExecution.subcycled((AMRClockRelation(0, 1, 2),)), - ), - components=(component,), - compile_options={"include": str(ROOT / "include")}, - ) + restart_resolved = _resolve_shared_interface_amr(authoring, max_levels=2) restart_artifact = pops.compile(restart_resolved) restart_interface = restart_resolved.blocks[0].numerics.boundaries[0].interfaces[0] restart_source = example._bind_artifact( @@ -601,9 +847,9 @@ def numerics(state): ) checkpoint = restart_source.checkpoint(tmp_path / "accepted-shared-interface") - # RegridOnRestart must enter the serial native tag/cluster/regrid boundary; a deliberately - # rejected post-transform validation must restore the fresh runtime exactly before the same - # restart is retried and committed. + # RegridOnRestart enters the native tag/cluster/regrid boundary. A deliberately rejected + # post-transform validation must restore the fresh runtime exactly before the same restart is + # retried and committed. restarted = example._bind_artifact( restart_artifact, initial_values={ diff --git a/tests/python/support/explicit_program.py b/tests/python/support/explicit_program.py index b27301ab2..97fb4c1d1 100644 --- a/tests/python/support/explicit_program.py +++ b/tests/python/support/explicit_program.py @@ -10,6 +10,7 @@ Projection and coupled-source splitting are opt-in by block identity/registration; implicit-solve tests still need purpose-built Program primitives. """ + from __future__ import annotations import hashlib @@ -74,21 +75,15 @@ def _empty_module_metadata_exports() -> str: return ( 'extern "C" int pops_module_operator_count() { return 0; }\n' 'extern "C" int pops_module_state_space_count() { return 0; }\n' - 'extern "C" int pops_module_field_space_count() { return 0; }\n' - + string_exports + 'extern "C" int pops_module_field_space_count() { return 0; }\n' + string_exports ) def _coupling_application(block_count: int, enabled: bool) -> str: if not enabled: return "" - candidates = [ - " {%d, &next_%d}" % (block, block) - for block in range(block_count) - ] - return " ctx.apply_coupling_operators(dt, {\n%s\n });" % ",\n".join( - candidates - ) + candidates = [" {%d, &next_%d}" % (block, block) for block in range(block_count)] + return " ctx.apply_coupling_operators(dt, {\n%s\n });" % ",\n".join(candidates) def _forward_euler_body( @@ -111,8 +106,7 @@ def _forward_euler_body( ) ) requests.append( - " {%d, &state_%d, &rhs_%d, %d, 0}" - % (block, block, block, 3000 + block) + " {%d, &state_%d, &rhs_%d, %d, 0}" % (block, block, block, 3000 + block) ) combinations.extend( ( @@ -128,7 +122,7 @@ def _forward_euler_body( return "\n".join( ( " ctx.set_stage_time(0, 1);", - " (void)pops::consume_solve_outcome(ctx.solve_fields());", + " (void)pops::consume_solve_outcome(ctx.solve_fields());", *declarations, " ctx.rhs_group(4000, {\n%s\n });" % ",\n".join(requests), *combinations, @@ -174,8 +168,7 @@ def _rhs_stage( return [ " ctx.set_stage_time(%d, %d);" % (numerator, denominator), *declarations, - " ctx.rhs_group(%d, {\n%s\n });" - % (40000 + stage, ",\n".join(requests)), + " ctx.rhs_group(%d, {\n%s\n });" % (40000 + stage, ",\n".join(requests)), ] @@ -186,13 +179,9 @@ def _projection_and_commit( apply_couplings: bool, ) -> list[str]: projections = [ - " ctx.apply_projection(%d, next_%d);" % (block, block) - for block in projection_indices - ] - commits = [ - " {&state_%d, &next_%d}" % (block, block) - for block in range(block_count) + " ctx.apply_projection(%d, next_%d);" % (block, block) for block in projection_indices ] + commits = [" {&state_%d, &next_%d}" % (block, block) for block in range(block_count)] return [ _coupling_application(block_count, apply_couplings), *projections, @@ -212,8 +201,7 @@ def _ssprk2_body( stage_one.extend( ( " pops::MultiFab& stage1_%d = " - "ctx.scratch_state(%d, 0, state_%d);" - % (block, 20000 + block, block), + "ctx.scratch_state(%d, 0, state_%d);" % (block, 20000 + block, block), " ctx.lincomb(stage1_%d, pops::Real(1), state_%d, dt, rhs_0_%d, dt, " "{{0, 1, 1}}, {{1, 1, 1}});" % (block, block, block), ) @@ -221,8 +209,7 @@ def _ssprk2_body( result.extend( ( " pops::MultiFab& endpoint1_%d = " - "ctx.scratch_state(%d, 0, state_%d);" - % (block, 21000 + block, block), + "ctx.scratch_state(%d, 0, state_%d);" % (block, 21000 + block, block), " ctx.lincomb(endpoint1_%d, pops::Real(1), stage1_%d, dt, rhs_1_%d, " "dt, {{0, 1, 1}}, {{1, 1, 1}});" % (block, block, block), " pops::MultiFab& next_%d = ctx.scratch_state(%d, 0, state_%d);" @@ -235,7 +222,7 @@ def _ssprk2_body( return "\n".join( ( *_state_declarations(block_count), - " (void)pops::consume_solve_outcome(ctx.solve_fields());", + " (void)pops::consume_solve_outcome(ctx.solve_fields());", *_rhs_stage( block_count, stage=0, @@ -274,8 +261,7 @@ def _ssprk3_body( first_stage.extend( ( " pops::MultiFab& stage1_%d = " - "ctx.scratch_state(%d, 0, state_%d);" - % (block, 20000 + block, block), + "ctx.scratch_state(%d, 0, state_%d);" % (block, 20000 + block, block), " ctx.lincomb(stage1_%d, pops::Real(1), state_%d, dt, rhs_0_%d, dt, " "{{0, 1, 1}}, {{1, 1, 1}});" % (block, block, block), ) @@ -283,13 +269,11 @@ def _ssprk3_body( second_stage.extend( ( " pops::MultiFab& endpoint1_%d = " - "ctx.scratch_state(%d, 0, state_%d);" - % (block, 21000 + block, block), + "ctx.scratch_state(%d, 0, state_%d);" % (block, 21000 + block, block), " ctx.lincomb(endpoint1_%d, pops::Real(1), stage1_%d, dt, rhs_1_%d, " "dt, {{0, 1, 1}}, {{1, 1, 1}});" % (block, block, block), " pops::MultiFab& stage2_%d = " - "ctx.scratch_state(%d, 0, state_%d);" - % (block, 22000 + block, block), + "ctx.scratch_state(%d, 0, state_%d);" % (block, 22000 + block, block), " ctx.lincomb(stage2_%d, pops::Real(3) / pops::Real(4), state_%d, " "pops::Real(1) / pops::Real(4), endpoint1_%d, dt, " "{{0, 3, 4}}, {{0, 1, 4}});" % (block, block, block), @@ -298,8 +282,7 @@ def _ssprk3_body( result.extend( ( " pops::MultiFab& endpoint2_%d = " - "ctx.scratch_state(%d, 0, state_%d);" - % (block, 23000 + block, block), + "ctx.scratch_state(%d, 0, state_%d);" % (block, 23000 + block, block), " ctx.lincomb(endpoint2_%d, pops::Real(1), stage2_%d, dt, rhs_2_%d, " "dt, {{0, 1, 1}}, {{1, 1, 1}});" % (block, block, block), " pops::MultiFab& next_%d = ctx.scratch_state(%d, 0, state_%d);" @@ -312,7 +295,7 @@ def _ssprk3_body( return "\n".join( ( *_state_declarations(block_count), - " (void)pops::consume_solve_outcome(ctx.solve_fields());", + " (void)pops::consume_solve_outcome(ctx.solve_fields());", *_rhs_stage( block_count, stage=0, @@ -346,6 +329,19 @@ def _ssprk3_body( ) +def _make_test_field_solve_explicitly_coarse(body: str) -> str: + """Qualify the legacy test-only AMR field cadence without claiming a fine solve.""" + generic = " (void)pops::consume_solve_outcome(ctx.solve_fields());" + if body.count(generic) != 1: + raise AssertionError("explicit test Program must contain one default field solve") + return body.replace( + generic, + " if (ctx.level() == 0)\n" + " (void)pops::consume_solve_outcome(\n" + " ctx.solve_default_field_on_coarse_level());", + ) + + def _source( *, target: str, @@ -375,6 +371,8 @@ def _source( ) else: # pragma: no cover - private callers validate the method raise ValueError("unsupported explicit test Program %r" % method) + if target == "amr_system": + body = _make_test_field_solve_explicitly_coarse(body) common = """\ #if !defined(POPS_RUNTIME_SHARED_EXCEPTION_ABI) #error "test Programs require the shared runtime exception ABI consumer contract" @@ -412,7 +410,8 @@ def _source( "pops::AmrSystem" if target == "amr_system" else "pops::System", ) if target == "system": - install = """\ + install = ( + """\ extern "C" void pops_install_program(pops::System* system) { auto context = pops::runtime::program::make_program_execution_provider(system); context->configure_primary_clock("pops.test.clock.macro"); @@ -422,9 +421,12 @@ def _source( %s }); } -""" % body +""" + % body + ) else: - install = """\ + install = ( + """\ extern "C" void pops_install_program_amr(pops::AmrSystem* system) { auto context = pops::runtime::program::make_program_execution_provider(system); context->configure_primary_clock("pops.test.clock.macro"); @@ -435,7 +437,9 @@ def _source( }); }, context); } -""" % body +""" + % body + ) return common + install @@ -505,9 +509,7 @@ def _install_explicit_program( coupled_sources: bool = False, ) -> str: if method not in {"euler", "ssprk2", "ssprk3", "imex_source_free"}: - raise ValueError( - "method must be 'euler', 'ssprk2', 'ssprk3', or 'imex_source_free'" - ) + raise ValueError("method must be 'euler', 'ssprk2', 'ssprk3', or 'imex_source_free'") if not isinstance(runtime, (System, AmrSystem)): raise TypeError("runtime must be a pops.runtime System or AmrSystem") block_names = tuple(runtime.block_names()) diff --git a/tests/python/test_durations.json b/tests/python/test_durations.json index 4af80edb6..09d0dff76 100644 --- a/tests/python/test_durations.json +++ b/tests/python/test_durations.json @@ -60,6 +60,7 @@ "tests/python/integration/native_loader/test_prepared_preconditioner_component.py": 120.0, "tests/python/integration/native_loader/test_ssprk3_production.py": 155.7, "tests/python/integration/native_loader/test_uniform_restart_missing_history.py": 30.0, + "tests/python/integration/runtime/test_axial_slip_wall_pipeline.py": 120.0, "tests/python/integration/runtime/test_coupling_preset_parity.py": 0.5, "tests/python/integration/runtime/test_diocotron_analytic_initial.py": 120.0, "tests/python/integration/runtime/test_dsl_runtime_params.py": 300.0, @@ -173,6 +174,8 @@ "tests/python/unit/codegen/test_program_emit_params_multimodel.py": 2.0, "tests/python/unit/codegen/test_program_graph_lowering.py": 2.0, "tests/python/unit/codegen/test_program_model_graph.py": 2.0, + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py": 2.0, + "tests/python/unit/codegen/test_representation_arity.py": 2.0, "tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py": 2.0, "tests/python/unit/codegen/test_schedule_extension_protocol.py": 2.0, "tests/python/unit/codegen/test_scheduler_codegen.py": 0.5, @@ -233,6 +236,7 @@ "tests/python/unit/numerics/test_finite_volume_composite.py": 1.0, "tests/python/unit/numerics/test_indicator_stencils.py": 1.0, "tests/python/unit/output/test_async_scientific_output.py": 2.0, + "tests/python/unit/output/test_async_scientific_output_diagnostics.py": 2.0, "tests/python/unit/output/test_durable_journal.py": 2.0, "tests/python/unit/output/test_durable_observer_integration.py": 2.0, "tests/python/unit/output/test_exact_writers.py": 1.0, @@ -362,6 +366,7 @@ "tests/python/unit/time/test_multirate_history_contract.py": 1.0, "tests/python/unit/time/test_operator_handle_resolution.py": 1.0, "tests/python/unit/time/test_program_authoring_atomicity.py": 1.0, + "tests/python/unit/time/test_program_cadence.py": 1.0, "tests/python/unit/time/test_program_deep_freeze.py": 1.0, "tests/python/unit/time/test_program_solve_final.py": 1.0, "tests/python/unit/time/test_program_to_graph.py": 1.0, @@ -408,7 +413,7 @@ "unit_seconds": "per-file pytest wall time", "measured_source": "borrowed _pops.so locally plus GitHub Actions run 30190778708 per-test timings", "estimated_note": "Unmeasured files use conservative path/content tiers (1/2/5/30/60/120 s); compiler-gated files retain native-compile estimates. Refresh every estimated row from a full CI run gate-python timing artifact.", - "estimated_count": 223, + "estimated_count": 228, "estimated_files": [ "tests/python/examples/final/test_hyqmom15_final_example.py", "tests/python/examples/final/test_scalar_advection_final_example.py", @@ -447,6 +452,7 @@ "tests/python/integration/native_loader/test_prepared_preconditioner_component.py", "tests/python/integration/native_loader/test_ssprk3_production.py", "tests/python/integration/native_loader/test_uniform_restart_missing_history.py", + "tests/python/integration/runtime/test_axial_slip_wall_pipeline.py", "tests/python/integration/runtime/test_diocotron_analytic_initial.py", "tests/python/integration/runtime/test_dsl_runtime_params.py", "tests/python/integration/runtime/test_final_condensed_uniform_runtime.py", @@ -513,6 +519,8 @@ "tests/python/unit/codegen/test_program_emit_params_multimodel.py", "tests/python/unit/codegen/test_program_graph_lowering.py", "tests/python/unit/codegen/test_program_model_graph.py", + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", + "tests/python/unit/codegen/test_representation_arity.py", "tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py", "tests/python/unit/codegen/test_schedule_extension_protocol.py", "tests/python/unit/codegen/test_shared_interface_validation.py", @@ -556,6 +564,7 @@ "tests/python/unit/numerics/test_finite_volume_composite.py", "tests/python/unit/numerics/test_indicator_stencils.py", "tests/python/unit/output/test_async_scientific_output.py", + "tests/python/unit/output/test_async_scientific_output_diagnostics.py", "tests/python/unit/output/test_durable_journal.py", "tests/python/unit/output/test_durable_observer_integration.py", "tests/python/unit/output/test_exact_writers.py", @@ -619,6 +628,7 @@ "tests/python/unit/time/test_multirate_history_contract.py", "tests/python/unit/time/test_operator_handle_resolution.py", "tests/python/unit/time/test_program_authoring_atomicity.py", + "tests/python/unit/time/test_program_cadence.py", "tests/python/unit/time/test_program_deep_freeze.py", "tests/python/unit/time/test_program_solve_final.py", "tests/python/unit/time/test_program_to_graph.py", @@ -634,6 +644,6 @@ "tests/python/unit/time/test_typed_provenance_guards.py", "tests/python/unit/time/test_typed_schedule.py" ], - "total_files": 404 + "total_files": 409 } } diff --git a/tests/python/unit/amr/test_external_amr_providers.py b/tests/python/unit/amr/test_external_amr_providers.py index 068c0366b..4ef680f9d 100644 --- a/tests/python/unit/amr/test_external_amr_providers.py +++ b/tests/python/unit/amr/test_external_amr_providers.py @@ -12,7 +12,7 @@ import pytest from pops import interfaces -from pops.amr import ClusteringProvider, TaggerProvider +from pops.amr import ClusteringProvider, RefluxProvider, TaggerProvider from pops.external import build_source_package_manifest, load from pops.layouts import AMR from pops.model import ComponentManifest @@ -144,13 +144,14 @@ def test_external_tagger_native_backend_accepts_an_exact_gpu_target(tmp_path): TaggerProvider(mismatched) -def _layout(authored, *, tagger, clustering, tagging=None): +def _layout(authored, *, tagger, clustering, tagging=None, reflux=None): return AMR( grid=authored.grid, hierarchy=authored.hierarchy, tagging=authored.tagging if tagging is None else tagging, tagger=tagger, clustering=clustering, + reflux=authored.reflux if reflux is None else reflux, regrid=authored.regrid, transfer=authored.transfer, execution=authored.execution, @@ -163,21 +164,25 @@ def test_external_amr_providers_survive_resolution_with_exact_components(tmp_pat tmp_path, name="tagger", interface=interfaces.Tagger) clustering_component = _component( tmp_path, name="clustering", interface=interfaces.Clustering) + reflux_component = _component( + tmp_path, name="reflux", interface=interfaces.Reflux) layout = _layout( target.layout, tagger=TaggerProvider(tagger_component), clustering=ClusteringProvider(clustering_component), + reflux=RefluxProvider(reflux_component), ) resolved = pops.resolve( pops.validate(target.authoring.case), layout=layout, - components=(tagger_component, clustering_component), + components=(tagger_component, clustering_component, reflux_component), ) - assert tuple(resolved.amr_providers) == ("clustering", "tagger") + assert tuple(resolved.amr_providers) == ("clustering", "tagger", "reflux") tagger = resolved.amr_providers["tagger"] clustering = resolved.amr_providers["clustering"] + reflux = resolved.amr_providers["reflux"] assert tagger["provider_type"] == "external_amr_tagger" assert tagger["component_id"] == tagger_component.component_manifest.component_id assert tagger["tagging_graph_identity"] == resolved.bootstrap_plan.tagging.qualified_id @@ -187,6 +192,10 @@ def test_external_amr_providers_survive_resolution_with_exact_components(tmp_pat assert clustering["provider_type"] == "external_amr_clustering" assert clustering["component_id"] == clustering_component.component_manifest.component_id assert clustering["native_interface"] == interfaces.Clustering.to_data() + assert reflux["provider_type"] == "external_amr_reflux" + assert reflux["component_id"] == reflux_component.component_manifest.component_id + assert reflux["native_interface"] == interfaces.Reflux.to_data() + assert reflux["clock_identity"] == target.authoring.program.clock.qualified_id from pops.identity.semantic import semantic_value assert resolved.resolved_hierarchy.plan.clustering.options.to_data() == { @@ -478,18 +487,21 @@ def binding(slot, interface, component_id, manifest): "interface_version": interface.version, "layout_identity": layout_identity, } + if slot in {"tagger", "reflux"}: + row["clock_identity"] = clock_identity if slot == "tagger": row.update({ - "clock_identity": clock_identity, "tagging_graph_identity": graph_identity, "tagging_capability": normalized_capability, }) row["provider_identity"] = amr_provider_binding_identity(slot, row) return row - tagger_handle, clustering_handle = object(), object() - tagger_id, clustering_id = "test::tagger", "test::clustering" - tagger_manifest, clustering_manifest = "manifest::tagger", "manifest::clustering" + tagger_handle, clustering_handle, reflux_handle = object(), object(), object() + tagger_id, clustering_id, reflux_id = "test::tagger", "test::clustering", "test::reflux" + tagger_manifest = "manifest::tagger" + clustering_manifest = "manifest::clustering" + reflux_manifest = "manifest::reflux" installed = { tagger_id: SimpleNamespace( component_manifest=SimpleNamespace(token=tagger_manifest), @@ -503,6 +515,12 @@ def binding(slot, interface, component_id, manifest): native_handle=clustering_handle, runtime_contract=SimpleNamespace(capabilities=()), ), + reflux_id: SimpleNamespace( + component_manifest=SimpleNamespace(token=reflux_manifest), + interface=interfaces.Reflux, + native_handle=reflux_handle, + runtime_contract=SimpleNamespace(capabilities=()), + ), } execution = ExecutionContext( backend=proven_serial_manifest( @@ -519,6 +537,8 @@ def binding(slot, interface, component_id, manifest): clustering_id, clustering_manifest), "tagger": binding( "tagger", interfaces.Tagger, tagger_id, tagger_manifest), + "reflux": binding( + "reflux", interfaces.Reflux, reflux_id, reflux_manifest), }, components=installed, execution_context=execution, @@ -541,6 +561,9 @@ def _install_amr_clustering_component(self, *args): def _install_amr_tagger_component(self, *args): self.calls.append(("tagger", args)) + def _install_amr_reflux_component(self, *args): + self.calls.append(("reflux", args)) + def _discard_amr_provider_components(self): self.calls.clear() self.discarded = True @@ -548,10 +571,11 @@ def _discard_amr_provider_components(self): native = Native() engine = SimpleNamespace(_s=native) _install_amr_provider_authorities(engine, plan) - assert [name for name, _ in native.calls] == ["clustering", "tagger"] + assert [name for name, _ in native.calls] == ["clustering", "tagger", "reflux"] assert native.calls[0][1][0] is clustering_handle assert native.calls[1][1][0] is tagger_handle - assert tuple(engine._amr_provider_authorities) == ("clustering", "tagger") + assert native.calls[2][1][0] is reflux_handle + assert tuple(engine._amr_provider_authorities) == ("clustering", "tagger", "reflux") missing = SimpleNamespace(**vars(plan)) missing.components = {clustering_id: installed[clustering_id]} @@ -560,3 +584,15 @@ def _discard_amr_provider_components(self): _install_amr_provider_authorities(SimpleNamespace(_s=untouched), missing) assert untouched.calls == [] assert not untouched.discarded + + missing_reflux = SimpleNamespace(**vars(plan)) + missing_reflux.components = { + clustering_id: installed[clustering_id], + tagger_id: installed[tagger_id], + } + untouched_reflux = Native() + with pytest.raises(ValueError, match="AMR reflux provider.*not installed"): + _install_amr_provider_authorities( + SimpleNamespace(_s=untouched_reflux), missing_reflux) + assert untouched_reflux.calls == [] + assert not untouched_reflux.discarded diff --git a/tests/python/unit/amr/test_public_amr_resolution.py b/tests/python/unit/amr/test_public_amr_resolution.py index acffb948b..07bbf52b3 100644 --- a/tests/python/unit/amr/test_public_amr_resolution.py +++ b/tests/python/unit/amr/test_public_amr_resolution.py @@ -86,6 +86,12 @@ def _resolved_target( return target, layout, layout_plan, layout.resolve_amr_authorities(context) +def _native_layout(layout_plan): + normalized, = layout_plan.layouts + assert normalized.native_spatial_layout is not None + return normalized.native_spatial_layout + + @pytest.mark.parametrize("value", [0, 1, "true", None, object()]) def test_patch_layout_requires_an_exact_bool(value): from pops.amr import PatchLayout @@ -125,7 +131,7 @@ def _set_load_balance_provider(self, *values): ) authored = PatchLayout(distribute_coarse=True, coarse_max_grid=7) - _, layout, _, authorities = _resolved_target(patch_layout=authored) + _, layout, layout_plan, authorities = _resolved_target(patch_layout=authored) public_data = { "schema_version": 1, "authority_type": "amr_patch_layout", @@ -142,7 +148,11 @@ def _set_load_balance_provider(self, *values): "distribute_coarse": True, "coarse_max_grid": 7, } - config = amr_config_from_layout(layout, hierarchy=authorities.hierarchy) + config = amr_config_from_layout( + layout, + hierarchy=authorities.hierarchy, + native_layout=_native_layout(layout_plan), + ) assert config.distribute_coarse is True assert config.coarse_max_grid == 7 assert config.load_balance_provider[:3] == ( @@ -151,11 +161,13 @@ def _set_load_balance_provider(self, *values): "pops.amr.load-balance.space-filling-curve@1", ) - _, automatic_layout, _, automatic = _resolved_target( + _, automatic_layout, automatic_plan, automatic = _resolved_target( patch_layout=PatchLayout(distribute_coarse=True) ) automatic_config = amr_config_from_layout( - automatic_layout, hierarchy=automatic.hierarchy + automatic_layout, + hierarchy=automatic.hierarchy, + native_layout=_native_layout(automatic_plan), ) assert automatic_config.distribute_coarse is True assert automatic_config.coarse_max_grid == 0 @@ -178,7 +190,7 @@ def _set_load_balance_provider(self, *values): "pops._bootstrap", SimpleNamespace(AmrSystemConfig=NativeConfigProbe), ) - _, layout, _, authorities = _resolved_target() + _, layout, layout_plan, authorities = _resolved_target() frame = Rectangle("rectangular", (-2.0, 1.5), (4.0, 4.5)).frame(Cartesian2D()) grid = CartesianGrid( frame=frame, @@ -193,8 +205,22 @@ class RectangularRuntimeLayout: def runtime_layout_data(): return dict(runtime_data) + from pops.mesh import NativeSpatialLayout + + normalized, = layout_plan.layouts + spatial_data = grid.native_spatial_data() + rectangular_native = NativeSpatialLayout.from_geometry( + layout=normalized.handle, + geometry=grid.normalized_geometry(), + periodicity=spatial_data["periodicity"], + centering=spatial_data["centering"], + decomposition={"kind": "adaptive", "source": "rectangular-test"}, + ) config = amr_config_from_layout( - RectangularRuntimeLayout(), hierarchy=authorities.hierarchy) + RectangularRuntimeLayout(), + hierarchy=authorities.hierarchy, + native_layout=rectangular_native, + ) assert (config.n, config.ny) == (30, 12) assert (config.L, config.Ly) == (6.0, 3.0) assert (config.xlo, config.ylo) == (-2.0, 1.5) @@ -206,6 +232,7 @@ def runtime_layout_data(): [ ("SpaceFillingCurve", "space_filling_curve", True), ("Knapsack", "knapsack", True), + ("MeasuredKnapsack", "measured_knapsack", True), ("RoundRobin", "round_robin", False), ], ) @@ -234,6 +261,61 @@ def test_public_load_balance_roundtrips_exact_identity( authorities.hierarchy.plan.load_balance.provider.local_id) +def test_measured_knapsack_roundtrips_exact_native_decision_policy(monkeypatch): + from pops.lib.amr import MeasuredKnapsack + from pops.runtime._amr_bind_lowering import amr_config_from_layout + + class NativeConfigProbe: + def _set_load_balance_provider(self, *values): + self.load_balance_provider = values + + monkeypatch.setitem( + sys.modules, + "pops._bootstrap", + SimpleNamespace(AmrSystemConfig=NativeConfigProbe), + ) + policy = MeasuredKnapsack( + minimum_improvement_ppm=125_000, + amortization_steps=40, + migration_bandwidth_bytes_per_second=25_000_000_000, + per_patch_migration_latency_nanoseconds=2_500, + ) + _, layout, layout_plan, authorities = _resolved_target(load_balance=policy) + config = amr_config_from_layout( + layout, + hierarchy=authorities.hierarchy, + native_layout=_native_layout(layout_plan), + ) + assert config.load_balance_provider == ( + "measured_knapsack", + policy.load_balance_provider_data()["provider_identity"], + "pops.amr.load-balance.measured-knapsack@1", + { + "minimum_improvement_ppm": 125_000, + "amortization_steps": 40, + "migration_bandwidth_bytes_per_second": 25_000_000_000, + "per_patch_migration_latency_nanoseconds": 2_500, + }, + ) + + +@pytest.mark.parametrize( + ("keyword", "value"), + [ + ("minimum_improvement_ppm", True), + ("minimum_improvement_ppm", 1_000_000), + ("amortization_steps", 0), + ("migration_bandwidth_bytes_per_second", 0), + ("per_patch_migration_latency_nanoseconds", -1), + ], +) +def test_measured_knapsack_rejects_invalid_decision_policy(keyword, value): + from pops.lib.amr import MeasuredKnapsack + + with pytest.raises((TypeError, ValueError)): + MeasuredKnapsack(**{keyword: value}) + + def test_load_balance_extension_protocol_needs_no_core_class_branch(): from pops.identity import make_identity @@ -618,6 +700,19 @@ def set_temporal_relations(self, numerators, denominators, policies): "memory_spaces": list(tagging_abi["memory_spaces"]), }, }, + "reflux": { + "schema_version": 1, + "provider_type": "builtin_amr_reflux", + "runtime_installation": { + "schema_version": 1, + "protocol": "builtin", + }, + "provider_id": "pops.lib.amr::flux_register_reflux", + "provider_identity": "test::reflux-provider", + "native_interface": interfaces.Reflux.to_data(), + "layout_identity": layout_identity, + "clock_identity": "test::clock", + }, }, ) for role, binding in install_plan.amr_providers.items(): diff --git a/tests/python/unit/analytic/test_analytic_expressions.py b/tests/python/unit/analytic/test_analytic_expressions.py index 9d32feb1b..d745b438b 100644 --- a/tests/python/unit/analytic/test_analytic_expressions.py +++ b/tests/python/unit/analytic/test_analytic_expressions.py @@ -11,6 +11,7 @@ import pytest +import pops from pops.analytic import ( AnalyticTruthValueError, PredicateExpr, @@ -34,6 +35,7 @@ radius, sin, sqrt, + time, where, x, y, @@ -69,6 +71,30 @@ def test_coordinates_are_typed_and_bound_to_one_frame() -> None: x_value + x(_frame("other")) +def test_physical_time_is_bound_to_one_exact_owner_qualified_clock() -> None: + program = pops.Program("analytic-time-program") + value = time(program.clock) + payload = value.to_data() + + assert value.time_clocks() == (program.clock,) + assert payload["root"] == { + "kind": "scalar", + "op": "time", + "clock": program.clock.to_data(), + "clock_id": program.clock.qualified_id, + } + assert ScalarExpr.from_data(payload).same_as(value) + + from pops.time import Clock + + with pytest.raises(TypeError, match="owner-qualified"): + time(Clock("unowned")) + forged = copy.deepcopy(payload) + forged["root"]["clock_id"] = "pops.clock.v1::sha256:forged" + with pytest.raises(ValueError, match="Clock identity"): + ScalarExpr.from_data(forged) + + def test_scalar_math_builds_a_data_only_canonical_tree() -> None: frame = _frame() x_value, y_value = coordinates(frame) diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 6ec105491..85ecb4079 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -1,19 +1,22 @@ from __future__ import annotations +from dataclasses import replace + import pytest import pops -from pops.boundary import TransportBoundarySet +from pops.boundary import TransportBoundarySet, model_primitive_to_conservative from pops.boundary.transport import ResolvedTransportBoundarySet -from pops.boundary.transport import Inflow, Outflow +from pops.boundary.transport import Inflow, NoFlux, Outflow, SlipWall from pops.domain import Rectangle -from pops.frames import Cartesian2D +from pops.frames import Cartesian2D, Z_AXIS from pops.math import ddt, div from pops.numerics import DiscretizationPlan, reconstruction, riemann, variables from pops.numerics.reconstruction import limiters from pops.numerics.spatial import FiniteVolume from pops.params import RuntimeParam -from pops.representations import Conservative +from pops.physics import Axial, Density, Momentum +from pops.representations import Conservative, Primitive from pops.spaces import CellState @@ -110,6 +113,257 @@ def test_transport_set_resolves_exact_ports_values_and_derived_stencil_requireme } +def test_no_flux_lowers_to_one_prepared_ghost_and_post_riemann_face_law(): + from pops.mesh.boundaries import BoundaryProviderKind, NumericalFlux + from pops.mesh.boundaries.compiled_plan import CompiledBoundaryPlan + + frame, _, _, _, numerics, case, block, block_state = _authoring() + numerics.boundaries.add(TransportBoundarySet({ + boundary: NoFlux(state=block_state) for boundary in frame.boundaries.all + })) + case.numerics(numerics, block=block) + + authority = case._resolved_numerics_for("tracer").boundaries[0] + assert {row.condition_type for row in authority.conditions} == {"no_flux"} + for condition in authority.conditions: + assert condition.values == () + assert condition.provider.kind is BoundaryProviderKind.NO_FLUX + assert isinstance(condition.provider.outputs[0], NumericalFlux) + assert condition.provider.dependencies.states == (condition.state,) + + compiled = authority.compile_boundary_data() + runtime = authority.runtime_boundary_data({}) + assert [row["type"] for row in compiled["faces"]] == ["no_flux"] * 4 + assert [row["type"] for row in runtime["faces"]] == ["no_flux"] * 4 + assert all(row["values"] == [0.0] for row in runtime["faces"]) + + detached = dict(compiled) + detached.update({ + "ghost_plan_identity": authority.plan.canonical_id, + "producer_order": [], + "component_region_templates": [], + }) + assert [ + row["type"] + for row in CompiledBoundaryPlan(detached).runtime_boundary_data({})["faces"] + ] == ["no_flux"] * 4 + + # The immutable provider contract rejects a NumericalFlux law forged into a ghost-state family. + foreign_provider = next( + row.provider for row in authority.conditions + if row.provider.kind is BoundaryProviderKind.NO_FLUX + ) + with pytest.raises((TypeError, ValueError)): + replace(foreign_provider, kind=BoundaryProviderKind.OUTFLOW) + + +def test_primitive_fixed_state_lowers_only_through_the_exact_block_model_converter(): + frame, _, _, _, numerics, case, block, block_state = _authoring() + converter = model_primitive_to_conservative(block_state) + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: Inflow( + state=block_state, + value=0.25, + representation=Primitive(), + converter=converter, + ), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + case.validate_report().raise_if_error() + + authority = case._resolved_numerics_for("tracer").boundaries[0] + compiled = authority.compile_boundary_data() + runtime = authority.runtime_boundary_data({}) + compiled_xmin = next(face for face in compiled["faces"] if face["ordinal"] == 0) + runtime_xmin = next(face for face in runtime["faces"] if face["ordinal"] == 0) + expected_state = authority.conditions[0].state + expected = model_primitive_to_conservative(expected_state).qualified_id + assert compiled_xmin["representation"] == "primitive" + assert compiled_xmin["converter"] == expected + assert runtime_xmin["representation"] == "primitive" + assert runtime_xmin["converter"] == expected + + from pops.mesh.boundaries.compiled_plan import CompiledBoundaryPlan + + detached_compile = dict(compiled) + detached_compile.update({ + "ghost_plan_identity": authority.plan.canonical_id, + "producer_order": [], + "component_region_templates": [], + }) + detached_xmin = next( + face for face in CompiledBoundaryPlan(detached_compile).runtime_boundary_data({})["faces"] + if face["ordinal"] == 0 + ) + assert detached_xmin["representation"] == "primitive" + assert detached_xmin["converter"] == expected + + from pops.model import Handle + + converted_condition = next( + row for row in authority.conditions if row.geometry.axis.index == 0 + and row.geometry.side.value == "lower" + ) + forged_flow = replace( + converted_condition.provider.dependencies.representation, + converter=Handle( + "forged-converter", + kind="representation_conversion", + owner=converted_condition.state.owner_path, + ), + ) + forged_dependencies = replace( + converted_condition.provider.dependencies, + representation=forged_flow, + ) + forged_condition = replace( + converted_condition, + provider=replace(converted_condition.provider, dependencies=forged_dependencies), + ) + with pytest.raises(NotImplementedError, match="exact model_primitive_to_conservative"): + replace( + authority, + conditions=tuple( + forged_condition if row is converted_condition else row + for row in authority.conditions + ), + ) + + +def test_analytic_inflow_lowers_typed_x_time_and_bound_parameters_without_callback(): + from pops.analytic import param, time, x + from pops.mesh.boundaries.compiled_plan import CompiledBoundaryPlan + from pops.model import BindSchema + + frame, _, inlet, _, numerics, case, block, block_state = _authoring() + program = pops.Program("analytic-boundary-clock") + analytic_value = 1.0 + x(frame) + time(program.clock) + param(inlet) + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow(state=block_state, value=analytic_value), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + authority = case._resolved_numerics_for("tracer").boundaries[0] + + analytic_condition = next( + row + for row in authority.conditions + if row.geometry.axis.index == 0 and row.geometry.side.value == "lower" + ) + assert analytic_condition.provider.dependencies.states == () + assert len(analytic_condition.provider.dependencies.time) == 1 + assert len(analytic_condition.provider.dependencies.runtime_params) == 1 + assert analytic_condition.values[0].frame_id == frame.canonical_id + + schema = BindSchema.from_problem(case) + bindings = schema.resolve_bind({}, compile_values=schema.resolve_compile()) + runtime = authority.runtime_boundary_data(bindings) + xlo = next(face for face in runtime["faces"] if face["ordinal"] == 0) + assert xlo["values"] == [0.0] + assert xlo["analytic_clock"] == program.clock.qualified_id + assert xlo["analytic_programs"][0]["opcodes"] == [ + "constant", + "x", + "add", + "input", + "add", + "constant", + "add", + ] + assert xlo["analytic_programs"][0]["literals"][3] == 0.0 + assert xlo["analytic_programs"][0]["literals"][5] == 0.25 + + compiled = authority.compile_boundary_data() + compiled.update( + { + "ghost_plan_identity": authority.plan.canonical_id, + "producer_order": [], + "component_region_templates": [], + } + ) + detached = CompiledBoundaryPlan(compiled).runtime_boundary_data(bindings) + assert detached["faces"] == runtime["faces"] + + +def test_analytic_inflow_fails_closed_for_primitive_per_point_conversion(): + from pops.analytic import x + + frame, _, _, _, numerics, case, block, block_state = _authoring() + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow( + state=block_state, + value=x(frame), + representation=Primitive(), + converter=model_primitive_to_conservative(block_state), + ), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + + with pytest.raises(NotImplementedError, match="analytic primitive inflow"): + case._resolved_numerics_for("tracer") + + +def test_analytic_inflow_fails_closed_for_discrete_setup_inputs(): + from pops.analytic import input + + frame, _, _, _, numerics, case, block, block_state = _authoring() + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow( + state=block_state, value=input(0, "n")), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=0.25), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + + with pytest.raises(NotImplementedError, match="setup-program discrete inputs"): + case._resolved_numerics_for("tracer") + + +def test_analytic_inflow_fails_closed_when_one_plan_mixes_logical_clocks(): + from pops.analytic import time + + frame, _, _, _, numerics, case, block, block_state = _authoring() + first = pops.Program("analytic-boundary-first-clock") + second = pops.Program("analytic-boundary-second-clock") + numerics.boundaries.add( + TransportBoundarySet( + { + frame.boundaries.x_min: Inflow( + state=block_state, value=time(first.clock)), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow( + state=block_state, value=time(second.clock)), + frame.boundaries.y_max: Outflow(state=block_state), + } + ) + ) + case.numerics(numerics, block=block) + + with pytest.raises(ValueError, match="plan cannot mix several logical Clocks"): + case._resolved_numerics_for("tracer") + + def test_transport_set_rejects_incomplete_geometry_at_resolution(): frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() numerics.boundaries.add(TransportBoundarySet({ @@ -135,3 +389,213 @@ def test_transport_conditions_require_instance_handles_and_exact_component_cover case.numerics(numerics, block=block) with pytest.raises(ValueError, match="prescribe exactly 1 components, got 2"): case._resolved_numerics_for("tracer") + + +def test_directional_characteristic_provider_cannot_fall_back_to_native_inflow(): + from pops.mesh.boundaries import ( + CharacteristicClosure, + ClosureMode, + DirectionalTransport, + IncomingMultiplicity, + SignDependence, + SonicPolicy, + ) + + class CharacteristicInflow: + def __init__(self, base): + self.base = base + self.state = base.state + + def inspect(self): + return {**self.base.inspect(), "characteristic": "directional"} + + def resolve_references(self, resolver): + return type(self)(self.base.resolve_references(resolver)) + + def resolve_condition(self, **kwargs): + resolved = self.base.resolve_condition(**kwargs) + dependencies = replace( + resolved.provider.dependencies, + characteristic=CharacteristicClosure( + mode=ClosureMode.DIRECTIONAL, + sign_dependence=SignDependence.FIXED, + sonic=SonicPolicy.NEUTRAL, + incoming=IncomingMultiplicity.SINGLE, + characteristics=(resolved.state,), + ), + ) + return replace( + resolved, + provider=DirectionalTransport( + handle=resolved.provider.handle, + outputs=resolved.provider.outputs, + dependencies=dependencies, + ), + ) + + frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: CharacteristicInflow( + Inflow(state=block_state, value=inlet_value) + ), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=inlet_value), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + + with pytest.raises( + NotImplementedError, + match="prepared model eigenstructure.*cannot fall back", + ): + case._resolved_numerics_for("tracer") + + +def test_model_characteristic_no_inflow_lowers_one_exact_prepared_face(): + from pops.boundary import model_characteristic_no_inflow + + frame, _, inlet, inlet_value, numerics, case, block, block_state = _authoring() + provider = model_characteristic_no_inflow(block_state) + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: Inflow( + state=block_state, + value=inlet_value, + characteristic=provider, + ), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=inlet_value), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + + authority = case._resolved_numerics_for("tracer").boundaries[0] + compiled = authority.compile_boundary_data() + canonical_inlet = case.resolve(inlet, block=block) + runtime = authority.runtime_boundary_data({canonical_inlet: 0.25}) + assert compiled["faces"][0]["type"] == "characteristic_no_inflow" + assert runtime["faces"][0]["type"] == "characteristic_no_inflow" + assert runtime["faces"][0]["values"] == [0.25] + assert runtime["faces"][1]["type"] == "foextrap" + + +def test_characteristic_no_inflow_rejects_forged_or_primitive_provider(): + from pops.boundary import model_characteristic_no_inflow + from pops.model import Handle + from pops.representations import Primitive + + _, _, _, inlet_value, _, _, _, block_state = _authoring() + forged = Handle( + "forged-characteristics", + kind="boundary_eigenstructure", + owner=block_state.owner_path, + ) + with pytest.raises(ValueError, match="exact model_characteristic_no_inflow"): + Inflow(state=block_state, value=inlet_value, characteristic=forged) + with pytest.raises(NotImplementedError, match="conservative reference"): + Inflow( + state=block_state, + value=inlet_value, + representation=Primitive(), + characteristic=model_characteristic_no_inflow(block_state), + ) + + +def test_resolved_transport_condition_rejects_a_forged_provider_law(): + from pops.mesh.boundaries import BoundaryProviderKind + + frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: Inflow(state=block_state, value=inlet_value), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: Inflow(state=block_state, value=inlet_value), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + authority = case._resolved_numerics_for("tracer").boundaries[0] + condition = next( + row for row in authority.conditions if row.condition_type == "inflow") + forged = replace(condition.provider, kind=BoundaryProviderKind.OUTFLOW) + + with pytest.raises(ValueError, match="condition 'inflow'.*provider law 'outflow'"): + replace(condition, provider=forged) + + +def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): + frame, _, _, _, numerics, case, block, block_state = _authoring() + numerics.boundaries.add(TransportBoundarySet({ + frame.boundaries.x_min: Outflow(state=block_state), + frame.boundaries.x_max: Outflow(state=block_state), + frame.boundaries.y_min: SlipWall(state=block_state), + frame.boundaries.y_max: Outflow(state=block_state), + })) + case.numerics(numerics, block=block) + with pytest.raises(ValueError, match="declared normal polar-vector component"): + case._resolved_numerics_for("tracer") + + domain = Rectangle("fluid_unit", (0.0, 0.0), (1.0, 1.0)) + fluid_frame = domain.frame(Cartesian2D()) + x_axis, y_axis = fluid_frame.axes + model = pops.Model("wall_model", frame=fluid_frame) + state = model.state( + "U", + components=("rho", "mx", "my", "bz"), + representation=Conservative(), + space=CellState(frame=fluid_frame), + roles={ + "rho": Density(), + "mx": Momentum(axis=x_axis), + "my": Momentum(axis=y_axis), + "bz": Axial(axis=Z_AXIS), + }, + ) + rho, mx, my, bz = state + flux = model.flux( + "flux", + frame=fluid_frame, + state=state, + components={ + x_axis: (rho, mx, my, bz), + y_axis: (rho, mx, my, bz), + }, + waves={ + x_axis: (1.0, 1.0, 1.0, 1.0), + y_axis: (1.0, 1.0, 1.0, 1.0), + }, + ) + rate = model.rate("rate", equation=ddt(state) == -div(flux)) + method = FiniteVolume( + flux=flux, + variables=variables.Conservative(state), + reconstruction=reconstruction.FirstOrder(), + riemann=riemann.Rusanov(), + ) + plan = DiscretizationPlan() + plan.rates.add(rate, method) + wall_case = pops.Case("wall_case") + wall_block = wall_case.block("fluid", model=model) + wall_state = wall_block[state] + plan.boundaries.add(TransportBoundarySet({ + boundary: SlipWall(state=wall_state) + for boundary in fluid_frame.boundaries.all + })) + wall_case.numerics(plan, block=wall_block) + + authority = wall_case._resolved_numerics_for("fluid").boundaries[0] + assert {row.condition_type for row in authority.conditions} == {"slip_wall"} + runtime = authority.runtime_boundary_data({}) + assert [row["type"] for row in runtime["faces"]] == ["slip_wall"] * 4 + assert all(row["values"] == [0.0] * 4 for row in runtime["faces"]) + + from pops.mesh.boundaries.compiled_plan import CompiledBoundaryPlan + + detached_compile_data = authority.compile_boundary_data() + detached_compile_data.update( + { + "ghost_plan_identity": authority.plan.canonical_id, + "producer_order": [], + "component_region_templates": [], + } + ) + detached_runtime = CompiledBoundaryPlan(detached_compile_data).runtime_boundary_data({}) + assert detached_runtime["faces"] == runtime["faces"] + assert detached_runtime["required_depth"] == runtime["required_depth"] diff --git a/tests/python/unit/codegen/test_amr_artifact_metadata.py b/tests/python/unit/codegen/test_amr_artifact_metadata.py index 18d58d899..09ffb1d20 100644 --- a/tests/python/unit/codegen/test_amr_artifact_metadata.py +++ b/tests/python/unit/codegen/test_amr_artifact_metadata.py @@ -46,6 +46,23 @@ def test_amr_artifact_reports_program_and_every_declared_block(): assert report_rows == manifest_rows +def test_requirements_report_preserves_exact_riemann_provider_options(): + artifact = artifact_fixture(target="amr_system", block_names=("fluid",)) + compiled_model = artifact.blocks[0].model + compiled_model.has_roe = True + compiled_model.hllc_provider = None + compiled_model.roe_provider = "flux_jacobian_v1" + compiled_model.roe_entropy_policy = "none" + compiled_model.roe_entropy_delta = None + + capabilities = artifact.requirements().capabilities + + roe = next(row for row in capabilities if row["capability"] == "roe_dissipation") + assert roe["providers"] == [ + {"kind": "flux_jacobian_v1", "entropy_policy": "none"} + ] + + @pytest.mark.parametrize("target", ["system", "amr_system"]) def test_single_layout_artifact_cannot_omit_the_compiled_program(target): artifact = artifact_fixture(target=target) diff --git a/tests/python/unit/codegen/test_boundary_jacvec_validation.py b/tests/python/unit/codegen/test_boundary_jacvec_validation.py index 9084d00c7..edc529b7e 100644 --- a/tests/python/unit/codegen/test_boundary_jacvec_validation.py +++ b/tests/python/unit/codegen/test_boundary_jacvec_validation.py @@ -102,15 +102,12 @@ def test_boundary_jacvec_accepts_multiple_frozen_primal_fields() -> None: ) -def test_boundary_jacvec_rejects_field_coupling_without_field_tangents() -> None: - with pytest.raises( - NotImplementedError, - match=r"no field-tangent materializer for field_coupled=True"): - _validate( - [_component_row("residual", fields=(FIELD_A,)), - _component_row("jvp", fields=(FIELD_A,))], - field_coupled=True, - ) +def test_boundary_jacvec_accepts_field_coupling_with_solved_field_dependencies() -> None: + _validate( + [_component_row("residual", fields=(FIELD_A,)), + _component_row("jvp", fields=(FIELD_A,))], + field_coupled=True, + ) def test_boundary_jacvec_rejects_cross_block_state_dependency() -> None: diff --git a/tests/python/unit/codegen/test_cell_local_time_codegen.py b/tests/python/unit/codegen/test_cell_local_time_codegen.py new file mode 100644 index 000000000..fc2ba4ffa --- /dev/null +++ b/tests/python/unit/codegen/test_cell_local_time_codegen.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest + +import pops +from pops.codegen.program_codegen import emit_cpp_program +from pops.lib import time as libtime +from pops.physics._facade import Model + + +def _transport_program(factory=libtime.ForwardEuler): + model = Model("cell_local_transport") + model.conservative_vars("u") + rate = model.rate("transport", flux=True, sources=()) + state = next( + declaration + for declaration in model.declaration_index().records() + if declaration.kind == "state" + ) + block = pops.Case("cell_local_case").block("tracer", model) + return factory(block[state], rate=rate), model + + +def test_cell_local_time_contract_is_frozen_rebuilt_and_hashed() -> None: + global_program, _ = _transport_program() + local_program, _ = _transport_program() + local_program.cell_local_time(tick_denominator=100, rung=1) + + assert local_program.cell_local_time_contract().to_data() == { + "schema_version": 1, + "tick_denominator": 100, + "rung": 1, + } + assert "cell_local_time" in local_program._serialize(include_provenance=False) + assert local_program._ir_hash() != global_program._ir_hash() + rebuilt = local_program._rebuild(lambda _value: True) + assert rebuilt.cell_local_time_contract() == local_program.cell_local_time_contract() + local_program.freeze() + with pytest.raises(RuntimeError, match="frozen"): + local_program.cell_local_time(tick_denominator=100) + + +@pytest.mark.parametrize( + ("tick_denominator", "rung", "message"), + [ + (0, 0, "positive int"), + (1.0, 0, "positive int"), + (10, -1, "in \\[0, 30\\]"), + (10, 31, "in \\[0, 30\\]"), + ], +) +def test_cell_local_time_contract_refuses_invalid_integer_clocks( + tick_denominator, rung, message) -> None: + program, _ = _transport_program() + with pytest.raises(ValueError, match=message): + program.cell_local_time(tick_denominator=tick_denominator, rung=rung) + + +def test_amr_codegen_selects_only_the_prepared_cell_local_driver() -> None: + program, model = _transport_program() + program.cell_local_time(tick_denominator=100, rung=0) + + source = emit_cpp_program(program, model=model, target="amr_system") + + assert "ctx.configure_primary_clock(" in source + assert "ctx.prepare_same_level_cell_temporal_execution(" in source + assert program.clock.qualified_id in source + assert "ctx_owner->advance_same_level_cell_temporal(dt);" in source + assert "ctx.advance_hierarchy(dt" not in source + assert "ctx.advance_synchronized_hierarchy(dt" not in source + + +def test_cell_local_codegen_refuses_non_euler_and_nondefault_cadence() -> None: + multistage, model = _transport_program(libtime.SSPRK2) + multistage.cell_local_time(tick_denominator=100) + with pytest.raises(ValueError, match="ForwardEuler"): + emit_cpp_program(multistage, model=model, target="amr_system") + + strided, model = _transport_program() + strided.cadence(stride=2) + strided.cell_local_time(tick_denominator=100) + with pytest.raises(ValueError, match="default Program cadence"): + emit_cpp_program(strided, model=model, target="amr_system") + + +def test_cell_local_codegen_refuses_uniform_target() -> None: + program, model = _transport_program() + program.cell_local_time(tick_denominator=100) + with pytest.raises(ValueError, match="target='amr_system'"): + emit_cpp_program(program, model=model, target="system") diff --git a/tests/python/unit/codegen/test_compiled_model_boundary.py b/tests/python/unit/codegen/test_compiled_model_boundary.py index 18c24cbe6..c58666ce4 100644 --- a/tests/python/unit/codegen/test_compiled_model_boundary.py +++ b/tests/python/unit/codegen/test_compiled_model_boundary.py @@ -32,6 +32,9 @@ def _model_hash(self): def check(self): return None + def __pops_bind_component_provider_packs__(self, packs): + self.provider_packs = packs + def __pops_native_loader_source__( self, *, name=None, target="system", hoist_reciprocals=False): return "// compiled-model-boundary fixture\n" diff --git a/tests/python/unit/codegen/test_compiler_model_provider.py b/tests/python/unit/codegen/test_compiler_model_provider.py index 7d0079d03..135e2a521 100644 --- a/tests/python/unit/codegen/test_compiler_model_provider.py +++ b/tests/python/unit/codegen/test_compiler_model_provider.py @@ -10,6 +10,7 @@ from pops.codegen.module_lowering import lower_and_validate from pops._ir.expr import Const from pops.model import Module, Rate +from pops.model.provider_pack import MissingInputProvider from pops.physics._facade import Model @@ -27,6 +28,21 @@ def _facade_model(name: str = "provider") -> Model: return model +def _field_dependent_flux_model(name: str, *, with_provider: bool = True) -> Model: + model = Model(name) + (rho,) = model.conservative_vars("rho") + phi = model.aux("phi") + grad_x = model.aux("grad_x") + model.aux("grad_y") + model.primitive_vars(rho=rho) + model.conservative_from([rho]) + model.flux(x=[rho * grad_x], y=[rho * grad_x]) + model.eigenvalues(x=(Const(1.0),), y=(Const(1.0),)) + if with_provider: + model.elliptic_rhs(rho + Const(0.0) * phi) + return model + + class _ThirdPartyProvider: """An external provider delegates only the documented compiler contract.""" @@ -50,6 +66,9 @@ class _CheckEmitter: def check(self) -> None: return None + def __pops_bind_component_provider_packs__(self, packs) -> None: + self.provider_packs = packs + def __pops_native_loader_source__( self, *, name=None, target="system", hoist_reciprocals=False): return "// test native loader\n" @@ -97,6 +116,50 @@ def test_frozen_module_remains_the_canonical_compiler_ir(): assert lowering.source_module is module +def test_facade_and_formula_carrier_share_one_minimal_flux_provider_pack(): + model = _field_dependent_flux_model("facade-flux-pack") + + emitted, source_module = lower_and_validate(model, facade=model) + + assert emitted is model + rows = model._component_flux_provider_metadata["entries"] + assert rows == model._m._component_flux_provider_metadata["entries"] + assert [row["key"]["component"] for row in rows] == ["grad_x"] + assert rows[0]["provider"]["availability"] is True + assert rows[0]["key"]["owner_qid"] == str(source_module.owner_path.canonical()) + + source = model.__pops_native_loader_source__() + assert rows[0]["key"]["owner_qid"] in source + assert '"grad_x"' in source + assert "true, 1" in source + assert "static constexpr int n_flux_providers = 1;" in source + assert "flux_provider_requirements" in source + assert "flux(const State& U, const auto& a, int dir)" in source + assert "a.template flux_provider<1>()" in source + + +def test_field_dependent_flux_without_provider_fails_before_native_source(): + model = _field_dependent_flux_model("missing-flux-provider", with_provider=False) + + with pytest.raises(MissingInputProvider, match="unset"): + lower_and_validate(model, facade=model) + + +def test_same_field_spelling_under_distinct_model_owners_stays_distinct_in_emitted_pack(): + left = _field_dependent_flux_model("left-flux-owner") + right = _field_dependent_flux_model("right-flux-owner") + + lower_and_validate(left, facade=left) + lower_and_validate(right, facade=right) + left_owner = left._component_flux_provider_metadata["entries"][0]["key"]["owner_qid"] + right_owner = right._component_flux_provider_metadata["entries"][0]["key"]["owner_qid"] + + assert left_owner != right_owner + assert left_owner in left.__pops_native_loader_source__() + assert right_owner not in left.__pops_native_loader_source__() + assert right_owner in right.__pops_native_loader_source__() + + class _MissingProtocol: pass diff --git a/tests/python/unit/codegen/test_component_packages.py b/tests/python/unit/codegen/test_component_packages.py index 3e987b6a2..60b07cd6d 100644 --- a/tests/python/unit/codegen/test_component_packages.py +++ b/tests/python/unit/codegen/test_component_packages.py @@ -13,6 +13,7 @@ SourcePackageRegistry, build_fixed_binary_manifest, build_source_package_manifest, + compile_component, load, ) from pops.model import ComponentManifest @@ -81,7 +82,7 @@ def test_external_component_parameters_are_deeply_frozen_authorities(tmp_path): component.parameters["options"]["policy"]["strict"] = False -def test_tampered_manifest_digest_is_rejected(tmp_path): +def test_tampered_manifest_and_retained_source_are_rejected_at_phase_boundaries(tmp_path): path, data = _write_source(tmp_path) changed = deepcopy(data) changed["exports"] = {"other": _manifest().component_id} @@ -90,6 +91,26 @@ def test_tampered_manifest_digest_is_rejected(tmp_path): load(path) assert error.value.code == "package_digest" + retained_root = tmp_path / "retained" + retained_root.mkdir() + retained = load(_write_source(retained_root)[0]) + component = retained.require("average", interface=interfaces.NumericalFlux)() + + # Frozen values are still part of a hostile extension boundary: prove that even deliberate + # in-memory corruption cannot turn the Python type itself into package authority. + object.__setattr__(retained.payloads[0], "content", b"tampered-retained-bytes") + + registry = SourcePackageRegistry() + with pytest.raises(ComponentPackageError) as registry_error: + registry.register(retained) + assert registry_error.value.code == "source_digest" + assert registry.revision == 0 + + # This refusal occurs before toolchain discovery, compilation or native module access. + with pytest.raises(ComponentPackageError) as compile_error: + compile_component(component) + assert compile_error.value.code == "source_digest" + def test_source_registry_is_atomic_idempotent_collision_safe_and_frozen(tmp_path): first_dir, second_dir = tmp_path / "first", tmp_path / "second" @@ -121,6 +142,32 @@ def test_fixed_binary_cannot_claim_template_genericity(): assert error.value.code == "fixed_generic_claim" +def test_fixed_binary_bytes_are_authenticated_before_package_use(tmp_path): + platform = proven_serial_manifest( + backend="aot-component", target="component", abi="headers|clang|c++20") + component = _manifest(generic=False) + binary = b"authenticated-fixed-component" + data = build_fixed_binary_manifest( + components={"average": component}, + platform=platform, + binary_path="average.so", + binary=binary, + symbols=("pops_component_interface_v1",), + ) + binary_path = tmp_path / "average.so" + manifest_path = tmp_path / "average.pops.json" + binary_path.write_bytes(binary) + manifest_path.write_text(json.dumps(data), encoding="utf-8") + + package = load(manifest_path) + assert package.binary == binary + + binary_path.write_bytes(binary + b"-tampered") + with pytest.raises(ComponentPackageError) as error: + load(manifest_path) + assert error.value.code == "binary_digest" + + def test_compiled_registry_refuses_source_values_and_freezes(): registry = CompiledArtifactRegistry() with pytest.raises(TypeError, match="CompiledComponentArtifact"): diff --git a/tests/python/unit/codegen/test_component_provider_pack.py b/tests/python/unit/codegen/test_component_provider_pack.py index 170d0ca98..6a4b12f76 100644 --- a/tests/python/unit/codegen/test_component_provider_pack.py +++ b/tests/python/unit/codegen/test_component_provider_pack.py @@ -64,6 +64,29 @@ def test_minimal_selection_preserves_qualified_identity_and_refuses_missing_prov pack.select([ComponentKey("case/missing", "field", "electric", "grad_x")]) +def test_component_selection_is_space_qualified_and_refuses_homonym_ambiguity(): + contract = ComponentContract("field", "cell", "V/m", "cell") + left = ComponentKey("owner", "field", "left", "grad_x") + right = ComponentKey("owner", "field", "right", "grad_x") + pack = ProviderPack([ + (left, contract, ProviderEntry("left_solver", True, 0)), + (right, contract, ProviderEntry("right_solver", True, 0)), + ]) + + selected = pack.select_components( + owner_qid="owner", + spaces=(("field", "left"),), + components=("grad_x",), + ) + assert tuple(selected) == (left,) + with pytest.raises(MissingInputProvider, match="ambiguous component"): + pack.select_components( + owner_qid="owner", + spaces=(("field", "left"), ("field", "right")), + components=("grad_x",), + ) + + def test_operator_provider_pack_contains_fields_but_not_explicit_state_trace(): module = Module("operator_pack") state = module.state_space("U", ("rho",)) @@ -78,6 +101,21 @@ def test_operator_provider_pack_contains_fields_but_not_explicit_state_trace(): } +def test_operator_requirements_select_only_declared_components(): + module = Module("operator_component_pack") + state = module.state_space("U", ("rho",)) + fields = module.field_space("electric", ("phi", "grad_x", "grad_y")) + module.operator("solve", state >> fields, "field_operator", expr=1.0) + operator = SimpleNamespace( + signature=SimpleNamespace(inputs=(state, fields)), + requirements={"aux": ("grad_x",)}, + ) + + pack = build_operator_provider_pack(module, operator) + + assert tuple(key.component for key in pack) == ("grad_x",) + + def test_provider_pack_accepts_exact_capacity_and_refuses_capacity_plus_one_atomically(): first = _row("rho", 0) second = _row("mx", 1) diff --git a/tests/python/unit/codegen/test_composite_tensor_fac_provider.py b/tests/python/unit/codegen/test_composite_tensor_fac_provider.py index 4ec316c3d..3a2c6d805 100644 --- a/tests/python/unit/codegen/test_composite_tensor_fac_provider.py +++ b/tests/python/unit/codegen/test_composite_tensor_fac_provider.py @@ -32,6 +32,7 @@ prepared_hierarchy_solver_provider_from_attrs, ) from pops.time import Program +from tests.python.support.native_execution_context import artifact_execution_context _HIERARCHY_BASE_CELLS = 8 @@ -1227,6 +1228,7 @@ def prepare_program_solve(self): if bound_plasma else None ), + resources={"execution_context": artifact_execution_context(compiled)}, ) bound_register, bound_prepare, bound_execution, bound_solve = ( _external_hierarchy_counters(compiled.so_path) diff --git a/tests/python/unit/codegen/test_coupled_fieldsolve_codegen.py b/tests/python/unit/codegen/test_coupled_fieldsolve_codegen.py index e61cca03e..3e42405e0 100644 --- a/tests/python/unit/codegen/test_coupled_fieldsolve_codegen.py +++ b/tests/python/unit/codegen/test_coupled_fieldsolve_codegen.py @@ -5,9 +5,10 @@ coupled Poisson where EVERY listed block contributes its own stage state at once. Its IR already builds (ADC-426); this test exercises the LOWERING ADC-457 adds: ``_check_lowerable`` no longer refuses it, and ``emit_cpp_program`` -produces one exact-IR ``ctx.solve_fields_from_blocks`` request with each listed block slotted by -index. The context owns and reuses the native pointer/snapshot workspace, so the generated step does -not allocate a host vector while the runtime still sees every coupled stage in one shared phi/aux. +produces one exact-IR ``ctx.solve_fields_from_blocks_at`` request with each listed block slotted by +index. The context reuses the native pointer workspace and the runtime owns the exact state +snapshots, so the generated step does not allocate a host vector while the runtime still sees every +coupled stage in one shared phi/aux. Pure-Python codegen check (always runs when pops.time imports; skips cleanly if _pops is absent). The .so that runs the coupled solve is validated on ROMEO (Kokkos-only AOT, not buildable host-only).""" @@ -106,8 +107,14 @@ def main(): # (2) emit_cpp_program lowers through the context-owned exact-IR workspace seam. src = _emit(P) - chk("ctx.solve_fields_from_blocks(" in src, - "emit contains the coupled multi-block solve call") + chk("ctx.solve_fields_from_blocks_at(field_boundary_point_" in src, + "emit contains the point/provider-qualified coupled multi-block solve call") + chk("const auto field_boundary_point_" in src, + "emit materializes the exact multi-block BoundaryEvaluationPoint") + chk("ctx.set_field_logical_timepoint(" in src and + src.index("ctx.set_field_logical_timepoint(") < + src.index("ctx.solve_fields_from_blocks_at("), + "the exact provider timepoint is installed before the multi-block solve") chk("std::vector" not in src, "the generated step does not allocate a MultiFab pointer vector") @@ -121,13 +128,13 @@ def main(): pos for token in ("ctx.rhs_into(", "ctx.rhs_group(") if (pos := src.find(token)) >= 0 ] - chk(bool(rhs_positions) and src.index("ctx.solve_fields_from_blocks(") < min(rhs_positions), + chk(bool(rhs_positions) and src.index("ctx.solve_fields_from_blocks_at(") < min(rhs_positions), "the coupled field solve is emitted before the RHS reads the shared aux") # (4) a 2-block coupled solve also lowers (parity with the per-block solve_fields path). P2 = coupled_program(t, "coupled_two", ("a", "b")) src2 = _emit(P2) - chk("ctx.solve_fields_from_blocks(" in src2 and + chk("ctx.solve_fields_from_blocks_at(" in src2 and sum(src2.count("{%d, &" % k) for k in range(2)) >= 2, "a 2-block coupled solve lowers with both blocks slotted") diff --git a/tests/python/unit/codegen/test_coupled_implicit_codegen.py b/tests/python/unit/codegen/test_coupled_implicit_codegen.py index 1fb13a566..75a39d24f 100644 --- a/tests/python/unit/codegen/test_coupled_implicit_codegen.py +++ b/tests/python/unit/codegen/test_coupled_implicit_codegen.py @@ -77,8 +77,8 @@ def test_coupled_implicit_uses_one_prepared_provider_with_explicit_action(): assert "Ueval[0] - G_[0] - static_cast(pops::Real(1)) * dt *" in source assert "pops::reduce_max(ci_status_" in source assert "ctx.scalar_scratch(2, 0, u0, 11, 0)" in source - assert "pops::detail::encode_ranked_local_nonlinear_failure(" in source - assert "pops::detail::decode_ranked_local_nonlinear_failure(" in source + assert "pops::collective_first_local_nonlinear_failure(" in source + assert "encode_ranked_local_nonlinear_failure" not in source assert "collective status/location precedence mismatch" in source assert "pops::reduce_sum(ci_status_" in source assert "pops::local_nonlinear_status_priority(solved_.status)" in source diff --git a/tests/python/unit/codegen/test_dsl_brick.py b/tests/python/unit/codegen/test_dsl_brick.py index e2413aba8..fc9652085 100644 --- a/tests/python/unit/codegen/test_dsl_brick.py +++ b/tests/python/unit/codegen/test_dsl_brick.py @@ -58,8 +58,8 @@ def build_exb_brick(): """Transport scalaire par derive E x B (B0=1) : flux qui DEPEND des champs auxiliaires (grad phi). - Sert a verifier que la brique generee emet bien des locals aux (a.grad_x / a.grad_y) dans flux et - max_wave_speed, et reproduit la brique manuelle pops::ExBVelocity{B0=1}.""" + Sert a verifier que la brique generee lit le pack provider exact dans flux et max_wave_speed, + et reproduit la brique manuelle pops::ExBVelocity{B0=1}.""" e = HyperbolicModel("exb") (n,) = e.conservative_vars("n") gx = e.aux("grad_x") @@ -139,8 +139,10 @@ def main(): # (2) brique a flux dependant des AUXILIAIRES (ExB) : les locals aux doivent etre emis dans # flux ET max_wave_speed, et la brique doit egaler pops::ExBVelocity ecrite a la main. exb = build_exb_brick().emit_cpp_brick(name="ExBGen") - assert exb.count("const pops::Real grad_x = a.grad_x;") >= 2, "locals aux absents (flux/vitesse)" - assert "flux(const State& U, const Aux& a, int dir)" in exb, "parametre Aux non nomme dans le flux" + assert exb.count("const pops::Real grad_x = a.template flux_provider<1>();") >= 2, \ + "lectures provider absentes (flux/vitesse)" + assert "flux(const State& U, const auto& a, int dir)" in exb, \ + "parametre provider exact non nomme dans le flux" prog2 = EXB_HARNESS % exb with tempfile.TemporaryDirectory() as tmp: cpp = os.path.join(tmp, "exb.cpp") diff --git a/tests/python/unit/codegen/test_dsl_roe_from_jacobian.py b/tests/python/unit/codegen/test_dsl_roe_from_jacobian.py index 711293961..e6d336980 100644 --- a/tests/python/unit/codegen/test_dsl_roe_from_jacobian.py +++ b/tests/python/unit/codegen/test_dsl_roe_from_jacobian.py @@ -72,7 +72,7 @@ def _nonhyperbolic_roe_model() -> Model: # exact signed spectrum of the same flux Jacobian. Register both explicitly: neither # provider is allowed to stand in for the other or fall back to a scalar radius. model.wave_speeds_from_jacobian() - model.roe_from_jacobian(entropy_fix=1.0e-6) + model.roe_from_jacobian(entropy_fix=riemann.Harten(1.0e-6)) model.rate("transport", equation=ddt(state) == -div(flux)) return model @@ -154,15 +154,48 @@ def test_dense_roe_complex_spectrum_fails_without_rusanov_fallback( def test_roe_dense_spectral_capacity_fails_during_authoring() -> None: boundary = _diagonal_roe_model("dense_roe_boundary", 16) - boundary.roe_from_jacobian(entropy_fix=1.0e-6) + boundary.roe_from_jacobian(entropy_fix=riemann.Harten(1.0e-6)) assert boundary._dsl._m._roe_jacobian is not None too_large = _diagonal_roe_model("dense_roe_too_large", 17) with pytest.raises(DenseSpectralCapacityError) as caught: - too_large.roe_from_jacobian(entropy_fix=1.0e-6) + too_large.roe_from_jacobian(entropy_fix=riemann.Harten(1.0e-6)) assert caught.value.components == 17 assert caught.value.max_components == 16 assert "HLL" in str(caught.value) assert "model.wave_speeds" in str(caught.value) assert "native Roe spectral provider" in str(caught.value) assert too_large._dsl._m._roe_jacobian is None + + +def test_flux_jacobian_roe_emits_generic_characteristic_no_inflow_provider() -> None: + model = _diagonal_roe_model("dense_characteristic_boundary", 2) + model.wave_speeds_from_jacobian() + model.roe_from_jacobian() + source = model._dsl._m.emit_cpp_brick(name="DenseCharacteristicBoundary") + assert "bool characteristic_no_inflow(" in source + assert "pops::characteristic_incoming_apply" in source + assert "outward_sign" in source + assert "Euler" not in source + + +def test_auxiliary_dependent_jacobian_does_not_advertise_characteristic_provider() -> None: + frame = Rectangle( + "aux-characteristic-domain", lower=(0.0, 0.0), upper=(1.0, 1.0) + ).frame(Cartesian2D()) + x_axis, y_axis = frame.axes + model = Model("aux_characteristic_boundary", frame=frame) + state = model.state("U", components=("q",)) + (q,) = state + coefficient = model._dsl._m.aux_field("coefficient") + model.flux( + "transport", + frame=frame, + state=state, + components={x_axis: (coefficient * q,), y_axis: (coefficient * q,)}, + ) + model.wave_speeds_from_jacobian() + model.roe_from_jacobian() + + source = model._dsl._m.emit_cpp_brick(name="AuxCharacteristicBoundary") + assert "bool characteristic_no_inflow(" not in source diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 6b03e1f32..331b6484d 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -70,10 +70,211 @@ def test_mpi_world_route_reports_only_proved_native_availability(supports_mpi, e assert weno.layout == "uniform|amr" assert "ratio-2 2D AMR" in weno.limitation assert "order-5" in weno.limitation + for feature in ("limiter:mc", "limiter:superbee"): + limiter = routes[feature] + assert limiter.status == "available" + assert limiter.backend == "production" + assert limiter.layout == "uniform|amr" + assert "formal_order=2" in limiter.limitation + assert "ghost_depth=2" in limiter.limitation + assert limiter.available_route == "" + assert limiter.alternative == "" amr_implicit = routes["amr:source_implicit_program"] - assert amr_implicit.status == "unavailable" - assert "no temporal fallback" in amr_implicit.limitation + assert amr_implicit.status == "partial" + assert amr_implicit.backend == "production" + assert amr_implicit.mpi is supports_mpi + assert amr_implicit.gpu is False + assert "prepared LocalNewton" in amr_implicit.limitation + assert "SolveOutcome/FailRun rollback is exact" in amr_implicit.limitation + assert "subcycled local solves" in amr_implicit.limitation assert amr_implicit.layout == "amr" + assert amr_implicit.available_route == ( + "generated Program local implicit source solve with LocalNewton and a consumed " + "SolveOutcome on synchronous two-level 2D AMR" + ) + cell_local = routes["amr:cell_local_temporal_transport"] + assert cell_local.status == "partial" + assert cell_local.layout == "amr" + assert cell_local.backend == "production" + assert cell_local.mpi is False + assert cell_local.gpu is False + assert "four time-integrated face records" in cell_local.limitation + assert "Program.cell_local_time" in cell_local.limitation + assert "same-topology restart" in cell_local.limitation + assert "prepared physical-boundary plans" in cell_local.limitation + external_amr = routes["amr:external_field_solver_v2"] + assert external_amr.status == "available" + assert external_amr.layout == "amr" + assert external_amr.mpi is supports_mpi + assert external_amr.gpu is False + assert "ratio-2 AMR" in external_amr.limitation + assert "both components to declare MPI_COMM_WORLD" in external_amr.limitation + assert "distributed coarse level" in external_amr.limitation + assert external_amr.available_route == ( + "authenticated FieldTopology@2 + FieldSolver@2 composite hierarchy batch with " + "metadata.level, binary coarse/fine coverage, one collective solve, exact " + "materialization/report consensus and transactional candidate publication" + ) + implicit_pair = routes["amr:shared_interface_implicit_jacvec_pair"] + assert implicit_pair.status == "partial" + assert implicit_pair.layout == "amr" + assert implicit_pair.backend == "production" + assert implicit_pair.mpi is False + assert implicit_pair.gpu is False + assert "compiles, binds and runs GMRES" in implicit_pair.limitation + assert "independent packed-vector carrier block" in implicit_pair.limitation + assert "dynamic hierarchy mutation" in implicit_pair.limitation + assert implicit_pair.available_route == ( + "generated host/serial GMRES solve with an authenticated two-sided shared-interface " + "JVP on a frozen two-level 2D AMR hierarchy" + ) + assert "additional interfaces, MPI or GPU" in implicit_pair.alternative + + +def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_kernels(): + report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": False, "supports_amr": True}, + source="test-manifest", + ) + routes = {row.feature: row for row in report.routes} + + prepared = routes["boundary:prepared_transport"] + assert prepared.status == "partial" + assert prepared.layout == "uniform|amr" + assert prepared.backend == "production" + assert prepared.mpi is True + assert prepared.gpu is False + assert "one prepared 2D model-aware plan" in prepared.limitation + assert "typed-role slip wall" in prepared.limitation + assert "typed no-flux faces" in prepared.limitation + assert "before divergence/reflux" in prepared.limitation + assert "model primitive-to-conservative" in prepared.limitation + assert "coarse-fine ghosts under the prepared transfer authority" in prepared.limitation + assert "corners explicitly not required" in prepared.limitation + + conversion = routes["boundary:representation_conversion"] + assert conversion.status == "partial" + assert conversion.layout == "uniform|amr" + assert conversion.backend == "production" + assert "to_conservative provider" in conversion.limitation + assert "recovery" in conversion.limitation + + analytic = routes["boundary:analytic_xtp"] + assert analytic.status == "partial" + assert analytic.layout == "uniform|amr" + assert analytic.backend == "production" + assert "analytic ScalarExpr" in analytic.limitation + assert "exact logical Clock" in analytic.limitation + assert "state/field/input reads remain unavailable" in analytic.limitation + assert "axis-permuted periodic coordinates" in analytic.limitation + + characteristic = routes["boundary:characteristic_no_inflow"] + assert characteristic.status == "partial" + assert characteristic.layout == "uniform|amr" + assert characteristic.backend == "production" + assert characteristic.mpi is False and characteristic.gpu is False + assert "m.roe_from_jacobian()" in characteristic.limitation + assert "sonic subspace as neutral" in characteristic.limitation + assert "rolls back ghosts" in characteristic.limitation + post_riemann = routes["boundary:post_riemann_flux"] + assert post_riemann.status == "partial" + assert post_riemann.layout == "uniform|amr" + assert post_riemann.backend == "production" + assert "outward-normal face flux" in post_riemann.limitation + assert "Riemann solve and divergence/reflux" in post_riemann.limitation + assert "2D Cartesian host-batch" in post_riemann.limitation + gpu_report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": True, "supports_amr": True}, + source="test-gpu-manifest", + ) + gpu_post_riemann = { + row.feature: row for row in gpu_report.routes + }["boundary:post_riemann_flux"] + assert gpu_post_riemann.gpu is False + + +def test_riemann_recovery_routes_distinguish_typed_rejection_from_missing_policy(): + report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": False, "supports_amr": True}, + source="test-manifest", + ) + routes = {row.feature: row for row in report.routes} + + typed = routes["riemann:typed_failure_outcome"] + assert typed.status == "partial" + assert typed.layout == "uniform|amr" + assert typed.backend == "production" + assert typed.mpi is True + assert typed.gpu is False + assert "one device-copyable FluxEvaluation" in typed.limitation + assert "requested/used/last solver identity" in typed.limitation + assert "single-solver routes remain explicit" in typed.limitation + assert "fallback counters and restart" in typed.limitation + + policy = routes["riemann:prepared_recovery_policy"] + assert policy.status == "partial" + assert policy.layout == "uniform|amr" + assert policy.backend == "production" + assert policy.mpi is False + assert policy.gpu is False + assert "typed public riemann.Recovery descriptor" in policy.limitation + assert "Uniform and AMR Cartesian face kernels" in policy.limitation + assert "only typed candidate rejection" in policy.limitation + assert "polar geometry is refused" in policy.limitation + assert "GPU qualification" in policy.limitation + assert "riemann.Recovery(primary=Roe()" in policy.available_route + assert "consume rejection through the step retry/failure policy" in policy.alternative + + +def test_variable_recovery_routes_separate_delivered_consumers_from_complete_cutover(): + report = capability_reports.native_capability_report( + flags={"supports_mpi": True, "supports_gpu": False, "supports_amr": True}, + source="test-manifest", + ) + routes = {row.feature: row for row in report.routes} + + prepared = routes["recovery:prepared_variable"] + assert prepared.status == "partial" + assert prepared.layout == "uniform|amr" + assert prepared.backend == "production" + assert prepared.mpi is True + assert prepared.gpu is False + assert "one block-prepared closed-form method" in prepared.limitation + assert "device-copyable RecoveryOutcome/RecoveryReport" in prepared.limitation + assert "selected and last-attempted method kinds" in prepared.limitation + assert "consume publication permission" in prepared.limitation + assert "transactional analytic initial-state materialization" in prepared.limitation + assert "primitive-to-conservative setup conversion" in prepared.limitation + assert "AMR regrid prolongation and restriction" in prepared.limitation + assert "AMR bootstrap commits" in prepared.limitation + assert "rematerialized history slots" in prepared.limitation + assert "physical boundary traces" in prepared.limitation + assert "generated Program terminal commits" in prepared.limitation + assert "model-local and coupled sources" in prepared.limitation + assert "no implicit repair or fallback" in prepared.limitation + assert "generation-qualified warm-start slot per local cell" in prepared.limitation + assert "invalidates every slot after a refused batch" in prepared.limitation + + cutover = routes["recovery:complete_consumer_cutover"] + assert cutover.status == "unavailable" + assert cutover.layout == "uniform|amr" + assert cutover.backend == "none" + assert "manual in-place Program writes" in cutover.limitation + assert "initial and analytic materialization" not in cutover.limitation + assert "fallible primitive-to-conservative conversion" not in cutover.limitation + assert "AMR bootstrap/history transfer" not in cutover.limitation + assert "primitive boundary traces" not in cutover.limitation + assert "persistent warm starts outside the host Uniform diagnostic materializer" in cutover.limitation + assert "transactional analytic initial-state materialization" in cutover.available_route + assert "spatial face reconstruction" in cutover.available_route + assert "fallible primitive-to-conservative setup conversion" in cutover.available_route + assert "transactional AMR regrid prolongation/restriction" in cutover.available_route + assert "bootstrap/history" in cutover.available_route + assert "physical boundary-trace publication" in cutover.available_route + assert "model-local and coupled-source endpoints" in cutover.available_route + assert "generation-qualified warm starts" in cutover.available_route + assert "missing in-place-write, AMR/spatial warm-start" in cutover.alternative + assert cutover.error_message def test_defaults_source_only_is_not_used_for_a_loaded_broken_extension(monkeypatch): diff --git a/tests/python/unit/codegen/test_field_install_plan.py b/tests/python/unit/codegen/test_field_install_plan.py index c013cb22f..7437efc9a 100644 --- a/tests/python/unit/codegen/test_field_install_plan.py +++ b/tests/python/unit/codegen/test_field_install_plan.py @@ -37,6 +37,8 @@ _LAYOUT = Uniform(cartesian_grid(n=16, periodic=False)) _ONE_LEVEL_AMR_LAYOUT = final_amr_layout( cartesian_grid(n=16, periodic=False), max_levels=1) +_MULTILEVEL_AMR_LAYOUT = final_amr_layout( + cartesian_grid(n=16, periodic=False), max_levels=2) class ExternalFieldPlan(Descriptor): @@ -437,6 +439,218 @@ def test_boundary_state_component_and_logical_time_lower_to_direct_provider_pack assert "context.point.time" in source +def test_multilevel_amr_level_local_boundary_state_has_exact_level_route() -> None: + model = Model("amr-level-boundary-model") + state = model.state("U", components=["rho", "momentum"]) + rho, _ = state + unknown = model.field("potential") + operator = model.field_operator( + "potential", unknown=unknown, equation=(-laplacian(unknown) == rho), + outputs=(FieldOutput("potential", unknown),), + ) + problem = Case(name="amr-level-boundary-case") + block = problem.block("material", model) + prepared_rho = boundary_value(block[state], "rho") + problem.field(operator, FieldDiscretization( + method=CellCenteredSecondOrder(), + boundaries=(BoundaryCondition( + AllPhysicalBoundaries(), + Dirichlet(prepared_rho + logical_time("time") + logical_time("stage")), + ),), + solver=GeometricMG(), + hierarchy_policy=LevelByLevelSolve(), + )) + + plan = capture_field_plans( + problem, + lambda value: value, + target="amr_system", + layout=_MULTILEVEL_AMR_LAYOUT, + )["potential"] + + assert plan.native_options["hierarchy_policy"]["policy_id"] == ( + "pops.field-hierarchy.level-local" + ) + dependencies = plan.native_options["boundary_dependencies"] + assert [(row["owner_block"], row["component"]) + for row in dependencies["states"]] == [("material", 0)] + assert dependencies["logical_time"] == ("stage", "time") + dependency_evidence = [ + output + for row in plan.coverage + if "boundary-dependency" in row.source + for output in row.targets + ] + assert dependency_evidence == [ + "field-install:potential:boundary-buffer:states:level-qualified" + ] + from pops.fields._prepared_field_solver_registry import ( + prepared_field_solver_binding_from_data, + ) + + solver_binding = prepared_field_solver_binding_from_data( + plan.native_options["solver_provider"] + ) + assert solver_binding.facts.boundary["state_dependent"] is True + assert solver_binding.provider["use_policy"]["capabilities"][ + "amr_boundary_dependencies" + ] == ( + "level-qualified-state@1", + "level-qualified-field@1", + "logical-timepoint@1", + ) + + from pops.codegen.program_emit_field_boundaries import emit_field_boundaries + + source = emit_field_boundaries(None, None, {"potential": plan}, "amr_system") + assert "context.states[0]->local_index_of(iterate.global_index(li))" in source + assert "context.point.time" in source + assert "context.point.stage_slot" in source + + +def test_multilevel_amr_composite_boundary_state_has_exact_level_route() -> None: + model = Model("amr-composite-boundary-model") + state = model.state("U", components=["rho"]) + (rho,) = state + unknown = model.field("potential") + operator = model.field_operator( + "potential", unknown=unknown, equation=(-laplacian(unknown) == rho), + outputs=(FieldOutput("potential", unknown),), + ) + problem = Case(name="amr-composite-boundary-case") + block = problem.block("material", model) + problem.field(operator, FieldDiscretization( + method=CellCenteredSecondOrder(), + boundaries=(BoundaryCondition( + AllPhysicalBoundaries(), + Dirichlet(boundary_value(block[state], "rho")), + ),), + solver=GeometricMG(fac=CompositeFAC()), + hierarchy_policy=CompositeHierarchySolve(), + )) + + plan = capture_field_plans( + problem, + lambda value: value, + target="amr_system", + layout=_MULTILEVEL_AMR_LAYOUT, + )["potential"] + + assert plan.native_options["hierarchy_policy"]["policy_id"] == ( + "pops.field-hierarchy.composite" + ) + dependencies = plan.native_options["boundary_dependencies"] + assert [(row["owner_block"], row["component"]) + for row in dependencies["states"]] == [("material", 0)] + dependency_evidence = [ + output + for row in plan.coverage + if "boundary-dependency" in row.source + for output in row.targets + ] + assert dependency_evidence == [ + "field-install:potential:boundary-buffer:states:level-qualified" + ] + + +def test_multilevel_amr_level_local_boundary_field_has_exact_level_route() -> None: + model = Model("amr-field-boundary-model") + state = model.state("U", components=["rho"]) + (rho,) = state + driver = model.field("driver") + potential = model.field("potential") + driver_operator = model.field_operator( + "driver", unknown=driver, equation=(-laplacian(driver) == rho), + outputs=(FieldOutput("driver", driver),), + ) + potential_operator = model.field_operator( + "potential", unknown=potential, equation=(-laplacian(potential) == rho), + outputs=(FieldOutput("potential", potential),), + ) + problem = Case(name="amr-field-boundary-case") + problem.block("material", model) + problem.field(driver_operator, FieldDiscretization( + method=CellCenteredSecondOrder(), + boundaries=(BoundaryCondition( + AllPhysicalBoundaries(), Dirichlet(0.0), + ),), + solver=GeometricMG(), + hierarchy_policy=LevelByLevelSolve(), + )) + problem.field(potential_operator, FieldDiscretization( + method=CellCenteredSecondOrder(), + boundaries=(BoundaryCondition( + AllPhysicalBoundaries(), Dirichlet(boundary_value(driver)), + ),), + solver=GeometricMG(), + hierarchy_policy=LevelByLevelSolve(), + )) + + plan = capture_field_plans( + problem, + lambda value: value, + target="amr_system", + layout=_MULTILEVEL_AMR_LAYOUT, + )["potential"] + + dependencies = plan.native_options["boundary_dependencies"] + assert [ + (row["owner_block"], row["output_key"], row["component"]) + for row in dependencies["fields"] + ] == [("material", "driver", 0)] + dependency_evidence = [ + output + for row in plan.coverage + if "boundary-dependency" in row.source + for output in row.targets + ] + assert dependency_evidence == [ + "field-install:potential:boundary-buffer:fields:level-qualified" + ] + + from pops.codegen.program_emit_field_boundaries import emit_field_boundaries + + source = emit_field_boundaries(None, None, {"potential": plan}, "amr_system") + assert "context.fields[0]->local_index_of(iterate.global_index(li))" in source + assert "field0(i, j, 0)" in source + + +def test_multilevel_amr_level_local_nonlinear_boundary_fails_closed() -> None: + from pops.math import ValueExpr + from pops.solvers.nonlinear import Newton + + model = Model("amr-level-nonlinear-boundary-model") + (rho,) = model.state("U", components=["rho"]) + unknown = model.field("potential") + operator = model.field_operator( + "potential", unknown=unknown, equation=(-laplacian(unknown) == rho), + outputs=(FieldOutput("potential", unknown),), + ) + problem = Case(name="amr-level-nonlinear-boundary-case") + problem.block("material", model) + problem.field(operator, FieldDiscretization( + method=CellCenteredSecondOrder(), + boundaries=(BoundaryCondition( + AllPhysicalBoundaries(), + Mixed(alpha=1.0, beta=1.0, value=ValueExpr(unknown) ** 2), + ),), + solver=GeometricMG(), + nonlinear=Newton(), + hierarchy_policy=LevelByLevelSolve(), + )) + + with pytest.raises( + LoweringRejection, + match="no qualified nonlinear transaction", + ): + capture_field_plans( + problem, + lambda value: value, + target="amr_system", + layout=_MULTILEVEL_AMR_LAYOUT, + ) + + def test_boundary_state_value_requires_explicit_component_contract() -> None: from pops.math import ValueExpr diff --git a/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py b/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py index 77eff465c..fc4df3b98 100644 --- a/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py +++ b/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py @@ -11,6 +11,7 @@ from pops.identity.scalar import scalar_cpp, scalar_data from pops.linalg import LinearProblem +from pops.numerics.terms import Flux from pops.params import ConstParam from pops.solvers import CompositeTensorFAC, Hierarchy from pops.time import FailRun, Program @@ -29,7 +30,7 @@ def _coupled_model(name): u = model.primitive("u", mx / rho) v = model.primitive("v", my / rho) pressure = model.primitive("p", cs2 * rho) - model.primitive_vars(rho=rho, u=u, v=v, p=pressure) + model.primitive_vars(rho=rho, u=u, v=v) model.conservative_from([rho, rho * u, rho * v]) model.flux( x=[mx, mx * u + pressure, my * u], @@ -69,6 +70,7 @@ def _build( properties=None, _return_model=False, _nested_hierarchy_solve=False, + _with_interface_pair=False, ): model = _coupled_model("hierarchy_tensor_model") program = Program("hierarchy_tensor_step")._bind_operators(model) @@ -85,6 +87,44 @@ def _build( block, state = state_refs(program, "blk", model=model) temporal = program.state(block[state]) current = temporal.n + if _with_interface_pair: + dummy_r0 = program.rhs( + "dummy_interface_r0", state=dummy_temporal.n, terms=(Flux(),) + ) + block_r0 = program.rhs( + "block_interface_r0", state=current, terms=(Flux(),) + ) + packed_width = ( + len(dummy_temporal.n.space.components) + len(current.space.components) + ) + interface_operator = program.matrix_free_operator( + "coupled_interface_jacobian", + domain="state", + range_="state", + ncomp=packed_width, + ) + + def apply_interface(builder, out, direction): + builder.rhs_jacvec( + out, + direction, + iterate=dummy_temporal.n, + r0=dummy_r0, + c_dt=1, + sources=(), + field_coupled=False, + ) + return builder.rhs_jacvec( + out, + direction, + iterate=current, + r0=block_r0, + c_dt=1, + sources=(), + field_coupled=False, + ) + + program.set_apply(interface_operator, apply_interface) linear = _linear_handle(model) coefficients = program.condensed_coeffs( @@ -151,7 +191,17 @@ def solve_phi(builder): ) next_state = program.value("next", 1 * reconstructed, at=temporal.next.point) program.commit(temporal.next, next_state) - source = emit_cpp_program(program, model=model, target="amr_system") + if _with_interface_pair: + from pops.codegen.program_codegen import _emit_cpp_program_impl + + source = _emit_cpp_program_impl( + program, + model=model, + target="amr_system", + has_shared_interface_implicit_jacvec=True, + ) + else: + source = emit_cpp_program(program, model=model, target="amr_system") if _return_model: return program, source, model return program, source @@ -235,6 +285,20 @@ def test_refined_hierarchy_uses_one_direct_solve_and_flat_path_executes_apply(): assert "hierarchy_solver" not in solve.attrs +def test_resolved_interface_pair_proof_reaches_every_hierarchy_phase(): + _, source = _build( + CompositeTensorFAC(), + _with_interface_pair=True, + ) + + amr = source.split('extern "C" void pops_install_program_amr', 1)[1] + assert source.count("ctx.rhs_jacvec_pair_into_at(") == 1 + gather = amr.index(".gather(hierarchy_dt)") + solve = amr.index("_level_programs->front().solve(hierarchy_dt)", gather) + publish = amr.index(".publish(hierarchy_dt)", solve) + assert gather < solve < publish + + def test_hierarchy_solve_nested_under_control_flow_is_rejected_before_lowering(): with pytest.raises( NotImplementedError, diff --git a/tests/python/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 82ff7e347..afb007115 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -11,7 +11,7 @@ 1 a raw Module with a bodyless codegen operator raises the SAME error through ``lower_and_validate`` as through ``_module_to_model`` (one validation path); 2 a facade Model resolves to its operator-first Module (``source_module``) with NO manual - ``to_module()`` / ``lower()`` and carries a ``module_hash``; + ``lower()`` and carries a ``module_hash``; 3 a facade dependency error is remapped, citing the model name / states / operators; 4 the emit model of a facade Model is BYTE-IDENTICAL through ``lower_and_validate`` vs direct. @@ -33,7 +33,22 @@ from pops._ir.expr import Const # noqa: E402 from pops.physics._facade import Model # noqa: E402 from pops.codegen.module_lowering import ( # noqa: E402 - _module_to_model, lower_and_validate, remap_lowering_error) + _lower_native_role, _module_to_model, lower_and_validate, remap_lowering_error) +from pops.frames import X_AXIS, Z_AXIS # noqa: E402 +from pops.physics import Axial, Density, Momentum, Scalar # noqa: E402 +from pops.physics._coupled_abi import role_canonical # noqa: E402 +from pops.runtime._bricks_time import Role # noqa: E402 + + +def test_module_role_lowering_preserves_typed_boundary_semantics(): + assert _lower_native_role(Density()) == "Density" + assert _lower_native_role(Momentum(axis=X_AXIS)) == "MomentumX" + assert _lower_native_role(Axial(axis=X_AXIS)) == "AxialX" + assert _lower_native_role(Axial(axis=Z_AXIS)) == "AxialZ" + assert _lower_native_role(Scalar()) == "Scalar" + assert _lower_native_role("momentum_y") == "MomentumY" + assert _lower_native_role("Custom") is None + assert role_canonical("AxialZ") == Role.AxialZ == "axial_z" def _facade_model(name="ep"): @@ -85,7 +100,7 @@ def test_one_validation_bodyless_operator_same_error(): assert direct == via_lower, "the SAME error text is raised via both entries (no divergence)" -# --- 2: a facade Model resolves to its operator-first Module with no manual to_module ----------- +# --- 2: a facade Model resolves to its operator-first Module with no manual lower() ------------- def test_facade_model_carries_operator_first_module(): m = _facade_model() diff --git a/tests/python/unit/codegen/test_recovery_admissibility_codegen.py b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py new file mode 100644 index 000000000..16c33a1d9 --- /dev/null +++ b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from pops.physics import Model +from tests.python.support.physics_roles import FRAME, X_AXIS, Y_AXIS + + +def _scalar_model(*, guarded: bool) -> Model: + model = Model("scalar", frame=FRAME) + state = model.state("U", components=("q",)) + (primitive,) = state + model.flux( + "transport", + frame=FRAME, + state=state, + components={X_AXIS: (primitive,), Y_AXIS: (primitive,)}, + waves={X_AXIS: (1,), Y_AXIS: (1,)}, + ) + if guarded: + model.recovery_admissibility(q=primitive > 0) + return model + + +def test_recovery_admissibility_is_emitted_and_hashed() -> None: + guarded = _scalar_model(guarded=True) + plain = _scalar_model(guarded=False) + + generated = guarded._dsl._m.emit_cpp_brick(name="GuardedScalar") + assert "bool recovery_admissible(const Prim& P, int* failing_component_)" in generated + assert "const pops::Real q = P[0];" in generated + assert "*failing_component_ = 0;" in generated + assert "return false;" in generated + assert "recovery_admissible" not in plain._dsl._m.emit_cpp_brick(name="PlainScalar") + assert guarded._dsl._model_hash() != plain._dsl._model_hash() + + +def test_recovery_admissibility_rejects_ambiguous_authoring() -> None: + model = Model("guarded_scalar", frame=FRAME) + + with pytest.raises(ValueError, match=r"primitive_vars\(\.\.\.\) first"): + model.recovery_admissibility(q=1) + + state = model.state("U", components=("q",)) + (primitive,) = state + with pytest.raises(ValueError, match="unknown primitive components"): + model.recovery_admissibility(density=primitive > 0) + with pytest.raises(TypeError, match="typed symbolic Boolean expression"): + model.recovery_admissibility(q=1) + + model.recovery_admissibility(q=primitive > 0) + with pytest.raises(ValueError, match="policy already declared"): + model.recovery_admissibility(q=primitive >= 0) diff --git a/tests/python/unit/codegen/test_representation_arity.py b/tests/python/unit/codegen/test_representation_arity.py new file mode 100644 index 000000000..f74bad3b8 --- /dev/null +++ b/tests/python/unit/codegen/test_representation_arity.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import pytest + +from pops.physics._model import HyperbolicModel + + +def test_generated_model_refuses_unequal_primitive_and_conservative_arities() -> None: + model = HyperbolicModel("unequal_representation_arities") + first, second = model.conservative_vars("first", "second") + model.set_flux([first, second], [first, second]) + model.set_eigenvalues([0.0], [0.0]) + model.set_primitive_state(first) + model.set_conservative_from([first, first]) + + with pytest.raises( + ValueError, + match=( + r"primitive and conservative states must have equal arity " + r"\(got 1 primitive and 2 conservative components\)" + ), + ): + model.emit_cpp_brick() diff --git a/tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py b/tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py index cf4f6067c..a41b9ee45 100644 --- a/tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py +++ b/tests/python/unit/codegen/test_rhs_jacvec_boundary_emit.py @@ -198,9 +198,16 @@ def test_zero_direction_has_a_positive_fallback_step_instead_of_dividing_by_zero def test_field_coupled_apply_restores_the_frozen_provider_after_the_perturbed_rhs(): - source, operator, jacvec, _ = _emit(sources=None, field_coupled=True) + source, operator, jacvec, r0 = _emit(sources=None, field_coupled=True) names = _names(operator, jacvec) apply_source = _apply_source(source, operator) + iterate, fields = r0.inputs + assert r0.op == "rhs" + assert r0.point == iterate.point == fields.point + assert r0.block == iterate.block == fields.block + assert r0.field_context == fields.field_context + assert r0.attrs["flux"] is True + assert r0.attrs["fluxes"] is None assert ( 'const std::string %s = "provider::potential::sha256:exact";' % names["field_slot"] @@ -215,16 +222,31 @@ def test_field_coupled_apply_restores_the_frozen_provider_after_the_perturbed_rh ) ) perturbed_rhs = "ctx.rhs_core_into_at(*%s" % names["point"] - boundary_jvp = "ctx.boundary_jvp_into_at(*%s" % names["point"] + perturbed_boundary = ( + "ctx.boundary_residual_into_at(*%s, 0, *jac_up%d_%d, *%s, " + "*operator_boundary_session%d_0);" + % ( + names["point"], operator.id, jacvec.id, names["boundary_work"], operator.id, + ) + ) + complete_base = ( + "pops::PureFieldAlgebra::axpy(out, jc, *jac_r0%d_%d);" + % (operator.id, jacvec.id) + ) + boundary_guard = "if (%s) {" % names["has_boundary"] assert apply_source.count("ctx.evaluate_with_field_state_at(") == 1 assert "ctx.solve_fields_from_state_at(" not in apply_source transaction_end = apply_source.index("});", apply_source.index(transactional_evaluation)) assert ( apply_source.index(transactional_evaluation) < apply_source.index(perturbed_rhs) + < apply_source.index(boundary_guard, apply_source.index(perturbed_rhs)) + < apply_source.index(perturbed_boundary) < transaction_end - < apply_source.index(boundary_jvp) + < apply_source.index(complete_base) ) + assert "ctx.boundary_jvp_into_at(" not in apply_source + assert "jac_r0_core" not in apply_source assert "ctx.solve_fields_from_state(0, *jac_up" not in apply_source @@ -320,7 +342,9 @@ def test_codegen_defensively_rejects_an_ambiguous_coupled_field_context(): field=object(), stage_sources=((block, iterate.id), (object(), 11)), ) - fields = SimpleNamespace(vtype="fields", field_context=context) + fields = SimpleNamespace( + vtype="fields", field_context=context, block=block, point=point, + ) r0 = SimpleNamespace( op="rhs", inputs=(iterate, fields), @@ -336,3 +360,29 @@ def test_codegen_defensively_rejects_an_ambiguous_coupled_field_context(): ) with pytest.raises(ValueError, match="unambiguous field context"): _validate_matrix_free_contract(jacvec, None) + + +def test_codegen_rejects_a_coupled_base_field_from_another_temporal_point(): + block = object() + point = object() + other_point = object() + iterate = SimpleNamespace(block=block, point=point, id=7) + context = SimpleNamespace(field=object(), stage_sources=((block, iterate.id),)) + fields = SimpleNamespace( + vtype="fields", field_context=context, block=block, point=other_point, + ) + r0 = SimpleNamespace( + op="rhs", + inputs=(iterate, fields), + block=block, + point=point, + attrs={"flux": True, "sources": None, "fluxes": None}, + field_context=context, + ) + jacvec = SimpleNamespace( + op="rhs_jacvec", + inputs=(object(), object(), iterate, r0), + attrs={"field_coupled": True, "flux": True, "sources": None}, + ) + with pytest.raises(ValueError, match="exact block and temporal point"): + _validate_matrix_free_contract(jacvec, None) diff --git a/tests/python/unit/codegen/test_shared_interface_validation.py b/tests/python/unit/codegen/test_shared_interface_validation.py index f77bf6e25..8b15f7f49 100644 --- a/tests/python/unit/codegen/test_shared_interface_validation.py +++ b/tests/python/unit/codegen/test_shared_interface_validation.py @@ -9,6 +9,7 @@ import pytest +from pops.codegen._compile_drivers import _compile_resolved_problem, compile_problem from pops.codegen._interface_validation import validate_shared_interface_program from pops.codegen.program_emit_control import _emit_contiguous_rhs_group from pops.codegen.program_codegen import emit_cpp_program @@ -80,9 +81,9 @@ def _validate( *, target: str = "system", resolved_hierarchy: object | None = None, -) -> None: +) -> tuple[bool, bool]: blocks, layout_plan = _resolved_context() - validate_shared_interface_program( + return validate_shared_interface_program( blocks, layout_plan, program, target=target, resolved_hierarchy=resolved_hierarchy ) @@ -96,6 +97,39 @@ def _paired_flux_program() -> Program: return program +def _implicit_interface_program( + *, paired: bool = True, operator_components: int | None = None +) -> Program: + program = Program("implicit_shared_interface") + left = typed_state(program, "left", state_name="U") + right = typed_state(program, "right", state_name="U") + left_r0 = program.rhs("left_r0", state=left.n, terms=[Flux()]) + right_r0 = program.rhs("right_r0", state=right.n, terms=[Flux()]) + ncomp = operator_components if operator_components is not None else (2 if paired else 1) + operator = program.matrix_free_operator( + "coupled_jacobian", domain="state", range_="state", ncomp=ncomp + ) + + def apply(builder: Program, out: object, direction: object) -> object: + result = builder.rhs_jacvec( + out, direction, iterate=left.n, r0=left_r0, c_dt=1, + sources=(), field_coupled=False, + ) + if paired: + result = builder.rhs_jacvec( + out, direction, iterate=right.n, r0=right_r0, c_dt=1, + sources=(), field_coupled=False, + ) + return result + + program.set_apply(operator, apply) + left_next = program.value("left_next", left.n + program.dt * left_r0, at=left.next.point) + right_next = program.value("right_next", right.n + program.dt * right_r0, at=right.next.point) + program.commit(left.next, left_next) + program.commit(right.next, right_next) + return program + + def _resolved_amr_hierarchy( *, levels: int, program: Program, frozen: bool = True ) -> object: @@ -131,6 +165,92 @@ def test_amr_shared_interface_accepts_two_frozen_levels() -> None: ) +def test_amr_shared_interface_accepts_but_public_emitter_cannot_forge_proof() -> None: + program = _implicit_interface_program() + hierarchy = _resolved_amr_hierarchy(levels=2, program=program) + _, has_shared_interface_implicit_jacvec = _validate( + program, target="amr_system", resolved_hierarchy=hierarchy + ) + assert has_shared_interface_implicit_jacvec is True + + with pytest.raises( + NotImplementedError, + match="authenticated shared-interface implicit-JVP evidence from resolve", + ): + emit_cpp_program(program, target="amr_system") + + +def test_public_emitter_rejects_removed_implicit_pair_boolean_backdoor() -> None: + program = _implicit_interface_program() + + with pytest.raises(TypeError, match="unexpected keyword argument"): + emit_cpp_program( + program, + target="amr_system", + has_shared_interface_implicit_jacvec=True, # type: ignore[call-arg] + ) + + with pytest.raises(TypeError, match="unexpected keyword argument"): + compile_problem( + time=program, + target="amr_system", + has_shared_interface_implicit_jacvec=True, # type: ignore[call-arg] + ) + + with pytest.raises(TypeError, match="unexpected keyword argument"): + _compile_resolved_problem( + object(), + time=program, # type: ignore[call-arg] + ) + + +def test_two_block_jacvec_shape_without_resolved_interface_evidence_fails_closed() -> None: + program = _implicit_interface_program() + + with pytest.raises( + NotImplementedError, + match="authenticated shared-interface implicit-JVP evidence from resolve", + ): + emit_cpp_program(program, target="amr_system") + + +@pytest.mark.parametrize( + ("target", "levels", "frozen", "match"), + [ + ("system", 2, True, "only on a frozen two-level AMR"), + ("amr_system", 1, True, "exactly one frozen two-level"), + ("amr_system", 3, True, "exactly one frozen two-level"), + ("amr_system", 2, False, "exactly one frozen two-level"), + ], +) +def test_shared_interface_jacvec_rejects_unproved_topologies( + target: str, levels: int, frozen: bool, match: str +) -> None: + program = _implicit_interface_program() + hierarchy = ( + None if target == "system" else + _resolved_amr_hierarchy(levels=levels, program=program, frozen=frozen) + ) + with pytest.raises(NotImplementedError, match=match): + _validate(program, target=target, resolved_hierarchy=hierarchy) + + +def test_shared_interface_jacvec_rejects_one_sided_or_wrong_packed_width() -> None: + one_sided = _implicit_interface_program(paired=False) + with pytest.raises(NotImplementedError, match="exactly two rhs_jacvec"): + _validate( + one_sided, target="amr_system", + resolved_hierarchy=_resolved_amr_hierarchy(levels=2, program=one_sided), + ) + + wrong_width = _implicit_interface_program(operator_components=3) + with pytest.raises(ValueError, match="component count must equal the sum"): + _validate( + wrong_width, target="amr_system", + resolved_hierarchy=_resolved_amr_hierarchy(levels=2, program=wrong_width), + ) + + @pytest.mark.parametrize("levels", [2, 3, 4]) def test_amr_shared_interface_accepts_dynamic_refined_regrid(levels: int) -> None: program = _paired_flux_program() diff --git a/tests/python/unit/codegen/test_typed_phase_records.py b/tests/python/unit/codegen/test_typed_phase_records.py index fea24d731..49783c27e 100644 --- a/tests/python/unit/codegen/test_typed_phase_records.py +++ b/tests/python/unit/codegen/test_typed_phase_records.py @@ -112,6 +112,9 @@ def test_resolved_plan_is_exact_deeply_frozen_and_self_authenticating(): plan, source_layout = _resolved_plan() assert not hasattr(plans, "ResolvedPlan") assert plan.plan_identity.domain == "resolved-plan" + assert plan.resolved_dimension == 2 + assert tuple(plan.native_layouts) == tuple( + row.handle.qualified_id for row in plan.layout_plan.layouts) assert dict(plan.compile_values) == {} source_layout["mesh"]["shape"].append(32) @@ -148,6 +151,8 @@ def test_wrong_phase_and_structural_lookalikes_are_rejected(): def test_compiled_artifact_is_one_exact_wrapper_and_rehashes_binaries(tmp_path): artifact, program_path = _artifact(tmp_path) assert artifact.so_path == str(program_path) + assert artifact.resolved_dimension == 2 + assert artifact.native_layouts == artifact.plan.native_layouts assert artifact.inspect.__func__ is CompiledSimulationArtifact.inspect assert artifact.manifest.__func__ is CompiledSimulationArtifact.manifest artifact.verify() diff --git a/tests/python/unit/descriptors/test_descriptor_protocol.py b/tests/python/unit/descriptors/test_descriptor_protocol.py index f53fa91b4..4cd2214bf 100644 --- a/tests/python/unit/descriptors/test_descriptor_protocol.py +++ b/tests/python/unit/descriptors/test_descriptor_protocol.py @@ -146,19 +146,20 @@ def test_brick_descriptor_native_id_carried_in_lowering(): # A native brick lowers with its real C++ symbol; a test-only unavailable route carries none. assert HLL().lower().to_dict()["native_id"] == "pops::HLLFlux" planned = BrickDescriptor( - "mc", "native", category="limiter", native_id="", scheme="mc", available=False) + "planned_limiter", "native", category="limiter", native_id="", + scheme="planned_limiter", available=False) assert planned.lower().to_dict()["native_id"] in (None, "") matrix = planned.capability_matrix() row = matrix.rows[0] assert row.status == "unavailable" - assert "requested limiter:mc" in row.error_message + assert "requested limiter:planned_limiter" in row.error_message try: planned.validate() raise AssertionError("an unavailable descriptor must reject before bind/compile") except ValueError as exc: msg = str(exc) assert "unsupported route" in msg - assert "requested limiter:mc" in msg + assert "requested limiter:planned_limiter" in msg assert "available route" in msg assert "alternative" in msg diff --git a/tests/python/unit/descriptors/test_lib_descriptors.py b/tests/python/unit/descriptors/test_lib_descriptors.py index 8febab47e..1ec99e2a1 100644 --- a/tests/python/unit/descriptors/test_lib_descriptors.py +++ b/tests/python/unit/descriptors/test_lib_descriptors.py @@ -48,6 +48,91 @@ def test_riemann_native_ids_are_exact(): assert lib.riemann.Roe().native_id == "pops::RoeFlux" +def test_riemann_recovery_is_the_exact_prepared_native_policy(): + descriptor = lib.riemann.Recovery( + primary=lib.riemann.Roe(), + fallbacks=(lib.riemann.HLL(), lib.riemann.Rusanov()), + ) + + assert descriptor.brick_type == "native" + assert descriptor.scheme == "roe_hll_rusanov_recovery" + assert descriptor.native_id == ( + "pops::PreparedRiemannRecoveryPolicy" + ) + assert descriptor.options["recovery_order"] == ( + "roe", "hll", "rusanov", "reject" + ) + assert set(descriptor.requirements["capabilities"]) >= { + "physical_flux", "provider_pack", "stability_bound", "wave_speeds", + "roe_dissipation", + } + + +@pytest.mark.parametrize( + ("primary", "fallbacks", "message"), + ( + ("roe", (lib.riemann.HLL(), lib.riemann.Rusanov()), "typed built-in"), + (lib.riemann.Roe(), [lib.riemann.HLL(), lib.riemann.Rusanov()], "requires a tuple"), + ( + lib.riemann.Roe(), + (lib.riemann.HLL(), lib.riemann.HLL()), + "candidates must be unique", + ), + ( + lib.riemann.HLL(), + (lib.riemann.Roe(), lib.riemann.Rusanov()), + "supports exactly primary=Roe()", + ), + ( + lib.riemann.Roe(), + (lib.riemann.HLL(waves=_num.riemann.waves.ExplicitPair()), + lib.riemann.Rusanov()), + "carries candidate options", + ), + ), +) +def test_riemann_recovery_refuses_non_exact_sequences(primary, fallbacks, message): + with pytest.raises((TypeError, ValueError), match=message): + lib.riemann.Recovery(primary=primary, fallbacks=fallbacks) + + +def test_riemann_recovery_refuses_external_and_forged_native_candidates(): + external = lib.BrickDescriptor( + "acme.roe", "external_cpp", category="riemann", native_id="acme_roe", + scheme="roe", + ) + with pytest.raises(ValueError, match="refuses external/non-native"): + lib.riemann.Recovery( + primary=external, + fallbacks=(lib.riemann.HLL(), lib.riemann.Rusanov()), + ) + + forged = lib.BrickDescriptor( + "roe", "native", category="riemann", native_id="pops::RoeFlux", scheme="roe", + requirements={"capabilities": []}, + ) + with pytest.raises(ValueError, match="not the catalog-authenticated"): + lib.riemann.Recovery( + primary=forged, + fallbacks=(lib.riemann.HLL(), lib.riemann.Rusanov()), + ) + + +def test_riemann_recovery_public_validation_refuses_polar_without_substitution(): + descriptor = lib.riemann.Recovery( + primary=lib.riemann.Roe(), + fallbacks=(lib.riemann.HLL(), lib.riemann.Rusanov()), + ) + + availability = lib.riemann.available(descriptor, {"layout": "polar"}) + assert availability.ok is False + assert "catalog polar_ok=false" in availability.reason + assert "no fallback or candidate substitution" in availability.reason + with pytest.raises(ValueError, match="unavailable on annular polar geometry"): + lib.riemann.validate(descriptor, {"layout": "polar"}) + + def test_reconstruction_weno5z_is_native(): d = lib.reconstruction.WENO5Z() assert d.brick_type == "native" @@ -59,7 +144,6 @@ def test_unwired_placeholder_bricks_are_absent_from_final_catalogs(): for catalog, name in ( (lib.fields, "Poisson"), (lib.preconditioners, "Jacobi"), - (lib.limiters, "MC"), ): assert not hasattr(catalog, name) @@ -68,6 +152,13 @@ def test_unwired_placeholder_bricks_are_absent_from_final_catalogs(): assert newton.native_id == "pops::FieldNewtonSolver" +def test_mc_limiter_is_an_executable_native_descriptor(): + descriptor = lib.limiters.MC() + assert descriptor.available().ok is True + assert descriptor.native_id == "pops::MC" + assert descriptor.scheme == "mc" + + def test_available_native_ids_exist_and_are_namespaced(): for d in (lib.fields.GeometricMG(), lib.solvers.CG(max_iter=200), lib.solvers.GMRES(max_iter=200), diff --git a/tests/python/unit/descriptors/test_moments_descriptors.py b/tests/python/unit/descriptors/test_moments_descriptors.py index ca5694efe..756b593cb 100644 --- a/tests/python/unit/descriptors/test_moments_descriptors.py +++ b/tests/python/unit/descriptors/test_moments_descriptors.py @@ -140,6 +140,10 @@ def test_handles_are_not_descriptors(): def test_moment_model_has_no_transport_noop_surface(): specification = moments.CartesianVelocityMoments(order=2) assert not hasattr(specification, "add_transport") + assert callable(specification.build) + assert not hasattr( + specification, "check" + ), "MomentModel.build() is the sole model-construction route" def test_moment_transport_blocks_follow_the_canonical_directional_chains(): diff --git a/tests/python/unit/domain/test_cartesian_domain_grid.py b/tests/python/unit/domain/test_cartesian_domain_grid.py index ea19d99de..b0fbc60cd 100644 --- a/tests/python/unit/domain/test_cartesian_domain_grid.py +++ b/tests/python/unit/domain/test_cartesian_domain_grid.py @@ -18,7 +18,7 @@ RectangleBoundaryNames, RectangleFrame, ) -from pops.frames import Cartesian2D, CartesianAxis, CartesianDirection +from pops.frames import Cartesian2D, CartesianAxis, CartesianDirection, Z_AXIS from pops.mesh.grid import CartesianGrid, PeriodicAxes @@ -38,9 +38,12 @@ def test_cartesian_axes_are_typed_immutable_and_canonical() -> None: assert y is frame.y assert (x.direction, x.index, x.name) == (CartesianDirection.X, 0, "x") assert (y.direction, y.index, y.name) == (CartesianDirection.Y, 1, "y") + assert (Z_AXIS.direction, Z_AXIS.index, Z_AXIS.name) == (CartesianDirection.Z, 2, "z") + assert Z_AXIS not in frame.axes assert len({x, y}) == 2 assert Cartesian2D.from_dict(frame.to_dict()) == frame assert CartesianAxis.from_dict(x.to_dict()) == x + assert CartesianAxis.from_dict(Z_AXIS.to_dict()) == Z_AXIS assert json.loads(json.dumps(frame.to_dict())) == frame.to_dict() with pytest.raises(FrozenInstanceError): diff --git a/tests/python/unit/fields/test_external_field_solver_provider.py b/tests/python/unit/fields/test_external_field_solver_provider.py index bf2d21293..6accdad77 100644 --- a/tests/python/unit/fields/test_external_field_solver_provider.py +++ b/tests/python/unit/fields/test_external_field_solver_provider.py @@ -11,9 +11,11 @@ from pops.external import build_source_package_manifest, load from pops.fields import ( CellCenteredSecondOrder, + CompositeHierarchySolve, ExternalFieldSolver, FieldDiscretization, FieldOutput, + LevelByLevelSolve, ) from pops.fields.bcs import AllPhysicalBoundaries, BoundaryCondition, Dirichlet from pops.layouts import Uniform @@ -21,12 +23,12 @@ from pops.model import ComponentManifest from pops.physics import Model from pops.problem import Case -from tests.python.support.layout_plan import cartesian_grid +from tests.python.support.layout_plan import cartesian_grid, final_amr_layout def _component( tmp_path, *, name, interface, source_suffix=b"", dimension=2, - manifest_parameters=(), instance_parameters=None, + manifest_parameters=(), instance_parameters=None, features=(), device="cpu", ): root = tmp_path / name root.mkdir(parents=True) @@ -44,8 +46,8 @@ def _component( target={"variants": [{ "dimension": dimension, "scalar": "float64", - "device": "cpu", - "features": [], + "device": device, + "features": list(features), }]}, entry_points={"interface_table": "pops_component_interface_v1"}, ) @@ -60,7 +62,7 @@ def _component( return factory(**({} if instance_parameters is None else instance_parameters)) -def _case(solver): +def _case(solver, *, hierarchy_policy=None): model = Model("external-field-solver-model") (rho,) = model.state("U", components=("rho",)) unknown = model.field("potential") @@ -72,11 +74,15 @@ def _case(solver): ) case = Case("external-field-solver-case") case.block("material", model) + options = {} + if hierarchy_policy is not None: + options["hierarchy_policy"] = hierarchy_policy case.field(operator, FieldDiscretization( method=CellCenteredSecondOrder(), boundaries=(BoundaryCondition( AllPhysicalBoundaries(), Dirichlet(0.0)),), solver=solver, + **options, )) return case @@ -120,6 +126,31 @@ def test_external_pair_survives_field_lowering_with_exact_component_authorities( plan.native_options["solver_provider"] ) assert external.provider["provider_id"] == "pops.fields.external-field-solver" + assert external.provider["version"] == 2 + provider_authority = external.to_data()["provider"] + assert provider_authority["use_policy"] == { + "policy_id": "pops.fields.external-field-solver.use", + "version": 4, + "capabilities": { + "provider_id": "pops.fields.external-field-solver", + "provider_version": 2, + "adapter_identity": ("pops.fields.external-field-solver.system-amr-host@2"), + "targets": ["system", "amr_system"], + "layout_kinds": ["uniform", "amr"], + "max_levels": None, + "refinement_ratios": [2], + "hierarchy_policies": [ + "pops.field-hierarchy.level-local", + "pops.field-hierarchy.composite", + ], + "abi_patch_level_metadata": True, + "hierarchy_materialization": True, + "amr_provider_bridge": True, + "binary_coarse_fine_coverage": True, + "execution": "host-serial-or-declared-mpi-hierarchy-batch", + "components": ["FieldTopology@2", "FieldSolver@2"], + }, + } topology_binding, solver_binding = plan.component_bindings() assert topology_binding["component_id"] == topology.component_manifest.component_id assert solver_binding["component_id"] == solver.component_manifest.component_id @@ -131,6 +162,17 @@ def test_external_pair_survives_field_lowering_with_exact_component_authorities( assert external.resolution.native_contract["schema_identity"] == ( "pops.external.field-solver-request@2" ) + assert external.resolution.native_contract["provider_id"] == external.provider["provider_id"] + assert external.resolution.topology_contract["hierarchy_policy"]["policy_id"] == ( + "pops.field-hierarchy.level-local" + ) + assert provider.to_data()["provider"] == provider_authority + capabilities = provider.capabilities().to_dict() + assert capabilities["provider"] == provider_authority + assert capabilities["adapter"] == provider_authority["use_policy"]["capabilities"] + assert capabilities["supports_amr"] is True + assert capabilities["max_levels"] is None + assert capabilities["refinement_ratios"] == (2,) plan.require_component_inputs((topology, solver)) # Artifact state is recursively immutable, but the Python/native boundary must receive an @@ -197,15 +239,81 @@ def test_external_pair_canonicalizes_nested_parameters_without_weakening_identit plan.require_component_inputs((topology, substituted_solver)) -def test_external_field_solver_v2_refuses_amr_during_resolve(tmp_path): +def test_external_field_solver_v2_resolves_one_composite_amr_hierarchy(tmp_path): + provider, topology, solver = _provider(tmp_path) + + plan = capture_field_plans( + _case(provider, hierarchy_policy=CompositeHierarchySolve()), + lambda value: value, + target="amr_system", + layout=final_amr_layout(cartesian_grid(n=8, periodic=False), max_levels=3, ratio=2), + )["potential"] + + assert plan.native_options["hierarchy_policy"]["policy_id"] == ( + "pops.field-hierarchy.composite" + ) + layout = plan.native_options["solver_provider"]["facts"]["layout"] + assert layout["kind"] == "amr" + assert layout["levels"] == 3 + plan.require_component_inputs((topology, solver)) + + +def test_external_field_solver_v2_refuses_level_local_amr(tmp_path): + provider, _topology, _solver = _provider(tmp_path) + + with pytest.raises(LoweringRejection, match="supports only hierarchy policy") as error: + capture_field_plans( + _case(provider, hierarchy_policy=LevelByLevelSolve()), + lambda value: value, + target="amr_system", + layout=final_amr_layout(cartesian_grid(n=8, periodic=False), max_levels=2, ratio=2), + ) + assert error.value.gate == "field.solver.provider_incompatible" + + +def test_external_field_solver_v2_refuses_non_binary_amr_ratio(tmp_path): + provider, _topology, _solver = _provider(tmp_path) + + with pytest.raises(LoweringRejection, match="requires one ratio-2 transition") as error: + capture_field_plans( + _case(provider, hierarchy_policy=CompositeHierarchySolve()), + lambda value: value, + target="amr_system", + layout=final_amr_layout( + cartesian_grid(n=8, periodic=False), max_levels=2, ratio=4 + ), + ) + assert error.value.gate == "field.solver.provider_incompatible" + + +def test_external_field_solver_reports_mpi_only_when_both_host_variants_declare_it(tmp_path): + topology = _component( + tmp_path, name="topology_mpi", interface=interfaces.FieldTopology, features=("mpi",)) + solver = _component( + tmp_path, name="solver_serial", interface=interfaces.FieldSolver) + provider = ExternalFieldSolver(topology=topology, solver=solver) + assert provider.capabilities().to_dict()["mpi"] is False + assert provider.capabilities().to_dict()["component_pair_declares_mpi"] is False + + solver_mpi = _component( + tmp_path, name="solver_mpi", interface=interfaces.FieldSolver, features=("mpi",)) + mpi_provider = ExternalFieldSolver(topology=topology, solver=solver_mpi) + assert mpi_provider.capabilities().to_dict()["mpi"] is True + assert mpi_provider.capabilities().to_dict()["gpu"] is False + + +def test_external_field_solver_refuses_unsupported_hierarchy_policy_at_resolve(tmp_path): provider, _topology, _solver = _provider(tmp_path) - with pytest.raises(LoweringRejection, match="hierarchy-aware") as error: + with pytest.raises(LoweringRejection, match="supports only hierarchy policy") as error: capture_field_plans( - _case(provider), lambda value: value, target="amr_system", + _case(provider, hierarchy_policy=CompositeHierarchySolve()), + lambda value: value, + target="system", layout=Uniform(cartesian_grid(n=8, periodic=False)), ) assert error.value.gate == "field.solver.provider_incompatible" + assert "pops.field-hierarchy.composite" in str(error.value) def test_external_solver_and_topology_roles_are_not_interchangeable(tmp_path): diff --git a/tests/python/unit/fields/test_field_residual_solve_contract.py b/tests/python/unit/fields/test_field_residual_solve_contract.py index 75074417b..0edf26e14 100644 --- a/tests/python/unit/fields/test_field_residual_solve_contract.py +++ b/tests/python/unit/fields/test_field_residual_solve_contract.py @@ -44,6 +44,7 @@ BoundaryHandle, BoundaryOrientation, BoundaryProvider, + BoundaryProviderKind, BoundarySide, BoundaryTopology, CharacteristicClosure, @@ -109,7 +110,8 @@ def _provider(region, name, dependencies): return BoundaryProvider( _h("%s_provider" % name, "boundary_provider", CASE), (ConstraintResidual(boundary, dependencies.iterate, representation),), - provider_dependencies) + provider_dependencies, + BoundaryProviderKind.CONSTRAINT_RESIDUAL) def _contribution(cls, region, name, dependencies): diff --git a/tests/python/unit/mesh/test_boundary_topology_ports.py b/tests/python/unit/mesh/test_boundary_topology_ports.py index 602cd7648..fc9531e51 100644 --- a/tests/python/unit/mesh/test_boundary_topology_ports.py +++ b/tests/python/unit/mesh/test_boundary_topology_ports.py @@ -10,6 +10,7 @@ BoundaryHandle, BoundaryOrientation, BoundaryProvider, + BoundaryProviderKind, BoundaryProviderRegistry, BoundarySide, BoundaryTopology, @@ -30,6 +31,7 @@ Outflow, PeriodicIdentification, PeriodicOrientation, + PostRiemannFlux, RepresentationFlow, SignDependence, SonicPolicy, @@ -104,6 +106,10 @@ def _provider_handle(name): return Handle(name, kind="boundary_provider", owner=OwnerPath.case("main")) +def _flux_provider_handle(name): + return Handle(name, kind="boundary_flux_provider", owner=OwnerPath.case("main")) + + def _case_instance(case_name): return (OwnerPath.case(case_name) .child(OwnerKind.BLOCK, "transport") @@ -143,6 +149,21 @@ def test_topology_serializes_explicit_periodic_identification_and_physical_parti assert json.loads(json.dumps(topology.inspect())) == topology.inspect() +def test_topology_serializes_axis_permuted_xlo_to_yhi_identification(): + x_min, x_max, y_min, y_max = _boundaries() + periodic = PeriodicIdentification( + x_min, y_max, PeriodicOrientation((1, 0), (1, 1))) + topology = BoundaryTopology( + OwnerPath.case("main"), (x_min, x_max, y_min, y_max), + (periodic,), (x_max, y_min)) + + row = topology.canonical_identity()["periodic"][0] + assert row["source"]["orientation"]["axis"] == 0 + assert row["target"]["orientation"]["axis"] == 1 + assert row["orientation"] == { + "schema_version": 1, "permutation": [1, 0], "signs": [1, 1]} + + def test_topology_fails_loud_on_missing_double_extra_and_periodic_physical(): x_min, x_max, y_min, y_max = _boundaries() periodic = PeriodicIdentification( @@ -274,6 +295,17 @@ def test_named_provider_factories_are_data_only_and_port_typed(): dependencies=dependencies), ) assert all(type(row) is BoundaryProvider for row in providers) + assert [row.kind for row in providers] == [ + BoundaryProviderKind.INFLOW, + BoundaryProviderKind.OUTFLOW, + BoundaryProviderKind.GHOST_FORMULA, + BoundaryProviderKind.DIRICHLET, + BoundaryProviderKind.NEUMANN, + BoundaryProviderKind.MIXED, + ] + assert [row.canonical_identity()["provider_kind"] for row in providers] == [ + row.kind.value for row in providers + ] assert all(not hasattr(row, "callback") for row in providers) with pytest.raises(TypeError, match="typed ConstraintResidual"): Mixed(handle=_provider_handle("bad_mixed"), outputs=(ghost,), @@ -289,6 +321,8 @@ def test_noflux_satisfies_numerical_flux_only(): provider = NoFlux( handle=_provider_handle("no_flux"), output=flux, dependencies=_dependencies()) assert provider.outputs == (flux,) + assert provider.kind is BoundaryProviderKind.NO_FLUX + assert provider.canonical_identity()["provider_kind"] == "no_flux" assert BoundaryProviderRegistry(provider).resolve(_topology(), (flux,)).bindings with pytest.raises(TypeError, match="NumericalFlux only"): NoFlux(handle=_provider_handle("bad_no_flux"), output=ghost, @@ -296,6 +330,53 @@ def test_noflux_satisfies_numerical_flux_only(): with pytest.raises(ValueError, match="missing boundary provider"): BoundaryProviderRegistry().resolve(_topology(), (ghost,)) + with pytest.raises(TypeError, match="BoundaryProviderKind"): + BoundaryProvider( + _provider_handle("untyped_flux"), (flux,), _dependencies(), "no_flux") + with pytest.raises(TypeError, match="typed NumericalFlux"): + BoundaryProvider( + _provider_handle("forged_flux"), (ghost,), _dependencies(), + BoundaryProviderKind.NO_FLUX) + + +def test_post_riemann_flux_has_one_exact_typed_component_route(): + boundary = _topology().physical[0] + state, _, _ = _model_values() + _, conservative = _representations() + flux = NumericalFlux(boundary, state, conservative) + provider = PostRiemannFlux( + handle=_flux_provider_handle("wall_flux"), + output=flux, + dependencies=_dependencies(), + ) + + assert provider.outputs == (flux,) + assert provider.kind is BoundaryProviderKind.POST_RIEMANN_FLUX + assert provider.handle.kind == "boundary_flux_provider" + assert provider.canonical_identity()["provider_kind"] == "post_riemann_flux" + assert BoundaryProviderRegistry(provider).resolve(_topology(), (flux,)).bindings + + +def test_post_riemann_flux_refuses_wrong_component_route_or_output(): + boundary = _topology().physical[0] + state, _, _ = _model_values() + _, conservative = _representations() + flux = NumericalFlux(boundary, state, conservative) + ghost = GhostState(boundary, state, conservative) + + with pytest.raises(TypeError, match="boundary_flux_provider"): + PostRiemannFlux( + handle=_provider_handle("wrong_component_route"), + output=flux, + dependencies=_dependencies(), + ) + with pytest.raises(TypeError, match="NumericalFlux only"): + PostRiemannFlux( + handle=_flux_provider_handle("wrong_output"), + output=ghost, + dependencies=_dependencies(), + ) + def test_resolution_diagnostics_cover_missing_double_extra_ambiguous_and_periodic_physical(): topology = _topology() @@ -399,6 +480,7 @@ def test_every_semantic_field_is_immutable(): (dependencies, "time", ()), (dependencies, "runtime_params", ()), (dependencies, "representation", _dependencies().representation), (dependencies, "characteristic", _none_closure()), + (provider, "kind", BoundaryProviderKind.INFLOW), (provider, "handle", _provider_handle("other")), (provider, "outputs", ()), (provider, "dependencies", _dependencies()), (registry, "providers", ()), (plan.bindings[0], "need", port), (plan.bindings[0], "provider", provider), diff --git a/tests/python/unit/mesh/test_ghost_producer_plan.py b/tests/python/unit/mesh/test_ghost_producer_plan.py index 49a012bc2..b30b6c458 100644 --- a/tests/python/unit/mesh/test_ghost_producer_plan.py +++ b/tests/python/unit/mesh/test_ghost_producer_plan.py @@ -37,11 +37,13 @@ InterfaceSide, InterfaceTraceOperation, MultiBlockInterface, + NumericalFlux, NumericalClosure, PeriodicGhost, PeriodicIdentification, PeriodicOrientation, PhysicalGhost, + PostRiemannFlux, RepresentationFlow, SameLevelHaloMPI, SignDependence, @@ -350,6 +352,16 @@ def _physical_provider(boundary, name): dependencies=_none_dependencies()) +def _post_riemann_provider(boundary, name): + state = _h("U", "state", OwnerPath.model("transport")) + representation = _h("conservative", "representation") + return PostRiemannFlux( + handle=_h(name, "boundary_flux_provider", CASE), + output=NumericalFlux(boundary, state, representation), + dependencies=_none_dependencies(), + ) + + def _interface( topology, *, trace_provider="limiter.none", trace_operation=InterfaceTraceOperation.CELL_AVERAGE, required_depth=1): @@ -439,6 +451,47 @@ def test_all_explicit_producer_protocols_and_shared_interface_flux(): (GhostProduction(wrong_region, physical),)) +def test_physical_ghost_composes_trace_then_exact_post_riemann_flux_provider(): + topology = _topology() + boundary = topology.physical[0] + trace = _physical_provider(boundary, "wall_trace") + flux = _post_riemann_provider(boundary, "wall_flux") + producer = PhysicalGhost( + handle=_producer_handle("physical_flux"), + protocol=_protocol("physical"), + provider=trace, + flux_provider=flux, + ) + + assert set(producer.boundary_providers) == {trace, flux} + region = _region("physical_flux", boundary=boundary) + plan = GhostProducerRegistry(producer).resolve( + topology, + _coverage(region), + (region,), + (GhostProduction(region, producer),), + execution_authority=_ExecutableBoundaryAuthority(), + ) + with pytest.raises(NotImplementedError, match="BoundaryFlux components"): + plan.compile_boundary_data() + + other_face = next(row for row in topology.physical if row != boundary) + with pytest.raises(ValueError, match="same exact face"): + PhysicalGhost( + handle=_producer_handle("wrong_face"), + protocol=_protocol("physical"), + provider=trace, + flux_provider=_post_riemann_provider(other_face, "other_flux"), + ) + with pytest.raises(TypeError, match="PostRiemannFlux"): + PhysicalGhost( + handle=_producer_handle("wrong_law"), + protocol=_protocol("physical"), + provider=trace, + flux_provider=_physical_provider(boundary, "second_trace"), + ) + + @pytest.mark.parametrize( ("trace_provider", "required_depth"), (("limiter.minmod", 2), ("limiter.weno5", 3)), diff --git a/tests/python/unit/mesh/test_layout_plan.py b/tests/python/unit/mesh/test_layout_plan.py index 954ef7bf1..8ed8c9c5b 100644 --- a/tests/python/unit/mesh/test_layout_plan.py +++ b/tests/python/unit/mesh/test_layout_plan.py @@ -13,6 +13,7 @@ LayoutRepresentation, LayoutSynchronization, LayoutPlanBuilder, + NativeSpatialLayout, NormalizedGeometry, ResolvedLayoutMapping, normalize_layout_plan, @@ -298,10 +299,103 @@ def test_normalized_geometry_is_exact_detached_and_delegated_by_uniform_and_amr( assert uniform.geometry.upper == (2.5, 2.5) assert uniform.geometry.cells == (8, 8) assert uniform.to_data()["geometry"] == adaptive.to_data()["geometry"] + assert uniform.native_spatial_layout is not None + assert adaptive.native_spatial_layout is not None + assert uniform.native_spatial_layout.dimension == 2 + assert uniform.native_spatial_layout.shape == uniform.geometry.cells + assert uniform.native_spatial_layout.periodicity == (True, True) + assert uniform.native_spatial_layout.decomposition["kind"] == "single_box" + assert adaptive.native_spatial_layout.decomposition["kind"] == "adaptive" with pytest.raises(AttributeError): uniform.geometry.cells = (16, 16) +def test_native_spatial_layout_round_trip_and_identity_cover_every_spatial_fact(): + row = normalize_layout_plan( + Uniform(cartesian_grid(n=8)), owner=OwnerPath.case("native-spatial")).layouts[0] + native = row.native_spatial_layout + assert native is not None + assert NativeSpatialLayout.from_data(native.to_data()) == native + + data = native.to_data() + data["periodicity"][0] = False + data.pop("identity") + changed = NativeSpatialLayout( + layout_id=data["layout_id"], + coordinate_system=data["coordinate_system"], + cell_measure=data["cell_measure"], + axis_names=tuple(data["axis_names"]), + shape=tuple(data["shape"]), + lower=tuple(float.fromhex(value) for value in data["lower"]), + upper=tuple(float.fromhex(value) for value in data["upper"]), + periodicity=tuple(data["periodicity"]), + centering=data["centering"], + decomposition=data["decomposition"], + ) + assert changed.identity != native.identity + + forged = native.to_data() + forged["dimension"] = 3 + with pytest.raises(ValueError, match="dimension does not match shape"): + NativeSpatialLayout.from_data(forged) + + +def test_native_dimension_refuses_structurally_before_artifact_creation(): + class ThreeDimensionalLayout: + name = "three-dimensional" + + def validate(self): + return True + + def capabilities(self): + return {"levels": 1, "supports_amr": False, "transition_ratios": []} + + def options(self): + return {} + + def requirements(self): + return {} + + def normalized_geometry(self): + return NormalizedGeometry( + "pops://coordinates/test-3d@1", + "pops://cell-measures/test-volume@1", + ("x", "y", "z"), + (0.0, 0.0, 0.0), + (1.0, 1.0, 1.0), + (4, 5, 6), + ) + + def native_spatial_data(self): + return { + "schema_version": 1, + "periodicity": [True, False, True], + "centering": "cell", + "decomposition": { + "schema_version": 1, + "kind": "single_box", + "boxes": [{"lower": [0, 0, 0], "upper_exclusive": [4, 5, 6]}], + }, + } + + plan = normalize_layout_plan( + ThreeDimensionalLayout(), owner=OwnerPath.case("three-dimensional")) + assert plan.layouts[0].native_spatial_layout.dimension == 3 + + from pops.codegen._layout_resolution import ( + LayoutCapabilityError, + resolve_native_spatial_layouts, + ) + + with pytest.raises(LayoutCapabilityError) as error: + resolve_native_spatial_layouts(plan) + assert error.value.evidence["gate"] == "native_dimension_unavailable" + assert error.value.evidence["refusal"]["evidence"] == { + "resolved_dimension": 3, + "supported_dimensions": [2], + } + + def test_normalized_geometry_protocol_is_called_twice_and_must_be_deterministic(): class FlakyLayout: name = "flaky" diff --git a/tests/python/unit/moments/test_hyqmom15_final_contract.py b/tests/python/unit/moments/test_hyqmom15_final_contract.py index 022d8071a..58162afc3 100644 --- a/tests/python/unit/moments/test_hyqmom15_final_contract.py +++ b/tests/python/unit/moments/test_hyqmom15_final_contract.py @@ -20,11 +20,25 @@ from pops.domain import RectangleFrame from pops.frames import Cartesian2D from pops.physics import Model -from pops.time import ProjectAndRecheck, RejectAttempt +from pops.time import ALL_PROVISIONAL_STORES, ProjectAndRecheck, RejectAttempt ROOT = Path(__file__).resolve().parents[4] EXAMPLE = ROOT / "examples/final/EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py" +_STANDARDIZED_SAMPLE = { + "S03": -0.2, + "S04": 2.8, + "S11": 0.15, + "S12": -0.35, + "S13": 0.42, + "S20": 1.0, + "S21": 0.25, + "S22": 1.2, + "S30": 0.3, + "S31": -0.1, + "S40": 3.1, + "S02": 1.0, +} def test_moment_flux_generator_is_public_for_explicit_python_models() -> None: @@ -83,22 +97,7 @@ def wrong_order(_standardized): def test_hyqmom15_closure_matches_closure_s5_matlab_oracle() -> None: """Pin the six non-Gaussian polynomial relations used by closureS5.m.""" - standardized = { - "S03": -0.2, - "S04": 2.8, - "S11": 0.15, - "S12": -0.35, - "S13": 0.42, - "S20": 1.0, - "S21": 0.25, - "S22": 1.2, - "S30": 0.3, - "S31": -0.1, - "S40": 3.1, - "S02": 1.0, - } - - closed = HyQMOM15Closure()(standardized) + closed = HyQMOM15Closure()(_STANDARDIZED_SAMPLE) assert closed == pytest.approx({ "S50": 2.1345, @@ -148,12 +147,22 @@ def test_final_authoring_derives_field_storage_and_complete_generic_program() -> target = _load_example().build_authoring() assert type(target.model) is Model + assert type(target.closure) is LocalClosure + assert target.closure.contract_data() == { + "kind": "local_moment_closure", + "order": 4, + "name": "user_hyqmom15_closure", + } + assert target.closure(_STANDARDIZED_SAMPLE) == pytest.approx( + HyQMOM15Closure()(_STANDARDIZED_SAMPLE) + ) assert isinstance(target.model.frame, RectangleFrame) assert target.components == tuple(moment_names(4)) assert target.model.field_spaces()[target.field.local_id].components == ( "phi", "grad_x", "grad_y") assert target.field_provider == target.model.operators["fields"] assert target.program.transaction_plan() is not None + assert target.program.transaction_plan().stores == ALL_PROVISIONAL_STORES guards = target.program.transaction_plan().guards assert [guard.name for guard in guards] == [ "hyqmom15_realizability_density", @@ -169,6 +178,36 @@ def test_final_authoring_derives_field_storage_and_complete_generic_program() -> assert projection.kind == "projection" +def test_particle_number_diagnostic_integrates_m00_and_rejects_drift() -> None: + example = _load_example() + target = example.build_authoring() + state = example.build_initial_state(cells=4)["plasma"] + reference = example._particle_number(state) + + assert reference == pytest.approx(1.0) + diagnostics = example._require_physical_diagnostics( + state, + projection=target.realizability, + reference_particle_number=reference, + where="initial state", + ) + assert diagnostics.realizable is True + assert diagnostics.particle_number == pytest.approx(reference) + assert diagnostics.particle_number_relative_error == pytest.approx(0.0) + + drifted = state.copy() + drifted[HyQMOM15.components.index("M00")] += ( + 2.0 * example.PARTICLE_NUMBER_RELATIVE_TOLERANCE + ) + with pytest.raises(RuntimeError, match="changed particle number"): + example._require_physical_diagnostics( + drifted, + projection=target.realizability, + reference_particle_number=reference, + where="drifted state", + ) + + def test_hyqmom15_projection_checks_all_moments_and_refuses_to_manufacture_density() -> None: example = _load_example() target = example.build_authoring() diff --git a/tests/python/unit/numerics/test_external_reconstruction_contract.py b/tests/python/unit/numerics/test_external_reconstruction_contract.py index 61b5f2eb5..0f90093ed 100644 --- a/tests/python/unit/numerics/test_external_reconstruction_contract.py +++ b/tests/python/unit/numerics/test_external_reconstruction_contract.py @@ -70,13 +70,17 @@ def test_builtin_reconstruction_contract_is_derived_from_generated_routes() -> N WENO5Z, authenticated_reconstruction_route, ) - from pops.numerics.reconstruction.limiters import Minmod, VanLeer + from pops.numerics.reconstruction.limiters import MC, Minmod, Superbee, VanLeer for descriptor, token, native_id, order, depth in ( (FirstOrder(), "none", "pops::NoSlope", 1, 1), (Minmod(), "minmod", "pops::Minmod", 2, 2), (VanLeer(), "vanleer", "pops::VanLeer", 2, 2), + (MC(), "mc", "pops::MC", 2, 2), + (Superbee(), "superbee", "pops::Superbee", 2, 2), (MUSCL(VanLeer()), "vanleer", "pops::VanLeer", 2, 2), + (MUSCL(MC()), "mc", "pops::MC", 2, 2), + (MUSCL(Superbee()), "superbee", "pops::Superbee", 2, 2), (WENO5(), "weno5", "pops::Weno5", 5, 3), (WENO5Z(), "weno5", "pops::Weno5", 5, 3), ): diff --git a/tests/python/unit/numerics/test_finite_volume_composite.py b/tests/python/unit/numerics/test_finite_volume_composite.py index 6a0080f23..5c256815b 100644 --- a/tests/python/unit/numerics/test_finite_volume_composite.py +++ b/tests/python/unit/numerics/test_finite_volume_composite.py @@ -34,6 +34,12 @@ def _model(*, hllc=False, roe=False, wave_speeds=True, n_vars=3, prim_names=list(prim_names), n_vars=n_vars, gamma=None, n_aux=3, params={}, caps={}, abi_key="", model_hash="", cxx="c++", std="23", hllc=hllc, roe=roe, wave_speeds=wave_speeds, + hllc_provider="fluid_roles_v1" if hllc else None, + roe_provider="fluid_roles_v1" if roe else None, + roe_entropy_policy="harten_v1" if roe else None, + roe_entropy_delta=( + '{"kind":"binary64","value":"0x1.999999999999ap-4"}' if roe else None + ), wave_speed_provider=("explicit_pair" if wave_speeds else None)) diff --git a/tests/python/unit/output/test_async_scientific_output_diagnostics.py b/tests/python/unit/output/test_async_scientific_output_diagnostics.py new file mode 100644 index 000000000..66be0b765 --- /dev/null +++ b/tests/python/unit/output/test_async_scientific_output_diagnostics.py @@ -0,0 +1,451 @@ +"""Diagnostics carried by AsyncScientificOutput are captured before post-commit dispatch.""" +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +import threading + +import pytest + +from pops.codegen._compiled_artifact import CompiledSimulationArtifact +from pops.codegen._plans import BindInputs, InstallPlan +from pops.diagnostics import Balance, BalanceLedger, Integral +from pops.identity import Identity, make_identity +from pops.layouts import Uniform +from pops.mesh import normalize_layout_plan +from pops.model import Handle, OwnerKind, OwnerPath +from pops.output import ( + AsyncScientificOutput, + ConsumerGraph, + NPZ, + OutputPublicationReceipt, + ParallelMode, +) +from pops.output._consumer_authoring import ConsumerAuthoringNode +from pops.output._consumer_contracts import ( + ConsumerKind, + ConsumerManifest, + DiagnosticQuantity, +) +from pops.output._restart_provider import RestartAuthority +from pops.output._writers.common import writer_session_authority +from pops.problem.handles import BlockHandle +from pops.runtime._runtime_consumers import RuntimeConsumerPublisher +from pops.runtime._runtime_instance import RuntimeInstance +from pops.time import Clock, every +from tests.python.support.layout_plan import cartesian_grid +from tests.python.support.native_execution_context import artifact_execution_context +from tests.python.unit.runtime.test_consumer_authoring import _case +from tests.python.unit.runtime.test_runtime_instance_gate import ( + _Executor, + _install, + _scientific_output_mode, +) + + +def _resolved_async_balance(): + case, block, state = _case() + clock = Clock("async-balance", owner=case.owner_path) + schedule = every(2, clock=clock) + ledger = BalanceLedger("async-mass") + descriptor = AsyncScientificOutput( + format=NPZ(), + schedule=schedule, + diagnostics=(Balance(ledger, block=block, cadence=schedule),), + target="async/balance", + ) + graph = ConsumerGraph.from_consumers((descriptor,)) + case.consumers(graph) + import pops + + pops.validate(case) + subjects = case.layout_subjects() + layout = normalize_layout_plan( + Uniform(cartesian_grid(n=8)), + owner=case.owner_path.canonical(), + states=subjects.states, + fields=subjects.fields, + blocks=subjects.blocks, + handle_resolver=case.resolve, + ) + return ( + descriptor, + graph.resolve(case.resolve, layout, owner=case.owner_path.canonical()), + block, + case.resolve(block), + case.resolve(state), + schedule, + ledger, + ) + + +def test_async_scientific_output_accepts_diagnostic_only_and_resolves_balance(): + descriptor, graph, declared_block, block, state, schedule, ledger = ( + _resolved_async_balance() + ) + (manifest,) = graph.nodes + + assert descriptor.fields == () + assert descriptor.declaration_references() == (declared_block,) + assert manifest.kind is ConsumerKind.MONITOR + assert manifest.quantities == () + assert manifest.operation_data["observer"]["observer_kind"] == "async_scientific_output" + assert manifest.schedule == schedule + (quantity,) = manifest.diagnostic_quantities + assert quantity.reference == state + assert quantity.levels == (0,) + assert quantity.execution["operations"] == ( + { + "name": "balance", + "reduction": "accepted_balance", + "transform": "identity", + "metric_weighted": False, + "coefficient": (1.0).hex(), + "balance_route": ledger.route_identity(block).token, + }, + ) + + +def test_async_scientific_output_requires_a_field_or_diagnostic_and_matching_cadence(): + case, block, state = _case() + clock = Clock("async-validation", owner=case.owner_path) + schedule = every(2, clock=clock) + + with pytest.raises(ValueError, match="at least one field or diagnostic"): + AsyncScientificOutput( + format=NPZ(), + schedule=schedule, + target="async/empty", + ) + with pytest.raises(ValueError, match="must use the same schedule"): + AsyncScientificOutput( + format=NPZ(), + schedule=schedule, + diagnostics=( + Integral(block=block, cadence=every(3, clock=clock)), + ), + target="async/cadence-mismatch", + ) + + descriptor = AsyncScientificOutput( + format=NPZ(), + schedule=schedule, + fields=(state,), + diagnostics=(Integral(block=block, cadence=schedule),), + target="async/field-and-diagnostic", + ) + assert descriptor.declaration_references() == (state, block) + assert descriptor.options()["n_diagnostics"] == 1 + + +class _NonScientificObserver: + __pops_ir_immutable__ = True + + def consumer_data(self): + return { + "schema_version": 1, + "provider_id": "pops.test.forged-async-scientific-observer.v1", + "observer_kind": "async_scientific_output", + } + + def open_session(self, _execution_context): + raise AssertionError("authoring validation must not open an observer session") + + +def test_generic_monitor_cannot_smuggle_diagnostic_providers(): + from pops.output import AllLevels, LiveVisualization + + case, block, state = _case() + clock = Clock("generic-monitor", owner=case.owner_path) + schedule = every(1, clock=clock) + live = LiveVisualization( + observer=_NonScientificObserver(), + schedule=schedule, + fields=(state,), + ) + operation = live.consumer_authoring()[0].operation + + with pytest.raises(ValueError, match="only AsyncScientificOutput"): + ConsumerAuthoringNode( + label="invalid-monitor-diagnostic", + kind=ConsumerKind.MONITOR, + references=(state,), + schedule=schedule, + target_uri="live", + output_format=None, + parallel_mode=ParallelMode.SERIAL, + levels=AllLevels(), + operation=operation, + diagnostics=(Integral(block=block),), + ) + + _, graph, _, _, resolved_state, _, _ = _resolved_async_balance() + (valid_async_manifest,) = graph.nodes + forged_operation = LiveVisualization( + observer=_NonScientificObserver(), + schedule=valid_async_manifest.schedule, + fields=(resolved_state,), + ).consumer_authoring()[0].operation + with pytest.raises(ValueError, match="only ConsoleMonitor, ScientificOutput"): + replace(valid_async_manifest, operation=forged_operation) + + +class _CapturingWriterSession: + def __init__(self, owner, request, target: Path) -> None: + self.authority = writer_session_authority("capturing-async", request, target) + self.identity = Identity.from_token(self.authority["session_identity"]) + self._owner = owner + self._request = request + self._target = target + + def stage(self): + self._owner.writer_started.set() + if not self._owner.release_writer.wait(timeout=10): + raise TimeoutError("capturing async writer was not released") + + def abort_prepare(self): + return None + + def publish(self): + self._target.parent.mkdir(parents=True, exist_ok=True) + self._target.write_bytes(b"captured detached diagnostics\n") + return OutputPublicationReceipt( + self._target, + "capturing-async", + make_identity( + "scientific-output", + {"selection": self._request.publication_identity.token}, + ), + self._request.publication_identity, + ) + + def rollback(self): + self._target.unlink(missing_ok=True) + + def finalize(self): + return None + + +class _CapturingWriter: + format = "capturing-async" + + def __init__(self, owner) -> None: + self._owner = owner + + def preflight(self, _execution_context): + return {"schema_version": 1, "provider_id": "capturing-async"} + + def prepare_session(self, snapshot, request, target, *, communicator=None): + assert communicator is None + self._owner.worker_threads.append(threading.current_thread().name) + self._owner.snapshots.append(snapshot) + return _CapturingWriterSession(self._owner, request, Path(target)) + + +class _CapturingFormat: + __pops_ir_immutable__ = True + + def __init__(self, mode: ParallelMode) -> None: + self.mode = mode + self.writer_started = threading.Event() + self.release_writer = threading.Event() + self.worker_threads: list[str] = [] + self.snapshots = [] + + def consumer_data(self): + return { + "schema_version": 1, + "provider_id": "pops.test.capturing-async.v1", + "format_name": "capturing-async", + "extension": ".capture", + "parallel_mode": self.mode.value, + } + + def writer(self): + return _CapturingWriter(self) + + +class _BalanceExecutor(_Executor): + def __init__(self, plan): + super().__init__(plan) + self.mailbox_open = True + self.mailbox_calls: list[tuple[str, str]] = [] + + def _accepted_balance_terms(self, route): + if not self.mailbox_open: + raise RuntimeError("post-commit worker attempted to read the native balance mailbox") + self.mailbox_calls.append((threading.current_thread().name, route)) + return { + "storage_change": 7.0, + "outward_boundary_flux": 2.0, + "sources": 3.0, + "reflux": 1.0, + "projection": 0.5, + } + + +def test_selected_native_balance_forwards_exact_owner_coordinates(): + class _SelectedExecutor: + def __init__(self): + self.call = None + + def _selected_accepted_balance_terms( + self, route, block, component, levels, automatic_terms + ): + self.call = (route, block, component, levels, automatic_terms) + return { + "storage_change": 7.0, + "outward_boundary_flux": 2.0, + "sources": 3.0, + "reflux": 1.0, + "projection": 0.5, + } + + executor = _SelectedExecutor() + terms = RuntimeConsumerPublisher._native_balance_terms( + executor, + "route", + block="fluid", + component=2, + levels=(0, 1), + automatic_terms=("projection", "reflux"), + ) + + assert executor.call == ( + "route", + "fluid", + 2, + [0, 1], + ["projection", "reflux"], + ) + assert terms.residual == pytest.approx(4.5) + + +def _async_balance_runtime(tmp_path: Path): + base = _install() + mode = _scientific_output_mode(base.artifact) + layout = base.artifact.layout_plan.layouts[0] + block_subject = next( + assignment.subject + for assignment in base.artifact.layout_plan.assignments + if assignment.subject_kind == "block" + ) + block = BlockHandle( + block_subject.local_id, + owner=block_subject.owner_path, + model_owner=OwnerPath.model("adc-686-balance-fixture"), + ) + state = Handle( + "rho", + kind="state", + owner=block.owner_path.child(OwnerKind.BLOCK, block.local_id), + ) + clock = Clock("detached-async-balance", owner=OwnerPath.consumer("adc-686")) + schedule = every(1, clock=clock) + ledger = BalanceLedger("detached-async-balance") + balance = Balance(ledger, block=block, cadence=schedule) + format_provider = _CapturingFormat(mode) + descriptor = AsyncScientificOutput( + format=format_provider, + schedule=schedule, + diagnostics=(balance,), + target="detached-balance", + ) + node = descriptor.consumer_authoring()[0] + consumer = Handle("detached-balance", kind="consumer", owner=OwnerPath.consumer("adc-686")) + diagnostic = DiagnosticQuantity( + Handle( + "balance", + kind="diagnostic", + owner=consumer.owner_path.child( + OwnerKind.DESCRIPTOR, consumer.local_id + ).child(OwnerKind.DESCRIPTOR, "diagnostics"), + ), + state, + "state:fluid", + layout.handle.qualified_id, + (0,), + node.diagnostics[0].diagnostic_execution(), + ) + manifest = ConsumerManifest( + consumer, + ConsumerKind.MONITOR, + (), + schedule, + "detached-balance", + None, + mode, + operation=node.operation, + diagnostics=node.diagnostics, + diagnostic_quantities=(diagnostic,), + ) + graph = ConsumerGraph((manifest,)) + record = replace( + base.artifact.plan, + consumer_graph=graph, + restart_authority=RestartAuthority.from_consumer_graph(graph), + ) + artifact = CompiledSimulationArtifact( + record, + base.artifact.program, + base.artifact.blocks, + ) + inputs = BindInputs() + plan = InstallPlan( + artifact=artifact, + bind_inputs=inputs, + instances={ + installed.name: {"model": installed.model, "spatial": installed.spatial} + for installed in artifact.blocks + }, + params=artifact.bind_schema.resolve_bind( + {}, compile_values=artifact.plan.compile_values + ), + aux={}, + execution_context=artifact_execution_context(artifact), + ) + executor = _BalanceExecutor(plan) + runtime = RuntimeInstance(plan, executor=executor) + return runtime, executor, format_provider, manifest, ledger.route_identity(block).token + + +def test_async_worker_receives_detached_balance_payload_without_reopening_mailbox(tmp_path): + runtime, executor, format_provider, manifest, route = _async_balance_runtime(tmp_path) + reports = [] + failures = [] + + def run(): + try: + reports.append(runtime._run(t_end=1.0, max_steps=1, output_dir=tmp_path)) + except BaseException as error: # noqa: BLE001 - report worker/run failures together + failures.append(error) + + runner = threading.Thread(target=run, name="adc686-balance-runner", daemon=False) + runner.start() + assert format_provider.writer_started.wait(timeout=5) + assert executor.mailbox_calls == [("adc686-balance-runner", route)] + + executor.mailbox_open = False + format_provider.release_writer.set() + runner.join(timeout=10) + + assert not runner.is_alive() + assert failures == [] + assert len(reports) == 1 and reports[0].accepted_steps == 1 + assert len(format_provider.snapshots) == 1 + assert len(format_provider.worker_threads) == 1 + assert format_provider.worker_threads[0] != "adc686-balance-runner" + (payload,) = format_provider.snapshots[0].diagnostics + assert payload.value == pytest.approx(4.5) + assert dict(payload.terms) == { + "storage_change": 7.0, + "outward_boundary_flux": 2.0, + "sources": 3.0, + "reflux": 1.0, + "projection": 0.5, + } + assert executor.mailbox_calls == [("adc686-balance-runner", route)] + accepted = runtime.inspect().to_dict()["instance"]["accepted_diagnostics"] + assert len(accepted) == 1 + assert accepted[0]["value"] == (4.5).hex() + assert runtime.consumer_cursors.for_consumer(manifest.qualified_id).committed_samples == 1 diff --git a/tests/python/unit/output/test_exact_writers.py b/tests/python/unit/output/test_exact_writers.py index 7fe6c4ae7..dc3872068 100644 --- a/tests/python/unit/output/test_exact_writers.py +++ b/tests/python/unit/output/test_exact_writers.py @@ -2617,19 +2617,15 @@ def flock(descriptor, operation): def test_generic_series_policy_excludes_paraview_pvd_collections(): + import inspect + assert HDF5().series is True assert NPZ().series is True assert ParaView().series is False assert ParaView().series_catalog() is None - with pytest.warns(DeprecationWarning, match="collection"): - enabled = ParaView(series=True) - assert enabled.collection is True - with pytest.warns(DeprecationWarning, match="collection"): - disabled = ParaView(series=False) - assert disabled.collection is False - with pytest.warns(DeprecationWarning, match="collection"): - with pytest.raises(ValueError, match="disagree"): - ParaView(collection=True, series=False) + assert "series" not in inspect.signature(ParaView).parameters + assert ParaView(collection=True).collection is True + assert ParaView(collection=False).collection is False def test_format_writers_publish_structural_preflight_capabilities(): diff --git a/tests/python/unit/output/test_post_commit_observers.py b/tests/python/unit/output/test_post_commit_observers.py index d74fb2d0e..c7d830aa5 100644 --- a/tests/python/unit/output/test_post_commit_observers.py +++ b/tests/python/unit/output/test_post_commit_observers.py @@ -1,4 +1,5 @@ """Isolated contract tests for bounded post-commit observers and optional Catalyst 2.""" + from __future__ import annotations import threading @@ -9,7 +10,7 @@ import pytest from pops._geometry_contracts import POLAR_ANNULUS_2D_COORDINATES -from pops.identity import make_identity +from pops.identity import Identity, make_identity from pops.model import Handle, OwnerKind, OwnerPath from pops.output._catalyst_backend import CatalystPythonProvider from pops.output._consumer_contracts import ParallelMode @@ -30,6 +31,7 @@ ObserverFrame, ObserverReceipt, ObserverRun, + ObserverWorkerCollectiveLost, detach_observer_frame, ) from pops.time import AcceptedStep, Clock, Every, Schedule @@ -46,14 +48,62 @@ def _identity(domain: str, name: str): return make_identity(domain, {"name": name}) +def _close_worker_for_test(worker: PostCommitObserverWorker) -> None: + """Guarantee that a fail-once close test cannot leak its non-daemon thread.""" + + failure = None + for _attempt in range(2): + if worker.close_succeeded: + return + try: + worker.close() + except RuntimeError as error: + failure = error + if not worker.close_succeeded: + raise RuntimeError("test cleanup could not close post-commit worker") from failure + + +def _cancel_prepared_queue_calls_for_test(queue: PostCommitObserverQueue) -> None: + """Release every unarmed lifecycle gate before its shared worker is joined.""" + + cleanup_error = RuntimeError("test cleanup cancelled an unresolved lifecycle call") + queue.cancel_initialize(cleanup_error) + queue.cancel_complete_close(cleanup_error) + queue.cancel_complete_abort_close(cleanup_error) + + +def _close_private_queue_for_test( + queue: PostCommitObserverQueue, + *, + abort: bool = False, +) -> None: + """Retry the deliberately fail-once private queue cleanup used by these tests.""" + + failure = None + close = queue.abort_close if abort else queue.close + for _attempt in range(2): + if queue.close_succeeded: + return + try: + close() + except RuntimeError as error: + failure = error + if not queue.close_succeeded: + raise RuntimeError("test cleanup could not close private observer queue") from failure + + def test_observer_report_has_an_exact_byte_free_collective_projection(): from pops._native_collectives import decode_value, encode_value frame = _frame() - receipt = ObserverReceipt(frame.identity, "test.observer", { - "opaque": b"\x00\xff", - "nested": {"values": [b"abc", 7, True, None]}, - }) + receipt = ObserverReceipt( + frame.identity, + "test.observer", + { + "opaque": b"\x00\xff", + "nested": {"values": [b"abc", 7, True, None]}, + }, + ) report = ObserverDeliveryReport( "test-consumer", frame.snapshot.provenance.run_identity, @@ -72,7 +122,10 @@ def test_observer_report_has_an_exact_byte_free_collective_projection(): def _frame( - *, mode: ParallelMode = ParallelMode.SERIAL, centering: str = "cell", + *, + run_identity: Identity | None = None, + mode: ParallelMode = ParallelMode.SERIAL, + centering: str = "cell", native_geometry_arrays=None, coordinate_system: str = "pops://coordinates/cartesian-2d@1", origin=(0.0, 0.0), @@ -112,19 +165,19 @@ def _frame( (0, 0), spatial_shape, np.arange(1, 1 + spatial_shape[0] * spatial_shape[1], dtype=np.float64).reshape( - (1,) + spatial_shape), + (1,) + spatial_shape + ), 0, 0, False, ) - field = FieldPayload( - key, centering, "K", component_names, spatial_shape, (piece,)) + field = FieldPayload(key, centering, "K", component_names, spatial_shape, (piece,)) snapshot = OutputSnapshot( OutputClock.at("macro", 0.25, 4, stage="accepted"), OutputProvenance( _identity("resolved-plan", "plan"), _identity("bind", "bind"), - _identity("run", "run"), + _identity("run", "run") if run_identity is None else run_identity, "accepted-step-transaction", ), (geometry,), @@ -132,7 +185,8 @@ def _frame( {"test": "post-commit-observer"}, ) request = OutputRequest( - "live-temperature", (key,), mode, rank=0, size=(1 if mode is ParallelMode.SERIAL else 2)) + "live-temperature", (key,), mode, rank=0, size=(1 if mode is ParallelMode.SERIAL else 2) + ) return ObserverFrame(snapshot, request) @@ -223,6 +277,12 @@ def initialize(self, node): raise RuntimeError("Catalyst allocated state then failed") +class _FailingFinalizeCatalyst(_CatalystModule): + def finalize(self, node): + self.operations.append(("finalize", node)) + raise RuntimeError("injected Catalyst finalize failure") + + class _BlueprintMesh: def __init__(self): self.verified_domains = [] @@ -230,10 +290,10 @@ def __init__(self): def verify(self, domain, _info): self.verified_domains.append(domain.prefix) paths = domain.values - return all(any( - candidate.startswith(domain.prefix + suffix) - for candidate in paths - ) for suffix in ("/coordsets/", "/topologies/", "/fields/")) + return all( + any(candidate.startswith(domain.prefix + suffix) for candidate in paths) + for suffix in ("/coordsets/", "/topologies/", "/fields/") + ) class _ConduitModule: @@ -263,8 +323,7 @@ def _collective_context( ): import pops._native_collectives as native_collectives - world = SimpleNamespace( - identity="MPI_COMM_WORLD", active=True, rank=0, size=world_size) + world = SimpleNamespace(identity="MPI_COMM_WORLD", active=True, rank=0, size=world_size) lane = SimpleNamespace( identity="MPI_COMM_WORLD/observer/catalyst-test", active=True, @@ -289,11 +348,13 @@ def allgather_value(communicator, value): rows = [dict(value, rank=owner) for owner in range(lane.size)] if peer_error is not None and len(rows) > 1 and "error" in rows[1]: rows[1]["error"] = peer_error - if divergent_initialize_authority and len(rows) > 1 \ - and isinstance(rows[1].get("value"), dict) \ - and "pipeline_sha256" in rows[1]["value"]: - rows[1]["value"] = dict( - rows[1]["value"], pipeline_sha256="0" * 64) + if ( + divergent_initialize_authority + and len(rows) > 1 + and isinstance(rows[1].get("value"), dict) + and "pipeline_sha256" in rows[1]["value"] + ): + rows[1]["value"] = dict(rows[1]["value"], pipeline_sha256="0" * 64) return tuple(rows) monkeypatch.setattr(native_collectives, "require_world", require_world) @@ -302,8 +363,7 @@ def allgather_value(communicator, value): monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) monkeypatch.setattr(native_collectives, "allgather_value", allgather_value) return ( - SimpleNamespace(communicator=SimpleNamespace( - identity="MPI_COMM_WORLD", handle=world)), + SimpleNamespace(communicator=SimpleNamespace(identity="MPI_COMM_WORLD", handle=world)), lane, agreements, ) @@ -387,26 +447,30 @@ def test_optional_real_catalyst_provider_executes_blueprint_lifecycle(tmp_path: frame = _frame() run = ObserverRun(frame.snapshot.provenance.run_identity, {"case": "heat"}) - with PostCommitObserverQueue( - session, run, consumer_id="live-temperature") as dispatcher: + with PostCommitObserverQueue(session, run, consumer_id="live-temperature") as dispatcher: assert dispatcher.submit(frame) == 0 reports = dispatcher.flush() assert len(reports) == 1 assert reports[0].status == "delivered" assert [operation for operation, _ in catalyst_module.operations] == [ - "initialize", "execute", "finalize"] + "initialize", + "execute", + "finalize", + ] assert catalyst_module.operations[0][1].values["catalyst/async/enabled"] == 0 - assert catalyst_module.operations[0][1].values[ - "catalyst_load/implementation"] == "paraview" - assert catalyst_module.operations[0][1].values[ - "catalyst_load/search_paths"] == [str(tmp_path.resolve())] - assert catalyst_module.operations[0][1].values[ - "catalyst/scripts/pops/args"] == ["--extract=volume"] + assert catalyst_module.operations[0][1].values["catalyst_load/implementation"] == "paraview" + assert catalyst_module.operations[0][1].values["catalyst_load/search_paths"] == [ + str(tmp_path.resolve()) + ] + assert catalyst_module.operations[0][1].values["catalyst/scripts/pops/args"] == [ + "--extract=volume" + ] execute_node = catalyst_module.operations[1][1] paths = execute_node.values temperature_prefixes = [ - path.removesuffix("/display_name") for path, value in paths.items() + path.removesuffix("/display_name") + for path, value in paths.items() if path.endswith("/display_name") and value == "temperature" ] assert len(temperature_prefixes) == 1 @@ -415,23 +479,25 @@ def test_optional_real_catalyst_provider_executes_blueprint_lifecycle(tmp_path: np.asarray([1.0, 2.0, 3.0, 4.0]), ) assert any( - path.endswith("/display_name") and value == "vtkGhostType" - for path, value in paths.items()) + path.endswith("/display_name") and value == "vtkGhostType" for path, value in paths.items() + ) level_prefix = next( - path.removesuffix("/display_name") for path, value in paths.items() + path.removesuffix("/display_name") + for path, value in paths.items() if path.endswith("/display_name") and value == "pops_level" ) layout_prefix = next( - path.removesuffix("/display_name") for path, value in paths.items() + path.removesuffix("/display_name") + for path, value in paths.items() if path.endswith("/display_name") and value == "pops_layout" ) assert np.array_equal(paths[level_prefix + "/values"], np.zeros(4, dtype=np.int32)) assert np.array_equal(paths[layout_prefix + "/values"], np.zeros(4, dtype=np.int32)) assert paths["catalyst/channels/mesh/type"] == "multimesh" ghost_metadata = [ - value for path, value in paths.items() - if "/state/metadata/vtk_fields/" in path - and path.endswith("/attribute_type") + value + for path, value in paths.items() + if "/state/metadata/vtk_fields/" in path and path.endswith("/attribute_type") ] assert ghost_metadata == ["Ghosts"] assert len(conduit_module.blueprint.mesh.verified_domains) == 1 @@ -454,9 +520,11 @@ def test_collective_catalyst_publishes_empty_mesh_when_rank_owns_no_geometry_box ) execution_context, worker_lane, _agreements = _collective_context(monkeypatch) session = Catalyst( - pipeline=str(pipeline), provider=provider, + pipeline=str(pipeline), + provider=provider, ).open_runtime_session( - {"worker_communicator": worker_lane}, execution_context, + {"worker_communicator": worker_lane}, + execution_context, ) frame = _without_local_pieces(_frame(mode=ParallelMode.COLLECTIVE)) @@ -469,13 +537,17 @@ def test_collective_catalyst_publishes_empty_mesh_when_rank_owns_no_geometry_box data_path = "catalyst/channels/mesh/data" assert execute_node.fetched == [data_path] child_paths = { - path: value for path, value in execute_node.values.items() + path: value + for path, value in execute_node.values.items() if path.startswith(data_path + "/") } - assert any(path.endswith("/topologies/mesh_000000/type") and value == "uniform" - for path, value in child_paths.items()) + assert any( + path.endswith("/topologies/mesh_000000/type") and value == "uniform" + for path, value in child_paths.items() + ) empty_arrays = [ - value for path, value in child_paths.items() + value + for path, value in child_paths.items() if "/fields/" in path and path.endswith("/values") ] assert len(empty_arrays) == 6 @@ -494,14 +566,18 @@ def test_collective_catalyst_rejects_unproved_polar_empty_peer( ) execution_context, worker_lane, _agreements = _collective_context(monkeypatch) session = Catalyst( - pipeline=str(pipeline), provider=provider, + pipeline=str(pipeline), + provider=provider, ).open_runtime_session( - {"worker_communicator": worker_lane}, execution_context, + {"worker_communicator": worker_lane}, + execution_context, + ) + frame = _without_local_pieces( + _frame( + mode=ParallelMode.COLLECTIVE, + coordinate_system=POLAR_ANNULUS_2D_COORDINATES, + ) ) - frame = _without_local_pieces(_frame( - mode=ParallelMode.COLLECTIVE, - coordinate_system=POLAR_ANNULUS_2D_COORDINATES, - )) session.initialize(ObserverRun(frame.snapshot.provenance.run_identity)) with pytest.raises( @@ -544,7 +620,7 @@ def test_catalyst_rejects_environment_loader_precedence(tmp_path: Path, monkeypa declaration.open_session(_serial_context()) -def test_catalyst_partial_initialize_is_finalized_exactly_once(tmp_path: Path): +def test_catalyst_partial_initialize_defers_finalize_to_explicit_abort(tmp_path: Path): pipeline = tmp_path / "partial_initialize.py" pipeline.write_text("# partial initialize cleanup\n") catalyst_module = _PartiallyFailingInitializeCatalyst() @@ -563,8 +639,39 @@ def test_catalyst_partial_initialize_is_finalized_exactly_once(tmp_path: Path): consumer_id="partial-catalyst-initialize", ) + assert [operation for operation, _node in catalyst_module.operations] == ["initialize"] + + session.abort() + assert [operation for operation, _node in catalyst_module.operations] == [ + "initialize", + "finalize", + ] + + +def test_catalyst_abort_never_reports_success_after_finalize_backend_failure( + tmp_path: Path, +): + pipeline = tmp_path / "abort_finalize_failure.py" + pipeline.write_text("# abort must retain failed Catalyst finalization\n") + catalyst_module = _FailingFinalizeCatalyst() + session = Catalyst( + pipeline=str(pipeline), + provider=CatalystPythonProvider( + catalyst_module=catalyst_module, + conduit_module=_ConduitModule(), + ), + ).open_session(_serial_context()) + session.initialize(ObserverRun(_identity("run", "catalyst-abort-finalize-failure"))) + + with pytest.raises(RuntimeError, match="injected Catalyst finalize failure"): + session.abort() + with pytest.raises(RuntimeError, match="cannot retry failed finalization"): + session.abort() + assert [operation for operation, _node in catalyst_module.operations] == [ - "initialize", "finalize"] + "initialize", + "finalize", + ] def test_catalyst_rejects_stub_implementation_acknowledgement(tmp_path: Path): @@ -586,8 +693,13 @@ def test_catalyst_rejects_stub_implementation_acknowledgement(tmp_path: Path): consumer_id="stub-rejected", ) + assert [operation for operation, _node in catalyst_module.operations] == ["initialize"] + + session.abort() assert [operation for operation, _node in catalyst_module.operations] == [ - "initialize", "finalize"] + "initialize", + "finalize", + ] def test_catalyst_maps_polar_annulus_to_explicit_cartesian_quads(tmp_path: Path): @@ -613,16 +725,22 @@ def test_catalyst_maps_polar_annulus_to_explicit_cartesian_quads(tmp_path: Path) paths = catalyst_module.operations[1][1].values topology_types = [ - value for path, value in paths.items() - if "/topologies/" in path and path.endswith("/type") + value for path, value in paths.items() if "/topologies/" in path and path.endswith("/type") ] assert topology_types == ["unstructured"] - x = next(value for path, value in paths.items() - if "/coordsets/" in path and path.endswith("/values/x")) - y = next(value for path, value in paths.items() - if "/coordsets/" in path and path.endswith("/values/y")) - connectivity = next(value for path, value in paths.items() - if path.endswith("/elements/connectivity")) + x = next( + value + for path, value in paths.items() + if "/coordsets/" in path and path.endswith("/values/x") + ) + y = next( + value + for path, value in paths.items() + if "/coordsets/" in path and path.endswith("/values/y") + ) + connectivity = next( + value for path, value in paths.items() if path.endswith("/elements/connectivity") + ) assert np.allclose(x[:3], [1.0, 1.5, 2.0]) assert np.allclose(y[:3], [0.0, 0.0, 0.0]) assert np.array_equal(connectivity[:4], [0, 1, 4, 3]) @@ -644,21 +762,25 @@ def test_catalyst_uses_same_block_disambiguated_names_as_paraview_files(tmp_path "accepted", ) keys.append(key) - fields.append(FieldPayload( - key, - "cell", - "kg/m3", - ("rho",), - geometry.cell_shape, - (ArrayPiece( - (0, 0), - (2, 2), - np.full((1, 2, 2), index + 1.0), - 0, - 0, - False, - ),), - )) + fields.append( + FieldPayload( + key, + "cell", + "kg/m3", + ("rho",), + geometry.cell_shape, + ( + ArrayPiece( + (0, 0), + (2, 2), + np.full((1, 2, 2), index + 1.0), + 0, + 0, + False, + ), + ), + ) + ) snapshot = OutputSnapshot( base_frame.snapshot.clock, base_frame.snapshot.provenance, @@ -686,7 +808,8 @@ def test_catalyst_uses_same_block_disambiguated_names_as_paraview_files(tmp_path paths = catalyst_module.operations[1][1].values display_names = { - value for path, value in paths.items() + value + for path, value in paths.items() if "/fields/" in path and path.endswith("/display_name") } assert {"fluid.rho", "radiation.rho"}.issubset(display_names) @@ -736,11 +859,13 @@ def open_session(self, _execution_context): descriptor = LiveVisualization( observer=_DeclaredProvider(), schedule=Schedule(Every(AcceptedStep(Clock("provider-authority")), 1)), - fields=(Handle( - "temperature", - kind="state", - owner=OwnerPath.model("provider-authority"), - ),), + fields=( + Handle( + "temperature", + kind="state", + owner=OwnerPath.model("provider-authority"), + ), + ), ) operation = descriptor.consumer_authoring()[0].operation @@ -762,8 +887,7 @@ def open_session(self, _configuration, _execution_context): pipeline = tmp_path / "provider_identity.py" pipeline.write_text("# provider identity test\n") - declaration = Catalyst( - pipeline=str(pipeline), provider=_DeclaredCatalystBackend()) + declaration = Catalyst(pipeline=str(pipeline), provider=_DeclaredCatalystBackend()) with pytest.raises(ValueError, match="provider_id differs from its authenticated provider"): declaration.open_session(_serial_context()) @@ -771,10 +895,15 @@ def open_session(self, _configuration, _execution_context): def test_bounded_dispatcher_retries_then_reports_without_compensation(): session = _RetrySession() + run_identity = _identity("run", "retry") dispatcher = PostCommitObserverQueue( - session, ObserverRun(_identity("run", "retry")), - consumer_id="retry-observer", capacity=1, max_attempts=2) - dispatcher.submit(_frame()) + session, + ObserverRun(run_identity), + consumer_id="retry-observer", + capacity=1, + max_attempts=2, + ) + dispatcher.submit(_frame(run_identity=run_identity)) reports = dispatcher.close() assert dispatcher.capacity == 1 @@ -785,10 +914,15 @@ def test_bounded_dispatcher_retries_then_reports_without_compensation(): def test_bounded_dispatcher_reports_exhausted_frame_as_skipped(): session = _RetrySession(always_fail=True) + run_identity = _identity("run", "skip") dispatcher = PostCommitObserverQueue( - session, ObserverRun(_identity("run", "skip")), - consumer_id="skip-observer", capacity=1, max_attempts=2) - dispatcher.submit(_frame()) + session, + ObserverRun(run_identity), + consumer_id="skip-observer", + capacity=1, + max_attempts=2, + ) + dispatcher.submit(_frame(run_identity=run_identity)) reports = dispatcher.close() assert reports[0].status == "skipped" @@ -801,9 +935,9 @@ def test_serial_catalyst_rejects_unproved_centering_and_distributed_frame(tmp_pa pipeline = tmp_path / "pipeline.py" pipeline.write_text("# injected Catalyst pipeline\n") provider = CatalystPythonProvider( - catalyst_module=_CatalystModule(), conduit_module=_ConduitModule()) - session = Catalyst( - pipeline=str(pipeline), provider=provider).open_session(_serial_context()) + catalyst_module=_CatalystModule(), conduit_module=_ConduitModule() + ) + session = Catalyst(pipeline=str(pipeline), provider=provider).open_session(_serial_context()) frame = _frame(centering="node") session.initialize(ObserverRun(frame.snapshot.provenance.run_identity)) with pytest.raises(NotImplementedError, match="cell-centered"): @@ -826,8 +960,7 @@ def test_catalyst_rejects_an_mpi_execution_context_before_loading_modules( conduit_module=_ConduitModule(), ), ) - mpi_context = SimpleNamespace( - communicator=SimpleNamespace(identity="MPI_COMM_WORLD")) + mpi_context = SimpleNamespace(communicator=SimpleNamespace(identity="MPI_COMM_WORLD")) with pytest.raises(ValueError, match="exact duplicated MPI_COMM_WORLD observer lane"): declaration.open_session(mpi_context) @@ -851,8 +984,7 @@ def test_catalyst_collective_session_authenticates_lane_and_passes_mpi_comm( ) mpi_context, lane, agreements = _collective_context(monkeypatch) - session = declaration.open_runtime_session( - {"worker_communicator": lane}, mpi_context) + session = declaration.open_runtime_session({"worker_communicator": lane}, mpi_context) assert session.authority == { "schema_version": 1, "provider_id": "pops.output.catalyst-python.v1", @@ -868,13 +1000,15 @@ def test_catalyst_collective_session_authenticates_lane_and_passes_mpi_comm( assert receipt.frame_identity == frame.identity assert [operation for operation, _node in catalyst_module.operations] == [ - "initialize", "execute", "finalize"] + "initialize", + "execute", + "finalize", + ] assert catalyst_module.operations[0][1].values["catalyst/mpi_comm"] == 73 assert agreements assert all( - row == {"rank": 0, "error": None} - or set(row) == {"rank", "value"} - for row in agreements) + row == {"rank": 0, "error": None} or set(row) == {"rank", "value"} for row in agreements + ) def test_catalyst_rejects_a_worker_lane_with_different_world_topology( @@ -891,12 +1025,10 @@ def test_catalyst_rejects_a_worker_lane_with_different_world_topology( conduit_module=_ConduitModule(), ), ) - mpi_context, lane, _agreements = _collective_context( - monkeypatch, world_size=3, lane_size=2) + mpi_context, lane, _agreements = _collective_context(monkeypatch, world_size=3, lane_size=2) with pytest.raises(ValueError, match="lane topology differs from MPI_COMM_WORLD"): - declaration.open_runtime_session( - {"worker_communicator": lane}, mpi_context) + declaration.open_runtime_session({"worker_communicator": lane}, mpi_context) assert catalyst_module.operations == [] @@ -916,13 +1048,13 @@ def test_catalyst_collective_agreement_propagates_a_peer_initialize_error( ), ) mpi_context, lane, agreements = _collective_context( - monkeypatch, peer_error="ValueError: rank-one pipeline failure") - session = declaration.open_runtime_session( - {"worker_communicator": lane}, mpi_context) + monkeypatch, peer_error="ValueError: rank-one pipeline failure" + ) + session = declaration.open_runtime_session({"worker_communicator": lane}, mpi_context) with pytest.raises( - RuntimeError, - match="Catalyst initialize failed collectively:.*rank 1:.*pipeline failure"): + RuntimeError, match="Catalyst initialize failed collectively:.*rank 1:.*pipeline failure" + ): session.initialize(ObserverRun(_identity("run", "peer-initialize-failure"))) assert agreements == [{"rank": 0, "error": None}] @@ -944,12 +1076,11 @@ def test_catalyst_collective_rejects_rank_divergent_initialize_authority( ), ) mpi_context, lane, _agreements = _collective_context( - monkeypatch, divergent_initialize_authority=True) - session = declaration.open_runtime_session( - {"worker_communicator": lane}, mpi_context) + monkeypatch, divergent_initialize_authority=True + ) + session = declaration.open_runtime_session({"worker_communicator": lane}, mpi_context) - with pytest.raises( - RuntimeError, match="Catalyst initialize authority differs across ranks: 1"): + with pytest.raises(RuntimeError, match="Catalyst initialize authority differs across ranks: 1"): session.initialize(ObserverRun(_identity("run", "divergent-authority"))) assert catalyst_module.operations == [] @@ -970,8 +1101,7 @@ def test_catalyst_collective_rejects_a_frame_from_another_lane_topology( ), ) mpi_context, lane, _agreements = _collective_context(monkeypatch) - session = declaration.open_runtime_session( - {"worker_communicator": lane}, mpi_context) + session = declaration.open_runtime_session({"worker_communicator": lane}, mpi_context) frame = _frame(mode=ParallelMode.COLLECTIVE) session.initialize(ObserverRun(frame.snapshot.provenance.run_identity)) mismatched = ObserverFrame( @@ -986,13 +1116,15 @@ def test_catalyst_collective_rejects_a_frame_from_another_lane_topology( ) with pytest.raises( - RuntimeError, - match="Catalyst execute failed collectively:.*exact worker MPI lane topology"): + RuntimeError, match="Catalyst execute failed collectively:.*exact worker MPI lane topology" + ): session.execute(mismatched) session.finalize() assert [operation for operation, _node in catalyst_module.operations] == [ - "initialize", "finalize"] + "initialize", + "finalize", + ] def test_catalyst_conduit_import_prefers_paraview_name_then_external_fallback(monkeypatch): @@ -1060,15 +1192,17 @@ def test_real_catalyst_conduit_blueprint_when_available(tmp_path: Path): session.finalize() assert [name for name, _node in catalyst_module.operations] == [ - "initialize", "execute", "finalize"] + "initialize", + "execute", + "finalize", + ] def test_scalar_tutorial_pipeline_executes_with_real_catalyst_when_available(): pytest.importorskip("catalyst") pytest.importorskip("catalyst_conduit") pipeline = ( - Path(__file__).resolve().parents[4] - / "docs/tuto/scalar_advection/catalyst_pipeline.py" + Path(__file__).resolve().parents[4] / "docs/tuto/scalar_advection/catalyst_pipeline.py" ) session = Catalyst(pipeline=str(pipeline)).open_session(_serial_context()) frame = _frame(field_name="U", component_names=("rho",)) @@ -1109,7 +1243,8 @@ def test_background_dispatcher_rejects_worker_mpi_without_a_duplicate_lane(): ) with pytest.raises(ValueError, match="explicit duplicated worker lane"): PostCommitObserverQueue( - session, ObserverRun(_identity("run", "mpi")), consumer_id="mpi-observer") + session, ObserverRun(_identity("run", "mpi")), consumer_id="mpi-observer" + ) def test_background_dispatcher_rejects_a_worker_lane_for_a_serial_session( @@ -1142,6 +1277,7 @@ def require_duplicate(communicator, *, allow_world=True): def test_background_dispatcher_accepts_a_collective_session_with_duplicate_lane( monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, ): import pops._native_collectives as native_collectives @@ -1178,19 +1314,33 @@ def allgather_value(communicator, value): monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) monkeypatch.setattr(native_collectives, "allgather_value", allgather_value) monkeypatch.setattr( - native_collectives, "barrier", lambda communicator: barriers.append(communicator)) + native_collectives, "barrier", lambda communicator: barriers.append(communicator) + ) session = _CollectiveSession() - worker = PostCommitObserverWorker(thread_name="test-collective-observer-worker") + run_identity = _identity("run", "collective-queue") + worker = PostCommitObserverWorker( + thread_name="test-collective-observer-worker", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) queue = PostCommitObserverQueue( session, - ObserverRun(_identity("run", "collective-queue")), + ObserverRun(run_identity), consumer_id="collective-observer", worker_communicator=lane, shared_worker=worker, + defer_initialize=True, ) - queue.submit(_frame(mode=ParallelMode.COLLECTIVE)) - reports = queue.close() + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) + queue.prepare_initialize() + queue.arm_initialize() + queue.complete_initialize() + queue.submit(_frame(run_identity=run_identity, mode=ParallelMode.COLLECTIVE)) + queue.prepare_close() + queue.prepare_complete_close() + queue.arm_complete_close() + reports = queue.complete_close() worker.close() assert len(reports) == 1 @@ -1200,133 +1350,1119 @@ def allgather_value(communicator, value): assert barriers == [lane] -def test_shared_post_commit_worker_preserves_cross_consumer_fifo_on_one_thread(): - events = [] +def test_collective_initialization_barrier_loss_seals_lane_without_agreement( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + import pops._native_collectives as native_collectives - class _OrderedSession(_RetrySession): - def __init__(self, name): - super().__init__() - self.name = name + class _CollectiveSession(_RetrySession): + authority = dict( + _RetrySession.authority, + threading="dedicated_collective", + worker_mpi=True, + ) - def initialize(self, _run): - events.append(("initialize", self.name, threading.get_ident())) + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/observer/barrier-loss", + active=True, + rank=0, + size=2, + ) + agreement_calls = 0 - def execute(self, frame): - events.append(("execute", self.name, threading.get_ident())) - return ObserverReceipt(frame.identity, "test.observer") + def fail_barrier(communicator): + assert communicator is lane + raise RuntimeError("injected initialization barrier loss") - def finalize(self): - events.append(("finalize", self.name, threading.get_ident())) + def forbidden_agreement(_communicator, _value): + nonlocal agreement_calls + agreement_calls += 1 + raise AssertionError("a lost initialization barrier seals the worker lane") - worker = PostCommitObserverWorker(thread_name="test-shared-post-commit-fifo") - run = ObserverRun(_identity("run", "shared-fifo")) - first = PostCommitObserverQueue( - _OrderedSession("first"), run, consumer_id="first", shared_worker=worker) - second = PostCommitObserverQueue( - _OrderedSession("second"), run, consumer_id="second", shared_worker=worker) + monkeypatch.setattr( + native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + monkeypatch.setattr(native_collectives, "rank", lambda communicator: communicator.rank) + monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) + monkeypatch.setattr(native_collectives, "barrier", fail_barrier) + monkeypatch.setattr(native_collectives, "allgather_value", forbidden_agreement) - second.submit(_frame()) - first.submit(_frame()) - first.close() - second.close() - worker.close() + run_identity = _identity("run", "collective-initialization-barrier-loss") + worker = PostCommitObserverWorker( + thread_name="test-collective-initialization-barrier-loss", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + queue = PostCommitObserverQueue( + _CollectiveSession(), + ObserverRun(run_identity), + consumer_id="collective-initialization-barrier-loss", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) - assert [(phase, name) for phase, name, _thread in events] == [ - ("initialize", "first"), - ("initialize", "second"), - ("execute", "second"), - ("execute", "first"), - ("finalize", "first"), - ("finalize", "second"), - ] - assert len({thread for _phase, _name, thread in events}) == 1 + queue.prepare_initialize() + queue.arm_initialize() + with pytest.raises(RuntimeError, match="initialization barrier lost"): + queue.complete_initialize() + assert queue.worker_collective_lost is True + assert agreement_calls == 0 + + +def test_local_seal_cancels_deferred_frame_without_provider_reentry_and_joins_worker( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + import pops._native_collectives as native_collectives + + class _CollectiveSession(_RetrySession): + authority = dict( + _RetrySession.authority, + threading="dedicated_collective", + worker_mpi=True, + ) -def test_observer_initialization_failure_aborts_partial_session_once(): - class _PartialInitializeSession(_RetrySession): def __init__(self): super().__init__() + self.initialize_calls = 0 + self.finalize_calls = 0 self.abort_calls = 0 def initialize(self, _run): - raise RuntimeError("partially initialized") + self.initialize_calls += 1 + + def execute(self, frame): + self.calls += 1 + return ObserverReceipt(frame.identity, "test.observer") + + def finalize(self): + self.finalize_calls += 1 def abort(self): self.abort_calls += 1 - session = _PartialInitializeSession() + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/observer/local-seal", + active=True, + rank=0, + size=2, + ) + monkeypatch.setattr( + native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + monkeypatch.setattr(native_collectives, "rank", lambda communicator: communicator.rank) + monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) + monkeypatch.setattr(native_collectives, "barrier", lambda _communicator: None) + monkeypatch.setattr( + native_collectives, + "allgather_value", + lambda _communicator, value: (value, dict(value, rank=1)), + ) - with pytest.raises(RuntimeError, match="initialization failed"): - PostCommitObserverQueue( - session, - ObserverRun(_identity("run", "partial-initialize")), - consumer_id="partial-initialize", + run_identity = _identity("run", "collective-local-seal") + session = _CollectiveSession() + worker = PostCommitObserverWorker( + thread_name="test-collective-local-seal", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + queue = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="collective-local-seal", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) + queue.prepare_initialize() + queue.arm_initialize() + queue.complete_initialize() + queue._prepare_detached( + observer_runtime._detach_owned_observer_frame( + _frame(run_identity=run_identity, mode=ParallelMode.COLLECTIVE) ) + ) - assert session.abort_calls == 1 - - -def test_observer_receipt_must_match_authenticated_session_provider(): - class _WrongProviderReceiptSession(_RetrySession): - def execute(self, frame): - self.calls += 1 - return ObserverReceipt(frame.identity, "another.observer") + lost = RuntimeError("WORLD observer collective lost") + queue.seal_local(lost) + worker.seal_local(lost) + queue.seal_local(lost) + worker.seal_local(lost) + + assert session.initialize_calls == 1 + assert session.calls == 0 + assert session.finalize_calls == 0 + assert session.abort_calls == 0 + assert queue.pending == 0 + assert len(queue.reports) == 1 + assert queue.reports[0].status == "skipped" + assert queue.worker_collective_lost is True + assert worker.close_succeeded is True + + +@pytest.mark.parametrize("phase", ("initialize", "execute", "finalize")) +@pytest.mark.parametrize("failure", ("transport", "malformed")) +def test_catalyst_worker_collective_loss_seals_queue_without_a_second_lane_probe( + phase: str, + failure: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + import pops._native_collectives as native_collectives - dispatcher = PostCommitObserverQueue( - _WrongProviderReceiptSession(), - ObserverRun(_identity("run", "wrong-receipt-provider")), - consumer_id="wrong-receipt-provider", + pipeline = tmp_path / ("lost-%s-%s.py" % (phase, failure)) + pipeline.write_text("# worker collective loss\n") + catalyst_module = _CatalystModule() + context, lane, _agreements = _collective_context(monkeypatch) + session = Catalyst( + pipeline=str(pipeline), + provider=CatalystPythonProvider( + catalyst_module=catalyst_module, + conduit_module=_ConduitModule(), + ), + ).open_runtime_session({"worker_communicator": lane}, context) + run_identity = _identity("run", "catalyst-%s-%s-loss" % (phase, failure)) + frame = _frame(run_identity=run_identity, mode=ParallelMode.COLLECTIVE) + run = ObserverRun(run_identity) + worker = PostCommitObserverWorker( + thread_name="test-catalyst-%s-%s-loss" % (phase, failure), + run_identity=run.run_identity, ) - dispatcher.submit(_frame()) - (report,) = dispatcher.close() + request.addfinalizer(lambda: _close_worker_for_test(worker)) + queue = PostCommitObserverQueue( + session, + run, + consumer_id="collective-catalyst-loss", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) + monkeypatch.setattr(native_collectives, "barrier", lambda _communicator: None) - assert report.status == "skipped" - assert "provider_id differs" in report.reason + loss_armed = False + lost_collective_calls = 0 + def gathered(communicator, value): + nonlocal lost_collective_calls + assert communicator is lane + if loss_armed and set(value) == {"rank", "error"}: + lost_collective_calls += 1 + if failure == "transport": + raise RuntimeError("injected Catalyst worker-lane transport loss") + return (dict(value, rank=0),) + return tuple(dict(value, rank=owner) for owner in range(lane.size)) + + monkeypatch.setattr(native_collectives, "allgather_value", gathered) + + captured = None + if phase == "initialize": + loss_armed = True + queue.prepare_initialize() + queue.arm_initialize() + with pytest.raises(RuntimeError, match="provider worker collective") as captured: + queue.complete_initialize() + else: + queue.prepare_initialize() + queue.arm_initialize() + queue.complete_initialize() + loss_armed = True + if phase == "execute": + queue.submit(frame) + with pytest.raises(RuntimeError, match="provider worker collective") as captured: + queue.flush() + else: + queue.prepare_close() + queue.prepare_complete_close() + queue.arm_complete_close() + with pytest.raises(RuntimeError, match="provider worker collective") as captured: + queue.complete_close() + + assert captured is not None + causes = [] + error = captured.value + while error is not None and error not in causes: + causes.append(error) + error = error.__cause__ + assert any(isinstance(cause, ObserverWorkerCollectiveLost) for cause in causes) + assert lost_collective_calls == 1 + assert queue.worker_collective_lost is True + worker.close() -def test_runtime_owned_submission_detaches_once_and_keeps_no_native_sharing(monkeypatch): - valid = np.ones((2, 2), dtype=np.bool_) - coverage = np.asarray([[False, True], [False, False]]) - volumes = np.full((2, 2), 0.125) - source = _frame(native_geometry_arrays=(valid, coverage, volumes)) - real_detach = observer_runtime.detach_observer_frame - calls = [] - def counted(frame): - calls.append(frame.identity) - return real_detach(frame) +def test_abort_collective_loss_marker_poisoned_queue_refuses_retry_and_seals_worker( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + import pops._native_collectives as native_collectives - monkeypatch.setattr(observer_runtime, "detach_observer_frame", counted) - owned = observer_runtime._detach_owned_observer_frame(source) + class _CollectiveSession(_RetrySession): + authority = dict( + _RetrySession.authority, + threading="dedicated_collective", + worker_mpi=True, + ) - class _CaptureSession(_RetrySession): def __init__(self): super().__init__() - self.frames = [] + self.abort_calls = 0 - def execute(self, frame): - self.frames.append(frame) - return ObserverReceipt(frame.identity, "test.observer") + def abort(self): + self.abort_calls += 1 + raise ObserverWorkerCollectiveLost("injected abort worker-lane loss") - session = _CaptureSession() - dispatcher = PostCommitObserverQueue( - session, - ObserverRun(_identity("run", "single-detach")), - consumer_id="single-detach", + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/observer/abort-marker-loss", + active=True, + rank=0, + size=2, ) - dispatcher._submit_detached(owned) - dispatcher.close() + agreement_calls = 0 - assert calls == [source.identity] - detached_geometry = session.frames[0].snapshot.geometries[0] - assert not np.shares_memory(detached_geometry.coverage, coverage) - assert not np.shares_memory(detached_geometry.cell_volumes, volumes) + def gathered(_communicator, value): + nonlocal agreement_calls + agreement_calls += 1 + return value, dict(value, rank=1) + monkeypatch.setattr( + native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + monkeypatch.setattr(native_collectives, "rank", lambda communicator: communicator.rank) + monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) + monkeypatch.setattr(native_collectives, "barrier", lambda _communicator: None) + monkeypatch.setattr(native_collectives, "allgather_value", gathered) -@pytest.mark.parametrize("rank", (0, 1)) -def test_root_provider_preflight_reaches_one_consensus_before_local_failure( - monkeypatch, rank, -): + run_identity = _identity("run", "abort-marker-loss") + session = _CollectiveSession() + worker = PostCommitObserverWorker( + thread_name="test-abort-marker-loss", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + queue = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="abort-marker-loss", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) + queue.prepare_initialize() + queue.arm_initialize() + queue.complete_initialize() + queue.prepare_abort_close() + queue.prepare_complete_abort_close() + queue.arm_complete_abort_close() + + with pytest.raises(RuntimeError, match="provider worker collective"): + queue.complete_abort_close() + with pytest.raises(RuntimeError, match="worker collective is lost"): + queue.prepare_complete_abort_close() + + assert session.abort_calls == 1 + assert agreement_calls == 1 + assert queue.worker_collective_lost is True + assert queue.abort_required is False + + lost = RuntimeError("WORLD observer collective lost after abort") + queue.seal_local(lost) + worker.seal_local(lost) + assert worker.close_succeeded is True + + +def test_collective_frame_gate_reports_local_serialization_failure_before_provider_entry( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + import pops._native_collectives as native_collectives + + class _CollectiveSession(_RetrySession): + authority = dict( + _RetrySession.authority, + threading="dedicated_collective", + worker_mpi=True, + ) + + def execute(self, frame): + self.calls += 1 + return ObserverReceipt(frame.identity, "test.observer") + + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/observer/frame-gate-failure", + active=True, + rank=0, + size=2, + ) + frame_gates = [] + + monkeypatch.setattr( + native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + monkeypatch.setattr(native_collectives, "rank", lambda communicator: communicator.rank) + monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) + monkeypatch.setattr(native_collectives, "barrier", lambda _communicator: None) + + def gathered(_communicator, value): + peer = dict(value, rank=1) + if set(value) == {"rank", "error", "gate"}: + frame_gates.append(value) + peer["error"] = None + peer["gate"] = {"peer": "valid-frame-authority"} + return value, peer + + monkeypatch.setattr(native_collectives, "allgather_value", gathered) + + session = _CollectiveSession() + run_identity = _identity("run", "frame-gate-serialization-failure") + worker = PostCommitObserverWorker( + thread_name="test-frame-gate-serialization-failure", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + dispatcher = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="collective-observer", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(dispatcher)) + dispatcher.prepare_initialize() + dispatcher.arm_initialize() + dispatcher.complete_initialize() + frame = _frame(run_identity=run_identity, mode=ParallelMode.COLLECTIVE) + owned = observer_runtime._detach_owned_observer_frame(frame) + + def fail_to_data(_request): + raise RuntimeError("rank-local request serialization failure") + + monkeypatch.setattr(OutputRequest, "to_data", fail_to_data) + dispatcher._submit_detached(owned) + dispatcher.prepare_close() + dispatcher.prepare_complete_close() + dispatcher.arm_complete_close() + reports = dispatcher.complete_close() + worker.close() + + assert len(frame_gates) == 1 + assert frame_gates[0]["gate"] is None + assert "rank-local request serialization failure" in frame_gates[0]["error"] + assert reports[0].status == "skipped" + assert "authority construction failed collectively" in reports[0].reason + assert session.calls == 0 + + +def test_collective_frame_gate_truncated_proof_poisoned_lane_refuses_provider_abort( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + import pops._native_collectives as native_collectives + + class _CollectiveSession(_RetrySession): + authority = dict( + _RetrySession.authority, + threading="dedicated_collective", + worker_mpi=True, + ) + + def __init__(self): + super().__init__() + self.abort_calls = 0 + + def execute(self, frame): + self.calls += 1 + return ObserverReceipt(frame.identity, "test.observer") + + def abort(self): + self.abort_calls += 1 + + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/observer/truncated-frame-gate", + active=True, + rank=0, + size=2, + ) + execute_gates = [] + + monkeypatch.setattr( + native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + monkeypatch.setattr(native_collectives, "rank", lambda communicator: communicator.rank) + monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) + monkeypatch.setattr(native_collectives, "barrier", lambda _communicator: None) + + def gathered(communicator, value): + if set(value) == {"rank", "error", "gate"}: + execute_gates.append(value) + return (value,) + return tuple(dict(value, rank=owner) for owner in range(communicator.size)) + + monkeypatch.setattr(native_collectives, "allgather_value", gathered) + + session = _CollectiveSession() + run_identity = _identity("run", "truncated-frame-gate") + worker = PostCommitObserverWorker( + thread_name="test-truncated-frame-gate", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + queue = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="collective-observer", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) + queue.prepare_initialize() + queue.arm_initialize() + queue.complete_initialize() + queue.submit(_frame(run_identity=run_identity, mode=ParallelMode.COLLECTIVE)) + with pytest.raises(RuntimeError, match="malformed rank evidence"): + queue.flush() + with pytest.raises(RuntimeError, match="worker collective is lost"): + queue.prepare_abort_close() + reports = queue.reports + worker.close() + + assert len(execute_gates) == 1 + assert len(reports) == 1 + assert reports[0].status == "skipped" + assert "malformed rank evidence" in reports[0].reason + assert session.calls == 0 + assert session.abort_calls == 0 + assert queue.worker_collective_lost is True + assert queue.abort_required is False + assert queue.abort_succeeded is False + assert queue.close_succeeded is False + assert worker.close_succeeded is True + + +@pytest.mark.parametrize("lost_phase", ("execute", "journal")) +def test_collective_transport_loss_poisoned_lane_never_reenters_provider( + lost_phase: str, + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, + tmp_path: Path, +): + import pops._native_collectives as native_collectives + from pops.output._durable_journal import DurableJournal + + class _CollectiveSession(_RetrySession): + authority = dict( + _RetrySession.authority, + threading="dedicated_collective", + worker_mpi=True, + ) + + def __init__(self): + super().__init__() + self.abort_calls = 0 + + def execute(self, frame): + self.calls += 1 + return ObserverReceipt(frame.identity, "test.observer") + + def abort(self): + self.abort_calls += 1 + + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/observer/lost-%s" % lost_phase, + active=True, + rank=0, + size=2, + ) + monkeypatch.setattr( + native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + monkeypatch.setattr(native_collectives, "rank", lambda communicator: communicator.rank) + monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) + monkeypatch.setattr(native_collectives, "barrier", lambda _communicator: None) + + status_collectives = 0 + + def gathered(_communicator, value): + nonlocal status_collectives + if set(value) == {"rank", "error", "gate"}: + return value, dict(value, rank=1) + assert set(value) == {"rank", "error"} + status_collectives += 1 + lost_at = 2 if lost_phase == "execute" else 3 + if status_collectives == lost_at: + raise RuntimeError("injected %s transport loss" % lost_phase) + return value, dict(value, rank=1) + + monkeypatch.setattr(native_collectives, "allgather_value", gathered) + + session = _CollectiveSession() + run_identity = _identity("run", "lost-%s-transport" % lost_phase) + worker = PostCommitObserverWorker( + thread_name="test-lost-%s-transport" % lost_phase, + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + queue = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="collective-observer", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) + queue.prepare_initialize() + queue.arm_initialize() + queue.complete_initialize() + + frame = _frame(run_identity=run_identity, mode=ParallelMode.COLLECTIVE) + if lost_phase == "journal": + journal = DurableJournal(tmp_path / "journal", sync="none") + record = journal.commit(journal.prepare(frame)) + queue.submit(frame, journal=journal, journal_record=record) + else: + queue.submit(frame) + + with pytest.raises(RuntimeError, match="lost its collective proof"): + queue.flush() + with pytest.raises(RuntimeError, match="lost its collective proof"): + queue.flush() + with pytest.raises(RuntimeError, match="worker collective is lost"): + queue.prepare_abort_close() + worker.close() + + assert session.calls == 1 + assert session.abort_calls == 0 + assert queue.worker_collective_lost is True + assert queue.abort_required is False + assert queue.abort_succeeded is False + assert queue.close_succeeded is False + assert worker.close_succeeded is True + + +def test_collective_lifecycle_tasks_enter_provider_only_after_explicit_arm( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + import pops._native_collectives as native_collectives + + class _LifecycleSession(_RetrySession): + authority = dict( + _RetrySession.authority, + threading="dedicated_collective", + worker_mpi=True, + ) + + def __init__(self): + super().__init__() + self.initialize_calls = 0 + self.finalize_calls = 0 + self.abort_calls = 0 + + def initialize(self, _run): + self.initialize_calls += 1 + + def finalize(self): + self.finalize_calls += 1 + + def abort(self): + self.abort_calls += 1 + + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/observer/lifecycle-gate", + active=True, + rank=0, + size=2, + ) + monkeypatch.setattr( + native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + monkeypatch.setattr(native_collectives, "rank", lambda communicator: communicator.rank) + monkeypatch.setattr(native_collectives, "size", lambda communicator: communicator.size) + monkeypatch.setattr(native_collectives, "barrier", lambda _communicator: None) + monkeypatch.setattr( + native_collectives, + "allgather_value", + lambda _communicator, value: (value, dict(value, rank=1)), + ) + + run_identity = _identity("run", "collective-lifecycle-gate") + worker = PostCommitObserverWorker( + thread_name="test-collective-lifecycle-gate", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + finalized = _LifecycleSession() + queue = PostCommitObserverQueue( + finalized, + ObserverRun(run_identity), + consumer_id="collective-finalize", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(queue)) + queue.prepare_initialize() + queue.cancel_initialize(RuntimeError("peer refused initialization enqueue")) + assert finalized.initialize_calls == 0 + queue.prepare_initialize() + queue.arm_initialize() + queue.complete_initialize() + assert finalized.initialize_calls == 1 + + queue.prepare_close() + queue.prepare_complete_close() + queue.cancel_complete_close(RuntimeError("peer refused finalization enqueue")) + assert finalized.finalize_calls == 0 + queue.prepare_complete_close() + queue.arm_complete_close() + queue.complete_close() + assert finalized.finalize_calls == 1 + + aborted = _LifecycleSession() + abort_queue = PostCommitObserverQueue( + aborted, + ObserverRun(run_identity), + consumer_id="collective-abort", + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(abort_queue)) + abort_queue.prepare_abort_close() + abort_queue.prepare_complete_abort_close() + abort_queue.cancel_complete_abort_close(RuntimeError("peer refused abort enqueue")) + assert aborted.abort_calls == 0 + abort_queue.prepare_complete_abort_close() + abort_queue.arm_complete_abort_close() + abort_queue.complete_abort_close() + assert aborted.abort_calls == 1 + worker.close() + + +def test_shared_post_commit_worker_preserves_cross_consumer_fifo_on_one_thread( + request: pytest.FixtureRequest, +): + events = [] + + class _OrderedSession(_RetrySession): + def __init__(self, name): + super().__init__() + self.name = name + + def initialize(self, _run): + events.append(("initialize", self.name, threading.get_ident())) + + def execute(self, frame): + events.append(("execute", self.name, threading.get_ident())) + return ObserverReceipt(frame.identity, "test.observer") + + def finalize(self): + events.append(("finalize", self.name, threading.get_ident())) + + run = ObserverRun(_identity("run", "shared-fifo")) + worker = PostCommitObserverWorker( + thread_name="test-shared-post-commit-fifo", + run_identity=run.run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + first = PostCommitObserverQueue( + _OrderedSession("first"), run, consumer_id="first", shared_worker=worker + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(first)) + second = PostCommitObserverQueue( + _OrderedSession("second"), run, consumer_id="second", shared_worker=worker + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(second)) + + second.submit(_frame(run_identity=run.run_identity)) + first.submit(_frame(run_identity=run.run_identity)) + first.close() + second.close() + worker.close() + + assert [(phase, name) for phase, name, _thread in events] == [ + ("initialize", "first"), + ("initialize", "second"), + ("execute", "second"), + ("execute", "first"), + ("finalize", "first"), + ("finalize", "second"), + ] + assert len({thread for _phase, _name, thread in events}) == 1 + + +def test_observer_queue_rejects_shared_worker_owned_by_another_run( + request: pytest.FixtureRequest, +): + expected_run = _identity("run", "expected-worker-owner") + worker = PostCommitObserverWorker( + thread_name="test-wrong-run-worker", + run_identity=_identity("run", "wrong-worker-owner"), + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + + with pytest.raises(ValueError, match="shared_worker belongs to a different run"): + PostCommitObserverQueue( + _RetrySession(), + ObserverRun(expected_run), + consumer_id="wrong-run-worker", + shared_worker=worker, + ) + + worker.close() + assert worker.close_succeeded is True + + +def test_shared_observer_close_retries_finalize_without_republishing_reports( + request: pytest.FixtureRequest, +): + class _FailOnceFinalizeSession(_RetrySession): + def __init__(self): + super().__init__() + self.finalize_calls = 0 + + def execute(self, frame): + self.calls += 1 + return ObserverReceipt(frame.identity, "test.observer") + + def finalize(self): + self.finalize_calls += 1 + if self.finalize_calls == 1: + raise RuntimeError("transient finalize failure") + + session = _FailOnceFinalizeSession() + run_identity = _identity("run", "shared-close-retry") + worker = PostCommitObserverWorker( + thread_name="test-shared-close-retry", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + dispatcher = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="shared-close-retry", + shared_worker=worker, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(dispatcher)) + dispatcher.submit(_frame(run_identity=run_identity)) + + with pytest.raises(RuntimeError, match="transient finalize failure"): + dispatcher.close() + + reports_after_failure = dispatcher.reports + assert dispatcher.close_requested + assert not dispatcher.close_succeeded + assert not dispatcher.closed + assert len(reports_after_failure) == 1 + assert session.calls == 1 + with pytest.raises(RuntimeError, match="observer queue is closed"): + dispatcher.submit(_frame(run_identity=run_identity)) + + assert dispatcher.close() == reports_after_failure + assert dispatcher.close_succeeded + assert dispatcher.closed + assert session.calls == 1 + assert session.finalize_calls == 2 + worker.close() + + +def test_private_observer_close_retries_finalize_on_original_worker_thread( + request: pytest.FixtureRequest, +): + class _FailOnceFinalizeSession(_RetrySession): + def __init__(self): + super().__init__() + self.execute_threads = [] + self.finalize_threads = [] + + def execute(self, frame): + self.calls += 1 + self.execute_threads.append(threading.get_ident()) + return ObserverReceipt(frame.identity, "test.observer") + + def finalize(self): + self.finalize_threads.append(threading.get_ident()) + if len(self.finalize_threads) == 1: + raise RuntimeError("transient private finalize failure") + + session = _FailOnceFinalizeSession() + run_identity = _identity("run", "private-close-retry") + dispatcher = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="private-close-retry", + ) + request.addfinalizer(lambda: _close_private_queue_for_test(dispatcher)) + dispatcher.submit(_frame(run_identity=run_identity)) + + with pytest.raises(RuntimeError, match="transient private finalize failure"): + dispatcher.close() + + reports_after_failure = dispatcher.reports + assert dispatcher.close_requested + assert not dispatcher.close_succeeded + assert dispatcher.close() == reports_after_failure + assert dispatcher.close_succeeded + assert session.calls == 1 + assert len(session.finalize_threads) == 2 + assert len(set(session.execute_threads + session.finalize_threads)) == 1 + + +def test_observer_close_retries_preparation_before_finalizing( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + class _CountingFinalizeSession(_RetrySession): + def __init__(self): + super().__init__() + self.finalize_calls = 0 + + def execute(self, frame): + self.calls += 1 + return ObserverReceipt(frame.identity, "test.observer") + + def finalize(self): + self.finalize_calls += 1 + + session = _CountingFinalizeSession() + run_identity = _identity("run", "flush-close-retry") + worker = PostCommitObserverWorker( + thread_name="test-flush-close-retry", + run_identity=run_identity, + ) + request.addfinalizer(lambda: _close_worker_for_test(worker)) + dispatcher = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="flush-close-retry", + shared_worker=worker, + ) + request.addfinalizer(lambda: _cancel_prepared_queue_calls_for_test(dispatcher)) + dispatcher.submit(_frame(run_identity=run_identity)) + original_prepare = dispatcher.prepare_close + prepare_calls = 0 + + def fail_once(): + nonlocal prepare_calls + prepare_calls += 1 + if prepare_calls == 1: + raise RuntimeError("transient preparation failure") + return original_prepare() + + monkeypatch.setattr(dispatcher, "prepare_close", fail_once) + + with pytest.raises(RuntimeError, match="transient preparation failure"): + dispatcher.close() + + assert not dispatcher.close_requested + assert not dispatcher.close_succeeded + assert session.finalize_calls == 0 + reports = dispatcher.close() + assert len(reports) == 1 + assert dispatcher.close_succeeded + assert prepare_calls == 2 + assert session.finalize_calls == 1 + worker.close() + + +def test_post_commit_worker_close_retries_join_without_second_stop( + monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, +): + worker = PostCommitObserverWorker(thread_name="test-worker-close-retry") + request.addfinalizer(lambda: _close_worker_for_test(worker)) + original_put = worker._jobs.put + original_join = worker._thread.join + close_items = [] + join_calls = 0 + + def record_put(item, *args, **kwargs): + close_items.append(item) + return original_put(item, *args, **kwargs) + + def fail_join_once(*args, **kwargs): + nonlocal join_calls + join_calls += 1 + if join_calls == 1: + raise RuntimeError("transient join failure") + return original_join(*args, **kwargs) + + monkeypatch.setattr(worker._jobs, "put", record_put) + monkeypatch.setattr(worker._thread, "join", fail_join_once) + + with pytest.raises(RuntimeError, match="transient join failure"): + worker.close() + + assert worker.close_requested + assert not worker.close_succeeded + assert not worker.closed + with pytest.raises(RuntimeError, match="post-commit worker is closed"): + worker.submit(lambda: None, lambda _error: None) + + worker.close() + assert worker.close_succeeded + assert worker.closed + assert join_calls == 2 + assert len(close_items) == 1 + + +def test_failed_open_queue_aborts_without_finalizing_and_retries_local_failure( + request: pytest.FixtureRequest, +): + class _AbortSession(_RetrySession): + def __init__(self): + super().__init__() + self.abort_calls = 0 + self.finalize_calls = 0 + + def finalize(self): + self.finalize_calls += 1 + + def abort(self): + self.abort_calls += 1 + if self.abort_calls == 1: + raise RuntimeError("transient abort failure") + + session = _AbortSession() + queue = PostCommitObserverQueue( + session, + ObserverRun(_identity("run", "failed-open-abort")), + consumer_id="failed-open-abort", + ) + request.addfinalizer(lambda: _close_private_queue_for_test(queue, abort=True)) + + with pytest.raises(RuntimeError, match="transient abort failure"): + queue.abort_close() + + assert queue.close_requested + assert not queue.close_succeeded + assert not queue.abort_succeeded + assert session.finalize_calls == 0 + + assert queue.abort_close() == () + assert queue.close_succeeded + assert queue.abort_succeeded + assert session.abort_calls == 2 + assert session.finalize_calls == 0 + + assert queue.abort_close() == () + assert session.abort_calls == 2 + + +def test_observer_initialization_failure_does_not_abort_inline(): + class _PartialInitializeSession(_RetrySession): + def __init__(self): + super().__init__() + self.abort_calls = 0 + + def initialize(self, _run): + raise RuntimeError("partially initialized") + + def abort(self): + self.abort_calls += 1 + + session = _PartialInitializeSession() + + with pytest.raises(RuntimeError, match="initialization failed"): + PostCommitObserverQueue( + session, + ObserverRun(_identity("run", "partial-initialize")), + consumer_id="partial-initialize", + ) + + assert session.abort_calls == 0 + + +def test_observer_receipt_must_match_authenticated_session_provider(): + class _WrongProviderReceiptSession(_RetrySession): + def execute(self, frame): + self.calls += 1 + return ObserverReceipt(frame.identity, "another.observer") + + run_identity = _identity("run", "wrong-receipt-provider") + dispatcher = PostCommitObserverQueue( + _WrongProviderReceiptSession(), + ObserverRun(run_identity), + consumer_id="wrong-receipt-provider", + ) + dispatcher.submit(_frame(run_identity=run_identity)) + (report,) = dispatcher.close() + + assert report.status == "skipped" + assert "provider_id differs" in report.reason + + +def test_runtime_owned_submission_detaches_once_and_keeps_no_native_sharing(monkeypatch): + valid = np.ones((2, 2), dtype=np.bool_) + coverage = np.asarray([[False, True], [False, False]]) + volumes = np.full((2, 2), 0.125) + run_identity = _identity("run", "single-detach") + source = _frame( + run_identity=run_identity, + native_geometry_arrays=(valid, coverage, volumes), + ) + real_detach = observer_runtime.detach_observer_frame + calls = [] + + def counted(frame): + calls.append(frame.identity) + return real_detach(frame) + + monkeypatch.setattr(observer_runtime, "detach_observer_frame", counted) + owned = observer_runtime._detach_owned_observer_frame(source) + + class _CaptureSession(_RetrySession): + def __init__(self): + super().__init__() + self.frames = [] + + def execute(self, frame): + self.frames.append(frame) + return ObserverReceipt(frame.identity, "test.observer") + + session = _CaptureSession() + dispatcher = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id="single-detach", + ) + dispatcher._submit_detached(owned) + dispatcher.close() + + assert calls == [source.identity] + detached_geometry = session.frames[0].snapshot.geometries[0] + assert not np.shares_memory(detached_geometry.coverage, coverage) + assert not np.shares_memory(detached_geometry.cell_volumes, volumes) + + +@pytest.mark.parametrize("rank", (0, 1)) +def test_root_provider_preflight_reaches_one_consensus_before_local_failure( + monkeypatch, + rank, +): calls = [] class _Operation: @@ -1377,8 +2513,9 @@ def gathered(actual_communicator, value): "RuntimeError: rank-zero preopen failed", "RuntimeError: rank-one preflight failed", ) - return tuple({"rank": owner_rank, "error": error} - for owner_rank, error in enumerate(errors)) + return tuple( + {"rank": owner_rank, "error": error} for owner_rank, error in enumerate(errors) + ) monkeypatch.setattr(runtime_consumers, "allgather_value", gathered) @@ -1392,14 +2529,17 @@ def gathered(actual_communicator, value): @pytest.mark.parametrize("rank", (0, 1)) def test_root_frame_detach_failure_is_collective_before_prepare_returns( - monkeypatch, rank, + monkeypatch, + rank, ): frame = _frame(mode=ParallelMode.ROOT) communicator = object() publisher = runtime_consumers.RuntimeConsumerPublisher.__new__( - runtime_consumers.RuntimeConsumerPublisher) + runtime_consumers.RuntimeConsumerPublisher + ) publisher._owner = SimpleNamespace( - _output_snapshot=lambda _manifest: (frame.snapshot, frame.request)) + _output_snapshot=lambda _manifest: (frame.snapshot, frame.request) + ) publisher._rank = rank publisher._size = 2 publisher._communicator = communicator @@ -1410,8 +2550,7 @@ def detached(_frame_value): raise RuntimeError("rank-zero detach failed") raise AssertionError("non-root must not detach") - monkeypatch.setattr( - runtime_consumers, "_detach_owned_observer_frame", detached) + monkeypatch.setattr(runtime_consumers, "_detach_owned_observer_frame", detached) def gathered(actual_communicator, value): assert actual_communicator is communicator @@ -1441,8 +2580,8 @@ def test_collective_live_delivery_drains_before_returning_to_solver(monkeypatch) qualified_id="monitor/collective-live", ) raw_frame = SimpleNamespace( - snapshot=SimpleNamespace( - provenance=SimpleNamespace(run_identity=run_identity))) + snapshot=SimpleNamespace(provenance=SimpleNamespace(run_identity=run_identity)) + ) events = [] class Submission: @@ -1467,15 +2606,15 @@ def flush(self): detached = object() queue = Queue() publisher = runtime_consumers.RuntimeConsumerPublisher.__new__( - runtime_consumers.RuntimeConsumerPublisher) + runtime_consumers.RuntimeConsumerPublisher + ) publisher._owner = SimpleNamespace(last_run_identity=run_identity) publisher._rank = 0 publisher._size = 2 publisher._communicator = object() publisher._manifest = lambda _effect: manifest publisher._observer_queue = lambda _manifest, _run_identity: queue - publisher._record_observer_failure = ( - lambda *_args: events.append("unexpected-failure")) + publisher._record_observer_failure = lambda *_args: events.append("unexpected-failure") monkeypatch.setattr( runtime_consumers, "_authenticated_detached_frame", @@ -1500,6 +2639,80 @@ def consensus(communicator, *, rank, size, error, phase): ] +def test_collective_live_delivery_rejects_truncated_world_envelope(monkeypatch): + run_identity = _identity("run", "collective-live-truncated-world") + manifest = SimpleNamespace( + parallel_mode=ParallelMode.COLLECTIVE, + qualified_id="monitor/collective-live-truncated-world", + ) + raw_frame = SimpleNamespace( + snapshot=SimpleNamespace(provenance=SimpleNamespace(run_identity=run_identity)) + ) + events = [] + + class Submission: + def arm(self): + events.append("arm") + + def cancel(self, error): + raise AssertionError("accepted submission must not be cancelled") from error + + class Queue: + def _prepare_detached(self, frame, *, journal, journal_record): + assert frame is detached + assert journal is None + assert journal_record is None + events.append("prepare") + return Submission() + + def flush(self): + events.append("flush") + return () + + detached = object() + queue = Queue() + world = object() + publisher = runtime_consumers.RuntimeConsumerPublisher.__new__( + runtime_consumers.RuntimeConsumerPublisher + ) + publisher._owner = SimpleNamespace(last_run_identity=run_identity) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = world + publisher._manifest = lambda _effect: manifest + publisher._observer_queue = lambda _manifest, _run_identity: queue + publisher._record_observer_failure = lambda *_args: events.append("report-only") + monkeypatch.setattr( + runtime_consumers, + "_authenticated_detached_frame", + lambda frame: raw_frame if frame is detached else None, + ) + + gathers = [] + + def gathered(communicator, value): + assert communicator is world + gathers.append(value) + if len(gathers) == 1: + return ( + {"rank": 0, "error": None}, + {"rank": 1, "error": None}, + ) + assert len(gathers) == 2 + return (value,) + + monkeypatch.setattr(runtime_consumers, "allgather_value", gathered) + + with pytest.raises( + runtime_consumers._ObserverCollectiveLost, + match="collective live delivery returned a malformed envelope", + ): + publisher._submit_live_visualization(SimpleNamespace(), detached) + + assert len(gathers) == 2 + assert events == ["prepare", "arm", "flush"] + + def test_detached_frame_does_not_borrow_runtime_geometry_buffers(): valid = np.ones((2, 2), dtype=np.bool_) coverage = np.asarray([[False, True], [False, False]]) diff --git a/tests/python/unit/physics/test_exact_physics_scalars.py b/tests/python/unit/physics/test_exact_physics_scalars.py index 37f1e1062..61474a99a 100644 --- a/tests/python/unit/physics/test_exact_physics_scalars.py +++ b/tests/python/unit/physics/test_exact_physics_scalars.py @@ -19,6 +19,7 @@ from pops.physics._facade import Model from pops.physics.multispecies import CoupledSource from pops.physics._scalars import canonical_scalar_data, physics_scalar_cpp +from pops.numerics.riemann import Harten, NoEntropyFix def _constant_source(*values): @@ -51,7 +52,8 @@ def _entropy_fixed_roe_model(entropy_fix): q1, q2 = model.conservative_vars("q1", "q2") model.flux(x=[q1, q2], y=[q2, q1]) model.wave_speeds_from_jacobian() - model.roe_from_jacobian(entropy_fix=entropy_fix) + policy = NoEntropyFix() if entropy_fix is None else Harten(entropy_fix) + model.roe_from_jacobian(entropy_fix=policy) model.primitive_vars(q1, q2) model.conservative_from([q1, q2]) return model @@ -173,7 +175,7 @@ def test_roe_entropy_fix_emits_and_hashes_exact_positive_literal(): @pytest.mark.parametrize("value", [True, 0, -1, float("nan"), float("inf"), Decimal("1e-10000")]) def test_roe_entropy_fix_rejects_invalid_or_native_underflowing_values(value): - with pytest.raises((TypeError, ValueError, OverflowError), match="entropy_fix"): + with pytest.raises((TypeError, ValueError, OverflowError), match="Harten.delta"): _entropy_fixed_roe_model(value) diff --git a/tests/python/unit/physics/test_fv_hll_minmod.py b/tests/python/unit/physics/test_fv_hll_minmod.py index 01eab9506..1ddc2b844 100644 --- a/tests/python/unit/physics/test_fv_hll_minmod.py +++ b/tests/python/unit/physics/test_fv_hll_minmod.py @@ -91,7 +91,7 @@ def gaussian(n): chk("hllc" in str(e), f"erreur explicite : {e}") # --- 4. AmrSystem : hll + minmod accepte (alignement de surface System/AMR) ------ -print("== AmrSystem : add_block(riemann='hll') accepte sur isotherme ==") +print("== AmrSystem : add_equation(riemann='hll') accepte sur isotherme ==") amr = AmrSystem(n=32, L=1.0, periodicity=(True, True), regrid_every=0) amr.set_poisson(rhs="charge_density", solver="geometric_mg", bc=Periodic()) amr_rho0 = gaussian(32) diff --git a/tests/python/unit/physics/test_generic_riemann_routes.py b/tests/python/unit/physics/test_generic_riemann_routes.py index d209e8252..e3d7fbb7f 100644 --- a/tests/python/unit/physics/test_generic_riemann_routes.py +++ b/tests/python/unit/physics/test_generic_riemann_routes.py @@ -5,11 +5,21 @@ pops = pytest.importorskip("pops") from pops.codegen.loader import CompiledModel # noqa: E402 from pops.numerics.riemann import HLLC, Roe # noqa: E402 +from pops.numerics.riemann.providers import ( # noqa: E402 + ENTROPY_HARTEN, + ENTROPY_NONE, + ENTROPY_PROVIDER_OWNED, + HLLC_FLUID_ROLES, + ROE_DIRECT_ACTION, + ROE_FLUID_ROLES, + ROE_FLUX_JACOBIAN, + Harten, +) from pops.runtime._bricks_scheme import Spatial # noqa: E402 from pops.runtime.routes import check_riemann_requirement_contract # noqa: E402 -def _compiled(*, n_vars, hllc=False, roe=False): +def _compiled(*, n_vars, hllc=False, roe=False, roe_provider=ROE_FLUID_ROLES): """Metadata-only compiled model carrying exact provider capabilities.""" return CompiledModel( so_path="/no/such/pops-riemann-provider.so", @@ -28,6 +38,18 @@ def _compiled(*, n_vars, hllc=False, roe=False): std="c++23", hllc=hllc, roe=roe, + hllc_provider=HLLC_FLUID_ROLES if hllc else None, + roe_provider=roe_provider if roe else None, + roe_entropy_policy=( + ENTROPY_PROVIDER_OWNED + if roe and roe_provider == ROE_DIRECT_ACTION + else ENTROPY_NONE + if roe and roe_provider == ROE_FLUX_JACOBIAN + else ENTROPY_HARTEN + if roe + else None + ), + roe_entropy_delta=(Harten().delta_token if roe and roe_provider == ROE_FLUID_ROLES else None), wave_speeds=True, wave_speed_provider="explicit_pair", target="system", @@ -57,6 +79,70 @@ def test_availability_depends_on_capability_not_component_count(n_vars): _validate(_compiled(n_vars=n_vars, roe=True), Roe()) +@pytest.mark.parametrize( + "provider", + [ROE_FLUID_ROLES, ROE_DIRECT_ACTION, ROE_FLUX_JACOBIAN], +) +def test_all_exact_roe_providers_feed_the_same_native_route(provider): + _validate(_compiled(n_vars=5, roe=True, roe_provider=provider), Roe()) + + +def test_detached_model_inspection_keeps_provider_and_options() -> None: + compiled = _compiled(n_vars=4, hllc=True, roe=True) + assert compiled.hllc_provider == HLLC_FLUID_ROLES + assert compiled.roe_provider == ROE_FLUID_ROLES + assert compiled.roe_entropy_policy == ENTROPY_HARTEN + assert compiled.roe_entropy_delta == Harten().delta_token + rendered = repr(compiled) + assert "hllc_provider='fluid_roles_v1'" in rendered + assert "roe_provider='fluid_roles_v1'" in rendered + assert "roe_entropy_policy='harten_v1'" in rendered + + +def test_compiled_provider_evidence_fails_closed_on_missing_unknown_or_mismatch(): + kwargs = dict( + so_path="/no/such/model.so", + backend="production", + cons_names=["q"], + cons_roles=["other"], + prim_names=[], + n_vars=1, + gamma=None, + n_aux=0, + params={}, + caps={}, + abi_key="abi", + model_hash="hash", + cxx="c++", + std="c++23", + ) + with pytest.raises(ValueError, match="hllc flag disagrees"): + CompiledModel(**kwargs, hllc=True) + with pytest.raises(ValueError, match="unknown HLLC provider"): + CompiledModel(**kwargs, hllc=True, hllc_provider="guessed") + with pytest.raises(ValueError, match="requires exact harten_v1 or none"): + CompiledModel(**kwargs, roe=True, roe_provider=ROE_FLUID_ROLES) + with pytest.raises(ValueError, match="canonical scalar JSON"): + CompiledModel( + **kwargs, + roe=True, + roe_provider=ROE_FLUID_ROLES, + roe_entropy_policy=ENTROPY_HARTEN, + roe_entropy_delta="not-json", + ) + + +def test_truthy_legacy_flags_without_provider_evidence_are_not_capabilities(): + class Forged: + has_hllc = True + has_roe = True + + with pytest.raises(ValueError, match="hllc_star_state"): + _validate(Forged(), HLLC()) + with pytest.raises(ValueError, match="roe_dissipation"): + _validate(Forged(), Roe()) + + def test_hllc_missing_capability_fails_before_native_install(): with pytest.raises(ValueError, match="hllc_star_state"): _validate(_compiled(n_vars=4), HLLC()) diff --git a/tests/python/unit/physics/test_polar_hll.py b/tests/python/unit/physics/test_polar_hll.py index a1ff50dc7..1e1270032 100644 --- a/tests/python/unit/physics/test_polar_hll.py +++ b/tests/python/unit/physics/test_polar_hll.py @@ -1,5 +1,4 @@ -"""Chantier POLAIRE (audit 2026-06, section 3) : flux HLL cable sur l'anneau pour le fluide -isotherme polaire (IsothermalFluxPolar). +"""Pipeline Riemann capability-driven sur l'anneau isotherme. CE QUE VERROUILLE CE TEST : T1 - DEFAUT BIT-IDENTIQUE : un run polaire isotherme avec riemann='rusanov' (le defaut) est @@ -7,23 +6,21 @@ non-regression vis-a-vis d'avant le patch (impossible dans un seul process), mais le patch ne touche PAS la branche rusanov de make_block_polar (ajout d'une branche 'hll' SEPAREE) : le defaut reste strictement l'historique. - T2 - HLL TOURNE FINI : le meme run avec riemann='hll' avance sans NaN/Inf (le flux signe - assemble_rhs_polar est device-clean, REUTILISE verbatim depuis le cartesien). - T3 - HLL DIFFERE DE RUSANOV : HLL est moins diffusif que Rusanov (dissipation ~ |sR - sL| signee au - lieu de 2 max|v| symetrique) -> l'etat final differe au-dela du bruit FP. C'est la preuve que - le flux injecte est REELLEMENT HLL (et non un alias silencieux de Rusanov). + T2 - HLL tourne fini par sa feuille distincte. + T3 - HLLC/Roe tournent finis et diffèrent de Rusanov : aucun alias/fallback silencieux. + T4 - un transport ExB sans les capacités exactes refuse HLLC/Roe au lieu de changer de solveur. Le fluide isotherme polaire expose model.wave_speeds (herite d'IsothermalFlux) : c'est la condition -du gate 'hll' (identique au cartesien block_builder.hpp). Un transport ExB SCALAIRE ne la fournit pas --> rejet, couvert par test_polar_rejections.test_polar_rejects_hll_on_scalar_exb. +du gate 'hll' (identique au cartesien block_builder.hpp). Un transport ExB SCALAIRE ne fournit pas +les capacités HLLC/Roe et le test de refus ci-dessous verrouille l'absence de fallback. """ -from pops.numerics.variables import Conservative -from pops.numerics.reconstruction.limiters import Minmod -from pops.numerics.riemann import Rusanov, HLL import math import numpy as np +from pops.numerics.reconstruction.limiters import Minmod +from pops.numerics.riemann import HLL, HLLC, Roe, Rusanov +from pops.numerics.variables import Conservative import pops.runtime._engine_descriptors as engine from pops.mesh import PolarMesh from pops.runtime._engine_descriptors import Dirichlet @@ -90,6 +87,29 @@ def _state3(sim, nr, nth): return np.array(sim.get_state("ions")).reshape(3, nth, nr) +def _assert_scalar_rejected(flux, capability): + sim = System(mesh=PolarMesh(r_min=RMIN, r_max=RMAX, nr=8, ntheta=8)) + sim.set_poisson(rhs="charge_density", solver="polar", bc=Dirichlet()) + model = engine.Model( + state=engine.Scalar(), + transport=engine.ExB(B0=1.0), + source=engine.NoSource(), + elliptic=engine.BackgroundDensity(alpha=0.0, n0=0.0), + ) + try: + sim.add_equation( + "density", + model=model, + spatial=engine.Spatial(limiter=Minmod(), flux=flux, recon=Conservative()), + time=engine.Explicit(), + ) + except (RuntimeError, ValueError) as error: + message = str(error) + assert capability in message and "fallback" in message.lower(), message + return + raise AssertionError("scalar ExB accepted %r without %s" % (flux, capability)) + + def _run(sim, nr, nth, n_steps, dt): for _ in range(n_steps): sim.step(dt) @@ -108,18 +128,35 @@ def test_polar_hll(): s_rus_b = _run(_build(nr, nth, Rusanov(), cs2), nr, nth, n_steps, dt) assert np.array_equal(s_rus_a, s_rus_b), "rusanov polaire : non reproductible (T1)" - # T2 : hll tourne fini. - s_hll = _run(_build(nr, nth, HLL(), cs2), nr, nth, n_steps, dt) - assert np.all(np.isfinite(s_hll)), "hll polaire : etat non fini (T2)" + state = _run(_build(nr, nth, HLL(), cs2), nr, nth, n_steps, dt) + assert np.all(np.isfinite(state)), "hll polaire : etat non fini (T2)" + diff = float(np.max(np.abs(state - s_rus_a))) + assert diff > 1e-8, "hll polaire est un alias/fallback Rusanov (diff=%.3e)" % diff - # T3 : hll differe de rusanov (au-dela du bruit FP) -> le flux injecte est bien HLL. - diff = float(np.max(np.abs(s_hll - s_rus_a))) - assert diff > 1e-8, ( - "hll polaire ne differe pas de rusanov (diff=%.3e) : le flux injecte serait un alias " - "silencieux de Rusanov (T3)" % diff - ) + +def test_polar_isothermal_hllc_and_roe_use_requested_provider(): + nr, nth = 24, 24 + cs2 = 1.0 + h = min((RMAX - RMIN) / nr, RMIN * (2.0 * math.pi / nth)) + dt = 0.2 * h / math.sqrt(cs2) + reference = _run(_build(nr, nth, Rusanov(), cs2), nr, nth, 8, dt) + for name, provider in (("hllc", HLLC()), ("roe", Roe())): + state = _run(_build(nr, nth, provider, cs2), nr, nth, 8, dt) + assert np.all(np.isfinite(state)), "%s polaire : etat non fini (T3)" % name + diff = float(np.max(np.abs(state - reference))) + assert diff > 1e-8, ( + "%s polaire ne differe pas de rusanov (diff=%.3e) : alias/fallback silencieux (T3)" + % (name, diff) + ) + + +def test_polar_exb_refuses_missing_hllc_and_roe_capabilities(): + _assert_scalar_rejected(HLLC(), "HasHLLCStructure") + _assert_scalar_rejected(Roe(), "HasRoeDissipation") if __name__ == "__main__": test_polar_hll() - print("test_polar_hll : OK (rusanov reproductible, hll fini et distinct)") + test_polar_isothermal_hllc_and_roe_use_requested_provider() + test_polar_exb_refuses_missing_hllc_and_roe_capabilities() + print("test_polar_hll : OK (HLL/HLLC/Roe finis, distincts et capability-gated)") diff --git a/tests/python/unit/physics/test_riemann_provider_identity.py b/tests/python/unit/physics/test_riemann_provider_identity.py new file mode 100644 index 000000000..fa24a351f --- /dev/null +++ b/tests/python/unit/physics/test_riemann_provider_identity.py @@ -0,0 +1,110 @@ +"""Exact provider and entropy-policy identity for the one HLLC/Roe pipeline.""" +from __future__ import annotations + +from fractions import Fraction + +import pytest + +from pops.codegen._compile_emit import model_hash +from pops.numerics.riemann import Harten, NoEntropyFix +from pops.numerics.riemann.providers import ( + ENTROPY_HARTEN, + ENTROPY_NONE, + ENTROPY_PROVIDER_OWNED, + HLLC_FLUID_ROLES, + ROE_DIRECT_ACTION, + ROE_FLUID_ROLES, + ROE_FLUX_JACOBIAN, + authoring_provider_evidence, +) +from pops.physics._facade import Model + + +def _fluid_model(name: str) -> Model: + model = Model(name) + rho, mx, my = model.conservative_vars( + "rho", + "mx", + "my", + roles=["Density", "MomentumX", "MomentumY"], + ) + u = model.primitive("u", mx / rho) + v = model.primitive("v", my / rho) + p = model.primitive("p", rho) + model.flux( + x=[mx, mx * u + p, mx * v], + y=[my, my * u, my * v + p], + ) + model.eigenvalues(x=[u - 1, u, u + 1], y=[v - 1, v, v + 1]) + model.primitive_vars(rho, u, v) + model.conservative_from([rho, rho * u, rho * v]) + return model + + +def _scalar_model(name: str) -> tuple[Model, object]: + model = Model(name) + (q,) = model.conservative_vars("q") + model.flux(x=[q], y=[q]) + model.eigenvalues(x=[1], y=[1]) + model.primitive_vars(q) + model.conservative_from([q]) + return model, q + + +def test_hllc_and_role_roe_carry_exact_provider_and_typed_policy() -> None: + model = _fluid_model("typed_role_roe") + model.enable_hllc() + model.enable_roe(entropy_fix=Harten(Fraction(1, 7))) + + evidence = authoring_provider_evidence(model) + assert evidence.hllc_provider == HLLC_FLUID_ROLES + assert evidence.roe_provider == ROE_FLUID_ROLES + assert evidence.roe_entropy_policy == ENTROPY_HARTEN + assert evidence.roe_entropy_delta == ( + '{"denominator":"7","kind":"rational","numerator":"1"}' + ) + source = model._m.emit_cpp_brick() + assert "const pops::HartenEntropyFix entropy_fix" in source + assert "pops::Real(1) / pops::Real(7)" in source + + +def test_role_roe_policy_changes_emission_and_model_identity() -> None: + default = _fluid_model("same_role_roe") + default.enable_roe() + no_fix = _fluid_model("same_role_roe") + no_fix.enable_roe(entropy_fix=NoEntropyFix()) + + assert model_hash(default._m) != model_hash(no_fix._m) + default_source = default._m.emit_cpp_brick() + no_fix_source = no_fix._m.emit_cpp_brick() + assert "HartenEntropyFix" in default_source + assert "HartenEntropyFix" not in no_fix_source + assert authoring_provider_evidence(no_fix).roe_entropy_policy == ENTROPY_NONE + + +def test_direct_and_flux_jacobian_providers_remain_distinct_evidence() -> None: + direct, q_direct = _scalar_model("direct_roe") + direct.roe_dissipation( + x=[direct.right(q_direct) - direct.left(q_direct)], + y=[direct.right(q_direct) - direct.left(q_direct)], + ) + direct_evidence = authoring_provider_evidence(direct) + assert direct_evidence.roe_provider == ROE_DIRECT_ACTION + assert direct_evidence.roe_entropy_policy == ENTROPY_PROVIDER_OWNED + + jacobian, _ = _scalar_model("jacobian_roe") + jacobian.roe_from_jacobian(entropy_fix=NoEntropyFix()) + jacobian_evidence = authoring_provider_evidence(jacobian) + assert jacobian_evidence.roe_provider == ROE_FLUX_JACOBIAN + assert jacobian_evidence.roe_entropy_policy == ENTROPY_NONE + assert direct_evidence != jacobian_evidence + + +def test_entropy_policy_refuses_untyped_magic_scalars() -> None: + role = _fluid_model("untyped_role_entropy") + with pytest.raises(TypeError, match="riemann.Harten"): + role.enable_roe(entropy_fix=0.2) + + jacobian, _ = _scalar_model("untyped_jacobian_entropy") + with pytest.raises(TypeError, match="riemann.Harten"): + jacobian.roe_from_jacobian(entropy_fix=1.0e-6) diff --git a/tests/python/unit/physics/test_wave_speed_cache.py b/tests/python/unit/physics/test_wave_speed_cache.py index fbc0e0a66..53f5d0e68 100644 --- a/tests/python/unit/physics/test_wave_speed_cache.py +++ b/tests/python/unit/physics/test_wave_speed_cache.py @@ -66,12 +66,12 @@ def make_sim(cache, riemann=None, limiter=None, time=None): riemann = riemann if riemann is not None else HLL() limiter = limiter if limiter is not None else FirstOrder() sim = System(n=N, L=1.0, periodicity=(True, True)) - sim.add_block("ions", - Model(state=FluidState("isothermal", cs2=CS2), - transport=IsothermalFlux(), source=NoSource(), - elliptic=BackgroundDensity(alpha=1.0, n0=1.0)), - spatial=Spatial(limiter=limiter, flux=riemann, wave_speed_cache=cache), - time=time if time is not None else Explicit()) + sim.add_equation("ions", + Model(state=FluidState("isothermal", cs2=CS2), + transport=IsothermalFlux(), source=NoSource(), + elliptic=BackgroundDensity(alpha=1.0, n0=1.0)), + spatial=Spatial(limiter=limiter, flux=riemann, wave_speed_cache=cache), + time=time if time is not None else Explicit()) return sim @@ -98,12 +98,12 @@ def make_sim(cache, riemann=None, limiter=None, time=None): print("== (2) defaut inchange : sans wave_speed_cache == cache OFF ==") s_def = System(n=N, L=1.0, periodicity=(True, True)) -s_def.add_block("ions", - Model(state=FluidState("isothermal", cs2=CS2), - transport=IsothermalFlux(), source=NoSource(), - elliptic=BackgroundDensity(alpha=1.0, n0=1.0)), - spatial=Spatial(limiter=FirstOrder(), flux=HLL()), - time=Explicit()) +s_def.add_equation("ions", + Model(state=FluidState("isothermal", cs2=CS2), + transport=IsothermalFlux(), source=NoSource(), + elliptic=BackgroundDensity(alpha=1.0, n0=1.0)), + spatial=Spatial(limiter=FirstOrder(), flux=HLL()), + time=Explicit()) s_def.set_state("ions", U0) install_forward_euler_program(s_def) for _ in range(20): @@ -136,13 +136,13 @@ def make_disc_sim_then_mode(): def make_mode_then_cache(): sim = System(n=N, L=1.0, periodicity=(True, True)) sim.set_disc_domain(DiscDomain(center=(0.5, 0.5), radius=0.3, mode=CutCell())) - sim.add_block("ions", - Model(state=FluidState("isothermal", cs2=CS2), - transport=IsothermalFlux(), source=NoSource(), - elliptic=BackgroundDensity(alpha=1.0, n0=1.0)), - spatial=Spatial(limiter=FirstOrder(), flux=HLL(), - wave_speed_cache=True), # doit lever (mode disque actif) - time=Explicit()) + sim.add_equation("ions", + Model(state=FluidState("isothermal", cs2=CS2), + transport=IsothermalFlux(), source=NoSource(), + elliptic=BackgroundDensity(alpha=1.0, n0=1.0)), + spatial=Spatial(limiter=FirstOrder(), flux=HLL(), + wave_speed_cache=True), # doit lever (mode disque actif) + time=Explicit()) msg = err_msg(make_disc_sim_then_mode) @@ -150,10 +150,10 @@ def make_mode_then_cache(): f"cache puis set_disc_domain(staircase) rejete ({msg[:60]}...)") msg = err_msg(make_mode_then_cache) chk("wave_speed_cache" in msg and ("cutcell" in msg or "staircase" in msg), - f"set_disc_domain(cutcell) puis add_block(cache) rejete ({msg[:60]}...)") + f"set_disc_domain(cutcell) puis add_equation(cache) rejete ({msg[:60]}...)") print("== (6) garde backend compile : cache + add_equation(modele .so) -> erreur ==") -# Le cache n'est cable que sur le chemin natif compose (add_block). Le package de production ne +# Le cache n'est cable que sur le chemin natif compose de add_equation. Le package de production ne # transporte pas le flag : il serait ignore en silence. On verifie le rejet avant le dlopen. from pops.codegen.loader import CompiledModel # noqa: E402 diff --git a/tests/python/unit/physics/test_wave_speed_providers.py b/tests/python/unit/physics/test_wave_speed_providers.py index 136519485..c38f954ba 100644 --- a/tests/python/unit/physics/test_wave_speed_providers.py +++ b/tests/python/unit/physics/test_wave_speed_providers.py @@ -65,6 +65,7 @@ def _compiled(*, wave_speeds=True, wave_speed_provider="explicit_pair", n_vars=2 cons_names=cons, cons_roles=["custom"] * n_vars, prim_names=[], n_vars=n_vars, gamma=1.4, n_aux=3, params={}, caps={"cpu": True}, abi_key="SIG|c++|c++23", model_hash="mh", cxx="c++", std="c++23", wave_speeds=wave_speeds, hllc=hllc, + hllc_provider="fluid_roles_v1" if hllc else None, wave_speed_provider=(wave_speed_provider if wave_speeds else None), target="system") return c diff --git a/tests/python/unit/runtime/test_amr_bind_lowering.py b/tests/python/unit/runtime/test_amr_bind_lowering.py index f6eef2e1c..0108ac08a 100644 --- a/tests/python/unit/runtime/test_amr_bind_lowering.py +++ b/tests/python/unit/runtime/test_amr_bind_lowering.py @@ -1,6 +1,10 @@ """AMR bind lowering preserves every authored Cartesian axis topology.""" from __future__ import annotations +import sys +from types import SimpleNamespace + +import pops import pytest from pops.amr import AMRRegrid @@ -12,6 +16,7 @@ _physical_patch_rectangles, _regrid_every, ) +from pops.runtime._amr_system_install import _AmrSystemInstall from pops.runtime._runtime_authorities import ( _materialized_shared_interface_levels, _validate_refined_shared_interface_execution, @@ -71,6 +76,78 @@ def test_dynamic_refined_shared_interface_bind_accepts_serial_and_exact_mpi_worl ) +def test_implicit_pair_requires_exact_frozen_two_level_prefix_at_complete_bind() -> None: + serial = { + "communicator_identity": "serial", + "device_identity": "host", + "memory_space": 1, + } + _validate_refined_shared_interface_execution( + (0,), serial, 1, implicit_jacvec_pair=True, complete_bind=False + ) + _validate_refined_shared_interface_execution( + (0, 1), serial, 1, implicit_jacvec_pair=True, complete_bind=True + ) + for levels in ((0,), (0, 1, 2)): + with pytest.raises(NotImplementedError, match=r"exactly materialized levels \(L0, L1\)"): + _validate_refined_shared_interface_execution( + levels, serial, 1, implicit_jacvec_pair=True, complete_bind=True + ) + + +@pytest.mark.parametrize( + ("execution", "ranks"), + [ + ({ + "communicator_identity": "MPI_COMM_WORLD", + "device_identity": "host", + "memory_space": 1, + }, 1), + ({ + "communicator_identity": "MPI_COMM_WORLD", + "device_identity": "host", + "memory_space": 1, + }, 2), + ({ + "communicator_identity": "serial", + "device_identity": "host", + "memory_space": 1, + }, 2), + ], +) +def test_implicit_pair_refuses_mpi_before_native_interface_install(execution, ranks) -> None: + with pytest.raises(NotImplementedError, match="currently serial-only"): + _validate_refined_shared_interface_execution( + (0, 1), execution, ranks, + implicit_jacvec_pair=True, complete_bind=True, + ) + + +@pytest.mark.parametrize( + "execution", + [ + { + "communicator_identity": "serial", + "device_identity": "gpu", + "memory_space": 2, + }, + { + "communicator_identity": "serial", + "device_identity": "cpu", + "memory_space": 3, + }, + ], +) +def test_implicit_pair_refuses_device_or_managed_memory_before_native_install( + execution, +) -> None: + with pytest.raises(NotImplementedError, match="currently host-memory-only"): + _validate_refined_shared_interface_execution( + (0,), execution, 1, + implicit_jacvec_pair=True, complete_bind=False, + ) + + def test_shared_interface_bind_rejects_non_prefix_and_unknown_communicator() -> None: with pytest.raises(ValueError, match="contiguous L0 prefix"): _validate_refined_shared_interface_execution((), {}, 1) @@ -80,11 +157,131 @@ def test_shared_interface_bind_rejects_non_prefix_and_unknown_communicator() -> _validate_refined_shared_interface_execution( (0, 1), {"communicator_identity": "serial"}, 1, dynamic_regrid=1 ) + with pytest.raises(TypeError, match="complete-bind contracts must be exact bools"): + _validate_refined_shared_interface_execution( + (0, 1), {"communicator_identity": "serial"}, 1, + implicit_jacvec_pair=True, complete_bind=1, + ) with pytest.raises(TypeError, match="serial or exact MPI_COMM_WORLD"): _validate_refined_shared_interface_execution( (0, 1), {"communicator_identity": "MPI_COMM_SELF"}, 1) +def test_implicit_pair_envelope_precedes_program_and_interface_install( + monkeypatch, +) -> None: + import pops.runtime._amr_system_install as amr_install + import pops.runtime._bound_snapshot as bound_snapshot + import pops.runtime._component_execution_context as component_execution + import pops.runtime._install_param_routing as param_routing + import pops.runtime._lifecycle as lifecycle + import pops.runtime._runtime_authorities as authorities + + events = [] + bind_schema = object() + artifact = SimpleNamespace( + bind_schema=bind_schema, + so_path="compiled-amr-program.so", + plan=SimpleNamespace( + field_plans={}, + capabilities={ + "shared_interfaces": {"implicit_jacvec_pair": True}, + }, + ), + ) + install_plan = SimpleNamespace( + artifact=artifact, + instances={}, + params={}, + aux={}, + bootstrap_plan=None, + amr_transfer=None, + execution_context=object(), + ) + + class Probe(_AmrSystemInstall): + def __init__(self) -> None: + self._s = SimpleNamespace() + + def _finish_program_install(self, *args, **kwargs) -> None: + del args, kwargs + events.append("program") + + def _finalize_bind(self, snapshot) -> None: + assert snapshot == "snapshot" + events.append("freeze") + + monkeypatch.setattr(lifecycle, "guard_assembling", lambda *_: None) + monkeypatch.setattr( + bound_snapshot, + "_require_exact_install_inputs", + lambda *_: install_plan, + ) + monkeypatch.setattr( + bound_snapshot, + "build_amr_snapshot", + lambda *args, **kwargs: "snapshot", + ) + monkeypatch.setattr( + amr_install, + "validate_install_arguments", + lambda *args, **kwargs: events.append("arguments"), + ) + monkeypatch.setattr( + component_execution, + "component_execution_data", + lambda _: { + "communicator_identity": "serial", + "device_identity": "host", + "memory_space": 1, + }, + ) + monkeypatch.setattr(param_routing, "route_block_params", lambda *args: {}) + native = SimpleNamespace(n_ranks=lambda: 1) + monkeypatch.setitem(sys.modules, "pops._pops", native) + monkeypatch.setattr(pops, "_pops", native, raising=False) + validate_envelope = authorities._validate_shared_interface_implicit_execution_envelope + + def spy_envelope(execution_data, rank_count) -> None: + events.append("implicit-envelope") + validate_envelope(execution_data, rank_count) + + monkeypatch.setattr( + authorities, + "_validate_shared_interface_implicit_execution_envelope", + spy_envelope, + ) + monkeypatch.setattr( + authorities, + "finalize_runtime_authorities", + lambda engine, plan, *, complete=False: events.append( + "interfaces-complete" if complete else "interfaces-incremental" + ), + ) + + Probe()._install_compiled( + artifact, + instances={}, + params={}, + aux={}, + field_plans={}, + bind_schema=bind_schema, + initial_values=(), + bootstrap_plan=None, + amr_transfer=None, + install_plan=install_plan, + ) + + assert events == [ + "arguments", + "implicit-envelope", + "program", + "interfaces-incremental", + "interfaces-complete", + "freeze", + ] + + def test_native_amr_grid_preserves_none_or_all_periodic_axes() -> None: frame = _frame() closed = CartesianGrid(frame=frame, cells=(16, 16)) diff --git a/tests/python/unit/runtime/test_amr_checkpoint_contract.py b/tests/python/unit/runtime/test_amr_checkpoint_contract.py index d4c495fd4..f4ce7c36d 100644 --- a/tests/python/unit/runtime/test_amr_checkpoint_contract.py +++ b/tests/python/unit/runtime/test_amr_checkpoint_contract.py @@ -16,6 +16,7 @@ ) from pops.output._checkpoint_collective import restore_checkpoint_payload from pops.runtime._amr_checkpoint_contract import ( + checkpoint_temporal_partition_kind, contract_for, encode_contract, preflight_contract, @@ -102,6 +103,19 @@ def program_accepted_state_manifest(self): def program_clock_manifest(self): return [["level", "0", "4", "0", "1", "0.4"], ["logical", "clock.macro", "4"]] + def program_temporal_partition_manifest(self): + return [ + [ + "summary", + "global", + "pops.temporal-partition.global@1", + "0", + "0", + "1", + "0", + ] + ] + def program_flux_ledger_manifest(self): return [ [ @@ -176,7 +190,7 @@ def _payload(sim=None): def test_contract_names_guarantee_relations_qualified_histories_and_transfer_plans(): contract = contract_for(_Sim()) - assert contract["schema_version"] == 4 + assert contract["schema_version"] == 5 assert contract["guarantee"] == "bit_identical_accepted_state" assert contract["ledger"]["accepted_entries"] == 1 assert contract["ledger"]["transaction_depth"] == 0 @@ -210,6 +224,10 @@ def test_contract_names_guarantee_relations_qualified_histories_and_transfer_pla ] assert contract["transfer_routes"][0][2:5] == ["route.u", "provider.u", "kernel.linear"] assert contract["clocks"][1] == ["logical", "clock.macro", "4"] + assert contract["temporal_partition"][0][1:3] == [ + "global", + "pops.temporal-partition.global@1", + ] assert [row[3] for row in contract["synchronization"]] == ["reflux", "average_down"] @@ -219,6 +237,25 @@ def test_preflight_returns_exact_native_payload_and_counters(): assert (regrids, epoch) == (4, 7) +def test_checkpoint_temporal_partition_kind_is_strict_and_data_only(): + payload = _payload() + assert checkpoint_temporal_partition_kind(payload) == "global" + + data = json.loads(str(payload["amr_accepted_contract"])) + data["temporal_partition"] = [ + ["summary", "cell_local", "test.partition@1", "7", "8", "16", "2"], + ["rung", "0", "1"], + ["rung", "1", "1"], + ] + payload["amr_accepted_contract"] = np.array(json.dumps(data)) + assert checkpoint_temporal_partition_kind(payload) == "cell_local" + + data["temporal_partition"].append(["not-a-rung"]) + payload["amr_accepted_contract"] = np.array(json.dumps(data)) + with pytest.raises(ValueError, match="invalid rung row"): + checkpoint_temporal_partition_kind(payload) + + @pytest.mark.parametrize("mutation", ["ratio", "route", "guarantee"]) def test_preflight_refuses_any_static_provenance_mismatch(mutation): payload = _payload() @@ -241,6 +278,7 @@ def test_preflight_refuses_any_static_provenance_mismatch(mutation): "clocks", "ledger", "interface_ledger", + "temporal_partition", "synchronization", ], ) @@ -251,6 +289,8 @@ def test_dynamic_contract_is_checked_after_the_opaque_state_is_restored(section) data[section][0][1] = "program.block.1" elif section in {"ledger", "interface_ledger"}: data[section]["accepted_entries"] += 1 + elif section == "temporal_partition": + data[section][0][1] = "cell_local" else: data[section].append(["tampered"]) payload["amr_accepted_contract"] = np.array(json.dumps(data)) @@ -344,8 +384,7 @@ def program_sync_manifest(self): } assert ( - receipt["history_consensus_identity_before"] - != receipt["history_consensus_identity_after"] + receipt["history_consensus_identity_before"] != receipt["history_consensus_identity_after"] ) # Phase-local all-rank consensus is the contract: interpolation may legitimately change the # dense history image while conserved solution components are checked independently. @@ -437,14 +476,19 @@ def __getitem__(self, key): ], ) def test_uniform_and_amr_payload_versions_are_exact_current_integer_scalars( - runtime_kind, key, expected, + runtime_kind, + key, + expected, ): - assert require_exact_payload_version( - {key: np.array(expected, dtype=np.int64)}, - key=key, - expected=expected, - runtime_kind=runtime_kind, - ) == expected + assert ( + require_exact_payload_version( + {key: np.array(expected, dtype=np.int64)}, + key=key, + expected=expected, + runtime_kind=runtime_kind, + ) + == expected + ) for incompatible in ( np.array(True), @@ -487,7 +531,9 @@ def test_uniform_and_amr_payload_versions_are_exact_current_integer_scalars( ], ) def test_historical_version_refusal_happens_before_restart_transaction( - runtime_kind, key, expected, + runtime_kind, + key, + expected, ): calls = [] diff --git a/tests/python/unit/runtime/test_analytic_expression_lowering.py b/tests/python/unit/runtime/test_analytic_expression_lowering.py index e0f5b7122..423565180 100644 --- a/tests/python/unit/runtime/test_analytic_expression_lowering.py +++ b/tests/python/unit/runtime/test_analytic_expression_lowering.py @@ -3,7 +3,7 @@ import pytest import pops -from pops.analytic import angle, between, param, radius, sin, where, x +from pops.analytic import angle, between, param, radius, sin, time, where, x from pops.domain import Rectangle from pops.frames import Cartesian2D from pops.model import BindSchema @@ -133,6 +133,30 @@ def test_parameter_lowering_rejects_a_foreign_authenticated_schema() -> None: [expression.to_data()], frame_id=frame.canonical_id, bindings=bindings) +def test_time_lowers_only_for_the_exact_consuming_clock() -> None: + frame = Rectangle("time-domain", (0.0, 0.0), (1.0, 1.0)).frame(Cartesian2D()) + program = pops.Program("analytic-lowering-time") + expression = x(frame) + 2.0 * time(program.clock) + + ((opcodes, literals),) = lower_analytic_components( + [expression.to_data()], + frame_id=frame.canonical_id, + time_clock_id=program.clock.qualified_id, + ) + assert opcodes == ("x", "constant", "input", "mul", "add") + assert literals[2] == 0.0 + + with pytest.raises(NotImplementedError, match="exact physical-time Clock"): + lower_analytic_components([expression.to_data()], frame_id=frame.canonical_id) + other = pops.Program("analytic-lowering-other-time") + with pytest.raises(ValueError, match="another logical Clock"): + lower_analytic_components( + [expression.to_data()], + frame_id=frame.canonical_id, + time_clock_id=other.clock.qualified_id, + ) + + @pytest.mark.parametrize( "source", ( diff --git a/tests/python/unit/runtime/test_board_multispecies.py b/tests/python/unit/runtime/test_board_multispecies.py index 733ade7c0..b67c73537 100644 --- a/tests/python/unit/runtime/test_board_multispecies.py +++ b/tests/python/unit/runtime/test_board_multispecies.py @@ -321,8 +321,7 @@ def test_multispecies_lowers_to_a_multiblock_module(): assert not hasattr(m, "compile"), "physics.Model must not expose a direct compile()" module = m.lower() assert isinstance(module, _model_pkg.Module), "physics.Model.lower() returns a pops.model.Module" - assert isinstance(m.to_module(), _model_pkg.Module), "to_module() returns a Module too" - assert type(m).to_module is type(m).lower, "to_module() is the lower() alias" + assert not hasattr(m, "to_module"), "lower() is the sole explicit Module projection" def test_multispecies_check_rejects_an_undeclared_coupled_coordinate(): @@ -464,7 +463,7 @@ def test_local_transform_promotion_preserves_the_first_species_declaration(): electrons = m.species("electrons", state=["ne"]) transform = m.local_transform( "repair_electrons", (electrons["ne"] + 1.0,), on=electrons) - ions = m.species("ions", state=["ni"]) + m.species("ions", state=["ni"]) module = m.module electron_space = module.state_spaces()["electrons"] ion_space = module.state_spaces()["ions"] diff --git a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py index 4eb48db89..63129ccf9 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations from copy import deepcopy +from pathlib import Path from types import SimpleNamespace import pytest @@ -10,6 +11,23 @@ from pops.runtime._runtime_authorities import install_runtime_authorities +ROOT = Path(__file__).resolve().parents[4] + + +def test_native_boundary_install_has_no_component_count_compatibility_abi(): + old_scalar_adapter = "const std::vector& face_values, int ncomp" + typed_roles = "const std::vector& component_roles" + for relative in ( + "include/pops/runtime/system.hpp", + "include/pops/runtime/amr_system.hpp", + "src/runtime/system/system_install.cpp", + "src/runtime/amr/amr_system.cpp", + ): + source = (ROOT / relative).read_text(encoding="utf-8") + assert old_scalar_adapter not in source + assert typed_roles in source + + def _execution_context() -> ExecutionContext: backend = proven_serial_manifest( backend="production", target="system", abi="test|clang++|c++23", runtime=True) @@ -22,10 +40,19 @@ def _execution_context() -> ExecutionContext: @pytest.mark.parametrize("prepare_fails", (False, True)) -def test_boundary_component_install_is_transactional_and_preserves_prepare_json(prepare_fails): +@pytest.mark.parametrize( + ("operation", "native_interface", "expected_installer"), + ( + ("apply_region_batch", {"abi_id": 17, "version": 1, + "cpp_table": "GhostBoundary"}, "ghost"), + ("transform_faces", {"abi_id": 6, "version": 1, + "cpp_table": "BoundaryFlux"}, "flux"), + ), +) +def test_boundary_component_install_is_transactional_and_preserves_prepare_json( + prepare_fails, operation, native_interface, expected_installer): component_id = "pops://external.test/boundary@1.0.0" manifest_identity = "component-manifest:boundary-test" - native_interface = {"abi_id": 17, "version": 1, "cpp_table": "GhostBoundary"} region = { "kind": "face", "dimension": 2, "codimension": 1, "axes": [0], "sides": [-1], "identity": "left-face", @@ -38,7 +65,7 @@ def test_boundary_component_install_is_transactional_and_preserves_prepare_json( "interface_version": 1, "region": region, "parameters": [{"qualified_id": "case::inlet", "value": 2.0}], - "operation": "apply_region_batch", + "operation": operation, "state_identity": "case::block::state", "states": [], "directions": [], "fields": [], "outputs": ["case::block::state"], @@ -50,7 +77,12 @@ def test_boundary_component_install_is_transactional_and_preserves_prepare_json( "state": {"qualified_id": "case::block::state"}, "required_depth": 1, "faces": [ - {"ordinal": ordinal, "type": "foextrap", "values": [0.0]} + { + "ordinal": ordinal, + "producer": "case::block::boundary::face::%d" % ordinal, + "type": "foextrap", + "values": [0.0], + } for ordinal in range(4) ], "omitted_interface_faces": [], @@ -69,6 +101,7 @@ def __init__(self): self.prepare_overrides = None self.discarded = False self.state_routes = [] + self.installer = None def _install_block_state_route(self, block, identity): self.state_routes.append((block, identity)) @@ -81,10 +114,21 @@ def _discard_boundary_plans(self): def _install_ghost_boundary_component( self, block, handle, row, parameters_json, target_json, execution): + self._install_component( + "ghost", block, handle, row, parameters_json, target_json, execution) + + def _install_boundary_flux_component( + self, block, handle, row, parameters_json, target_json, execution): + self._install_component( + "flux", block, handle, row, parameters_json, target_json, execution) + + def _install_component( + self, installer, block, handle, row, parameters_json, target_json, execution): assert block == "block" assert handle is native_handle assert row == component_row assert execution["communicator_identity"] == "serial" + self.installer = installer self.prepare_overrides = (parameters_json, target_json) if prepare_fails: raise RuntimeError("component prepare rejected") @@ -109,7 +153,8 @@ class BoundaryBlock: interface=Interface(), native_handle=native_handle, ) artifact = SimpleNamespace( - blocks=(SimpleNamespace(name="block", model=SimpleNamespace(n_vars=1)),), + blocks=(SimpleNamespace( + name="block", model=SimpleNamespace(n_vars=1, cons_roles=("Scalar",))),), plan=SimpleNamespace(blocks=(BoundaryBlock(),), field_plans={}), layout_plan=SimpleNamespace(layouts=(SimpleNamespace(adaptive=False),)), ) @@ -128,9 +173,20 @@ class BoundaryBlock: assert native.state_routes == [("block", "case::block::state")] assert native.discarded is False assert native.prepare_overrides == ("", "") + assert native.installer == expected_installer -def test_signed_periodic_identification_reaches_native_install_without_callback(): +@pytest.mark.parametrize( + ("target_axis", "target_face", "permutation", "signs", "face_types"), + ( + (0, 1, [0, 1], [1, -1], + ["periodic", "periodic", "dirichlet", "foextrap"]), + (1, 3, [1, 0], [1, 1], + ["periodic", "foextrap", "dirichlet", "periodic"]), + ), +) +def test_signed_periodic_identification_reaches_native_install_without_callback( + target_axis, target_face, permutation, signs, face_types): def boundary_identity(name, axis, side): return { "qualified_id": "case::%s" % name, @@ -143,7 +199,7 @@ def boundary_identity(name, axis, side): } source = boundary_identity("xlo", 0, "lower") - target = boundary_identity("xhi", 0, "upper") + target = boundary_identity("target", target_axis, "upper") runtime_data = { "schema_version": 1, "authority_type": "prepared_boundary_plan", @@ -153,8 +209,15 @@ def boundary_identity(name, axis, side): "faces": [ { "ordinal": ordinal, - "type": "periodic" if ordinal < 2 else "foextrap", + "producer": "case::block::reflected-periodic::face::%d" % ordinal, + "type": face_types[ordinal], + "representation": "conservative", "values": [0.0], + "analytic_programs": ( + [{"opcodes": ["x", "input", "add"], "literals": [0.0, 0.0, 0.0]}] + if ordinal == 2 else [] + ), + "analytic_clock": "clock.analytic" if ordinal == 2 else None, } for ordinal in range(4) ], @@ -163,9 +226,9 @@ def boundary_identity(name, axis, side): "source": source, "target": target, "source_face": 0, - "target_face": 1, - "permutation": [0, 1], - "signs": [1, -1], + "target_face": target_face, + "permutation": permutation, + "signs": signs, }], "component_regions": [], "interface_component_bindings": [], @@ -198,7 +261,8 @@ class BoundaryBlock: native = Native() engine = SimpleNamespace(_s=native) artifact = SimpleNamespace( - blocks=(SimpleNamespace(name="block", model=SimpleNamespace(n_vars=1)),), + blocks=(SimpleNamespace( + name="block", model=SimpleNamespace(n_vars=1, cons_roles=("Scalar",))),), plan=SimpleNamespace(blocks=(BoundaryBlock(),), field_plans={}), layout_plan=SimpleNamespace(layouts=(SimpleNamespace(adaptive=False),)), ) @@ -212,5 +276,17 @@ class BoundaryBlock: install_runtime_authorities(engine, install_plan) assert native.installed is not None - assert native.installed[3] == ["periodic", "periodic", "foextrap", "foextrap"] - assert native.installed[8] == [[0, 1, 0, 1, 1, -1]] + assert native.installed[3] == face_types + assert native.installed[5] == [ + "case::block::reflected-periodic::face::0", + "case::block::reflected-periodic::face::1", + "case::block::reflected-periodic::face::2", + "case::block::reflected-periodic::face::3", + ] + assert native.installed[6] == ["Scalar"] + assert native.installed[9] == [[0, target_face, *permutation, *signs]] + assert native.installed[10] == ["conservative"] * 4 + assert native.installed[11] == [""] * 4 + assert native.installed[12] == [[], [], ["x", "input", "add"], []] + assert native.installed[13] == [[], [], [0.0, 0.0, 0.0], []] + assert native.installed[14] == ["", "", "clock.analytic", ""] diff --git a/tests/python/unit/runtime/test_capabilities.py b/tests/python/unit/runtime/test_capabilities.py index d877fdb09..4bff0cdb0 100644 --- a/tests/python/unit/runtime/test_capabilities.py +++ b/tests/python/unit/runtime/test_capabilities.py @@ -9,10 +9,9 @@ T1 - the published top-level keys stay present (the doc and the limitations pages key off them; a vanished key means a stale reference). - T2 - the Riemann surface matches the dispatch gates: hllc/roe are exposed on the cartesian - and AMR facades but NOT on polar (no polar energy-flux brick, make_block_polar rejects - them); polar exposes only rusanov + hll (the isothermal fluid declares wave_speeds). - Guards the "hllc/roe = 2D Euler only" and "polar = scalar ExB only" doc regressions. + T2 - the Riemann surface matches the dispatch gates: all four public providers have one + capability-gated Cartesian, polar and AMR route. The native isothermal polar model supplies + HLLC/Roe capabilities; scalar ExB still fails at the exact model-capability leaf. T3 - backends_dsl MPI/AMR flags agree (truthiness) with the _BACKEND_CAPS table that actually drives backend selection; catches drift between the two tables. T4 - the polar stability bounds (stability_speed / stability_dt / source_frequency) are @@ -48,14 +47,12 @@ def test_top_level_keys_present(): def test_riemann_surface_matches_dispatch(): - # ADC-752: each provider has one capability-gated route; polar stays rusanov + hll. + # ADC-752: each provider has one capability-gated route on every supported geometry. riemann = capabilities()["riemann"] expected = ["rusanov", "hll", "hllc", "roe"] assert riemann["system_cartesian"] == expected, riemann["system_cartesian"] assert riemann["amr"] == expected, riemann["amr"] - # Polar has no contact/Roe metric provider: only rusanov + hll are currently wired. - assert riemann["system_polar"] == ["rusanov", "hll"], riemann["system_polar"] - assert "hllc" not in riemann["system_polar"] and "roe" not in riemann["system_polar"] + assert riemann["system_polar"] == expected, riemann["system_polar"] def test_backends_dsl_flags_match_backend_caps(): diff --git a/tests/python/unit/runtime/test_consumer_authoring.py b/tests/python/unit/runtime/test_consumer_authoring.py index 85d6cf50f..b92d59ce6 100644 --- a/tests/python/unit/runtime/test_consumer_authoring.py +++ b/tests/python/unit/runtime/test_consumer_authoring.py @@ -3,7 +3,7 @@ import pytest import pops -from pops.diagnostics import Integral, StepChangeNorm +from pops.diagnostics import Balance, BalanceLedger, Integral, StepChangeNorm from pops.domain import Rectangle from pops.frames import Cartesian2D from pops.mesh import LayoutPlanBuilder, normalize_layout_plan @@ -23,9 +23,10 @@ ) from pops.linalg.norms import L2 from pops.output._consumer_contracts import ConsumerKind, ParallelMode +from pops.output._balance_due_contract import BalanceDueContract from pops.representations import Conservative from pops.spaces import CellState -from pops.time import Clock, FailRun as SolveFailRun, every +from pops.time import Clock, FailRun as SolveFailRun, every, on_start from tests.python.support.layout_plan import cartesian_grid @@ -96,6 +97,7 @@ def test_direct_consumers_resolve_references_layout_levels_and_parallel_mode(): "reduction": "sum", "transform": "identity", "metric_weighted": True, + "coefficient": (1.0).hex(), }, ) assert checkpoint.output_format is None @@ -248,10 +250,126 @@ def test_console_monitor_is_a_scheduled_rank_zero_diagnostic_consumer(): "reduction": "step_change_l2", "transform": "identity", "metric_weighted": False, + "coefficient": (1.0).hex(), }, ) +def test_balance_consumer_resolves_one_exact_native_ledger_route(): + case, block, state = _case() + clock = Clock("macro", owner=case.owner_path) + schedule = every(4, clock=clock) + ledger = BalanceLedger("mass") + graph = ConsumerGraph.from_consumers(( + ScientificOutput( + format=ParaView(), + schedule=schedule, + fields=(state,), + diagnostics=(Balance(ledger, block=block),), + target="state/balance", + ), + )) + case.consumers(graph) + pops.validate(case) + subjects = case.layout_subjects() + layout = normalize_layout_plan( + Uniform(cartesian_grid(n=8)), + owner=case.owner_path.canonical(), + states=subjects.states, + fields=subjects.fields, + blocks=subjects.blocks, + handle_resolver=case.resolve, + ) + + resolved = graph.resolve(case.resolve, layout, owner=case.owner_path.canonical()) + quantity, = resolved.nodes[0].diagnostic_quantities + operation, = quantity.execution["operations"] + contract = BalanceDueContract.from_consumer_graph(resolved) + route = ledger.route_identity(case.resolve(block)) + assert not schedule.consumer_may_fire_at_start() + assert operation["reduction"] == "accepted_balance" + assert operation["balance_route"] == route.token + assert quantity.reference == case.resolve(state) + assert contract.consumer_graph == resolved.identity + assert contract.route(route.token).accepted_step_periods() == (4,) + assert contract.identity.domain == "balance-due-contract" + + +def test_balance_consumer_retains_native_term_selector_in_due_contract(): + case, block, state = _case() + clock = Clock("macro", owner=case.owner_path) + schedule = every(4, clock=clock) + ledger = BalanceLedger( + "mass-native", automatic_terms=("projection", "reflux") + ) + graph = ConsumerGraph.from_consumers(( + ScientificOutput( + format=ParaView(), + schedule=schedule, + fields=(state,), + diagnostics=(Balance(ledger, block=block),), + target="state/native-balance", + ), + )) + case.consumers(graph) + pops.validate(case) + subjects = case.layout_subjects() + layout = normalize_layout_plan( + Uniform(cartesian_grid(n=8)), + owner=case.owner_path.canonical(), + states=subjects.states, + fields=subjects.fields, + blocks=subjects.blocks, + handle_resolver=case.resolve, + ) + + resolved = graph.resolve(case.resolve, layout, owner=case.owner_path.canonical()) + quantity, = resolved.nodes[0].diagnostic_quantities + operation, = quantity.execution["operations"] + route = ledger.route_identity(case.resolve(block)) + contract = BalanceDueContract.from_consumer_graph(resolved) + + assert operation["automatic_terms"] == ("projection", "reflux") + assert operation["balance_component"] == 0 + assert contract.route(route.token).automatic_terms == ("projection", "reflux") + + +def test_balance_consumer_refuses_a_schedule_that_can_fire_at_start(): + case, block, state = _case() + clock = Clock("macro", owner=case.owner_path) + schedule = on_start(clock=clock) + graph = ConsumerGraph.from_consumers(( + ScientificOutput( + format=ParaView(), + schedule=schedule, + fields=(state,), + diagnostics=(Balance(BalanceLedger("mass"), block=block),), + target="state/balance", + ), + )) + case.consumers(graph) + pops.validate(case) + subjects = case.layout_subjects() + layout = normalize_layout_plan( + Uniform(cartesian_grid(n=8)), + owner=case.owner_path.canonical(), + states=subjects.states, + fields=subjects.fields, + blocks=subjects.blocks, + handle_resolver=case.resolve, + ) + + assert schedule.consumer_may_fire_at_start() + with pytest.raises( + ValueError, + match=( + "Balance schedule cannot fire at_start: accepted balance evidence " + "exists only after a native step attempt" + ), + ): + graph.resolve(case.resolve, layout, owner=case.owner_path.canonical()) + + def test_console_monitor_can_be_removed_at_authoring_time(): case, block, _state = _case() monitor = ConsoleMonitor( @@ -410,8 +528,6 @@ def test_output_format_options_refuse_python_truthiness_coercion() -> None: HDF5(mode="serial") with pytest.raises(TypeError, match="exact bool or None"): HDF5(series=1) - with pytest.raises(TypeError, match="exact bool or None"): - ParaView(series=1) assert HDF5().consumer_data()["options"] == {"mode": "serial", "series": True} serial_options = ParaView().consumer_data()["options"] assert serial_options["mode"] == "serial" diff --git a/tests/python/unit/runtime/test_consumer_transactions.py b/tests/python/unit/runtime/test_consumer_transactions.py index 9bdc4d645..ceef81318 100644 --- a/tests/python/unit/runtime/test_consumer_transactions.py +++ b/tests/python/unit/runtime/test_consumer_transactions.py @@ -47,6 +47,14 @@ from tests.python.unit.runtime.test_runtime_planning import _install, _manifest +def _output_mode(runtime) -> ParallelMode: + return ( + ParallelMode.SERIAL + if runtime.communication.communicator_id == "serial" + else ParallelMode.PER_RANK + ) + + def _runtime(*, collective: bool = False): install = _install() requirements = () @@ -77,7 +85,7 @@ def _manifest_for( resource: str = "state:u", dependency: Handle | None = None, action=None, - parallel_mode=ParallelMode.SERIAL, + parallel_mode=None, ) -> ConsumerManifest: owner = OwnerPath.consumer("adc-685") handle = Handle(name, kind="consumer", owner=owner) @@ -89,6 +97,8 @@ def _manifest_for( dependencies = (dependency,) if dependency is not None else () if action is None: action = FailRun() + if parallel_mode is None: + parallel_mode = _output_mode(runtime) return ConsumerManifest( handle=handle, kind=ConsumerKind.SCIENTIFIC_OUTPUT, @@ -97,7 +107,9 @@ def _manifest_for( target_uri="file:///adc-685/%s" % name, output_format=( HDF5(mode=ParallelMode.COLLECTIVE) - if parallel_mode is ParallelMode.COLLECTIVE else NPZ()), + if parallel_mode is ParallelMode.COLLECTIVE + else NPZ(mode=parallel_mode) + ), parallel_mode=parallel_mode, dependencies=dependencies, failure_action=action, @@ -138,11 +150,19 @@ def publish(self): self.publisher.temporaries.remove(self.temp_id) artifact = "artifact-%s" % self.effect.payload.identity.hexdigest[:12] self.publisher.artifacts.add(artifact) + mode = self.effect.target.parallel_mode + rank_artifacts = () + if mode is ParallelMode.PER_RANK: + rank_artifacts = tuple( + (rank, "%s-r%d" % (artifact, rank)) for rank in range(2) + ) return PublicationReceipt( self.effect.identity, self.effect.payload.identity, "test-publisher", artifact, + parallel_mode=mode, + rank_artifacts=rank_artifacts, ) def discard(self): @@ -221,6 +241,24 @@ def test_graph_and_plan_are_semantic_and_insertion_order_independent(): def test_distributed_modes_require_a_nonserial_context_before_planning(parallel_mode): _, serial_runtime = _runtime() clock = Clock("solution", owner=OwnerPath.consumer("adc-685-collective")) + if serial_runtime.communication.communicator_id != "serial": + # The installed MPI package cannot manufacture a serial ExecutionContext. Prove the + # inverse mismatch against its real context instead; the serial CI route exercises every + # distributed mode below. + manifest = replace( + _manifest_for(serial_runtime, "serial", clock), + output_format=NPZ(mode=ParallelMode.SERIAL), + parallel_mode=ParallelMode.SERIAL, + ) + with pytest.raises(RuntimePlanningError) as error: + plan_accepted_side_effects( + serial_runtime, ConsumerGraph((manifest,)), _moment(clock) + ) + assert error.value.code == "serial_consumer_requires_serial_context" + assert error.value.evidence == { + "communicator": serial_runtime.communication.communicator_id + } + return output_format = ( HDF5(mode=parallel_mode) if parallel_mode is not ParallelMode.PER_RANK @@ -314,14 +352,15 @@ def test_stale_field_requires_explicit_policy_and_records_recompute_without_solv runtime.calls[0].layout_id, field_context=context, ) + parallel_mode = _output_mode(runtime) manifest = ConsumerManifest( Handle("field-output", kind="consumer", owner=OwnerPath.consumer("adc-685-field")), ConsumerKind.SCIENTIFIC_OUTPUT, (quantity,), Schedule(Every(AcceptedStep(clock), 1)), "file:///adc-685/field", - NPZ(), - ParallelMode.SERIAL, + NPZ(mode=parallel_mode), + parallel_mode, ) moment = _moment(clock, step=2, layouts=(layout,)) with pytest.raises(RuntimePlanningError) as error: diff --git a/tests/python/unit/runtime/test_cutcell_thresholds.py b/tests/python/unit/runtime/test_cutcell_thresholds.py index f8c85268d..f659e033e 100644 --- a/tests/python/unit/runtime/test_cutcell_thresholds.py +++ b/tests/python/unit/runtime/test_cutcell_thresholds.py @@ -55,20 +55,20 @@ def test_transport_mask_thresholds_require_typed_mask(): # --- runtime tier (needs _pops) ---------------------------------------------- pops = pytest.importorskip("pops") -from pops.runtime._engine_descriptors import ( +from pops.runtime._engine_descriptors import ( # noqa: E402 ChargeDensity, FluidState, IsothermalFlux, Model, NoSource, Spatial, ) -from pops.runtime._system import System # ADC-545 advanced runtime seam +from pops.runtime._system import System # noqa: E402 # ADC-545 advanced runtime seam def _sim(): sim = System(n=16, L=1.0, periodicity=(False, False)) - sim.add_block("ion", Model(FluidState.isothermal(cs2=0.7), IsothermalFlux(), - NoSource(), ChargeDensity(charge=1.0)), - # The native embedded-boundary facade currently provides a geometry-aware - # first-order reconstruction. Higher-order neighbor stencils are rejected - # instead of reading inactive cells. - spatial=Spatial(none=True)) + sim.add_equation("ion", Model(FluidState.isothermal(cs2=0.7), IsothermalFlux(), + NoSource(), ChargeDensity(charge=1.0)), + # The native embedded-boundary facade currently provides a geometry-aware + # first-order reconstruction. Higher-order neighbor stencils are rejected + # instead of reading inactive cells. + spatial=Spatial(none=True)) return sim diff --git a/tests/python/unit/runtime/test_destring_finite_volume.py b/tests/python/unit/runtime/test_destring_finite_volume.py index 3e1763be5..cb078072e 100644 --- a/tests/python/unit/runtime/test_destring_finite_volume.py +++ b/tests/python/unit/runtime/test_destring_finite_volume.py @@ -25,7 +25,7 @@ from pops.numerics.riemann import Rusanov, HLL, HLLC, Roe # noqa: E402 from pops.numerics.reconstruction import FirstOrder, MUSCL, WENO5, WENO5Z # noqa: E402 -from pops.numerics.reconstruction.limiters import Minmod, VanLeer # noqa: E402 +from pops.numerics.reconstruction.limiters import MC, Minmod, Superbee, VanLeer # noqa: E402 from pops.numerics.variables import Conservative, Primitive # noqa: E402 @@ -91,9 +91,12 @@ def test_typed_flux_descriptors_lower(): def test_typed_limiter_descriptors_lower(): cases = ((FirstOrder(), "none"), (Minmod(), "minmod"), (VanLeer(), "vanleer"), + (MC(), "mc"), (Superbee(), "superbee"), (WENO5(), "weno5"), (WENO5Z(), "weno5"), (MUSCL(limiter=Minmod()), "minmod"), - (MUSCL(limiter=VanLeer()), "vanleer")) + (MUSCL(limiter=VanLeer()), "vanleer"), + (MUSCL(limiter=MC()), "mc"), + (MUSCL(limiter=Superbee()), "superbee")) for desc, token in cases: s = engine.Spatial(limiter=desc) assert s.limiter == token, (desc, s.limiter) @@ -111,6 +114,17 @@ def test_combined_typed_spatial(): assert (s.limiter, s.flux, s.recon) == ("vanleer", "hllc", "primitive") +def test_mc_and_superbee_share_the_prepared_spatial_route() -> None: + for limiter, token in ((MC(), "mc"), (Superbee(), "superbee")): + for variables, variables_token in ( + (Conservative(), "conservative"), (Primitive(), "primitive") + ): + spatial = engine.Spatial(limiter=limiter, flux=Roe(), recon=variables) + assert spatial.limiter.id == "limiter.%s" % token + assert spatial.limiter.native_entry == limiter.native_id + assert (spatial.flux, spatial.recon) == ("roe", variables_token) + + def test_defaults_are_canonical(): s = engine.Spatial() assert (s.limiter, s.flux, s.recon) == ("minmod", "rusanov", "conservative") diff --git a/tests/python/unit/runtime/test_diagnostics_typed.py b/tests/python/unit/runtime/test_diagnostics_typed.py index 289b8dc56..62ca4794d 100644 --- a/tests/python/unit/runtime/test_diagnostics_typed.py +++ b/tests/python/unit/runtime/test_diagnostics_typed.py @@ -16,8 +16,8 @@ pops = pytest.importorskip("pops") from pops.descriptors import Descriptor # noqa: E402 -from pops.diagnostics import (ConservationCheck, Integral, MinMax, # noqa: E402 - Norm, StepChangeNorm) +from pops.diagnostics import (Balance, BalanceLedger, ConservationCheck, # noqa: E402 + Integral, MinMax, Norm, StepChangeNorm) from pops.linalg.norms import L1, L2, LInf # noqa: E402 from pops.model import Module # noqa: E402 from pops.physics.roles import Density # noqa: E402 @@ -31,7 +31,10 @@ # --- package surface -------------------------------------------------------------------- def test_typed_measures_exported(): import pops.diagnostics as diag - for name in ("Norm", "Integral", "MinMax", "ConservationCheck", "StepChangeNorm"): + for name in ( + "Balance", "BalanceLedger", "Norm", "Integral", "MinMax", + "ConservationCheck", "StepChangeNorm", + ): assert hasattr(diag, name), name assert name in diag.__all__, name @@ -83,6 +86,7 @@ def test_step_change_norm_is_typed_l2_and_whole_state(): assert change.diagnostic_execution()["operations"] == [{ "name": "step_change_l2", "reduction": "step_change_l2", "transform": "identity", "metric_weighted": False, + "coefficient": (1.0).hex(), }] with pytest.raises(ValueError, match="exactly.*L2"): StepChangeNorm(L1()) @@ -90,15 +94,112 @@ def test_step_change_norm_is_typed_l2_and_whole_state(): StepChangeNorm("l2") +def test_balance_uses_one_typed_native_attempt_route(): + ledger = BalanceLedger("mass") + balance = Balance(ledger, block=_NE_BLOCK) + execution = balance.diagnostic_execution() + operation, = execution["operations"] + assert operation["name"] == "balance" + assert operation["reduction"] == "accepted_balance" + assert operation["coefficient"] == (1.0).hex() + assert operation["balance_route"].startswith( + "pops.balance-ledger-route.v1:sha256:") + assert execution["role"] is None and execution["conservation"] is None + assert balance.options()["ledger"] == ledger.to_data() + from pops.output._consumer_contracts import diagnostic_collective_operations + + assert diagnostic_collective_operations(execution) == () + mixed = { + **execution, + "operations": execution["operations"] + [{ + "name": "integral", + "reduction": "sum", + "transform": "identity", + "metric_weighted": True, + "coefficient": (1.0).hex(), + }], + } + with pytest.raises(ValueError, match="sole diagnostic execution operation"): + diagnostic_collective_operations(mixed) + scaled = { + **execution, + "operations": [{**operation, "coefficient": (2.0).hex()}], + } + with pytest.raises(ValueError, match="coefficient"): + diagnostic_collective_operations(scaled) + with pytest.raises(TypeError, match="BalanceLedger"): + Balance("mass", block=_NE_BLOCK) + with pytest.raises(TypeError, match="physics BlockHandle"): + Balance(ledger, block=None) + with pytest.raises(ValueError, match="open-domain Balance"): + ConservationCheck(balance).diagnostic_execution() + + +def test_balance_ledger_selects_exact_native_component_terms(): + ledger = BalanceLedger( + "mass-native", + role=Density(), + automatic_terms=("projection", "reflux"), + ) + balance = Balance(ledger, block=_NE_BLOCK) + execution = balance.diagnostic_execution() + operation, = execution["operations"] + + assert execution["role"] == "Density" + assert operation["automatic_terms"] == ["projection", "reflux"] + assert operation["balance_component"] == 0 + assert balance.options()["role"] == "Density" + assert ledger.to_data()["role"] == "Density" + assert ledger.to_data()["component"] == 0 + assert ledger.to_data()["automatic_terms"] == ["projection", "reflux"] + assert ledger.identity != BalanceLedger("mass-native").identity + + with pytest.raises(TypeError, match="ComponentRole"): + BalanceLedger("bad-role", role="Density") + reordered = BalanceLedger( + "canonical-order", automatic_terms=("reflux", "projection") + ) + assert reordered.automatic_terms == ("projection", "reflux") + with pytest.raises(ValueError, match="must be unique"): + BalanceLedger("duplicate", automatic_terms=("reflux", "reflux")) + with pytest.raises(ValueError, match="only reflux and projection"): + BalanceLedger("bad-producer", automatic_terms=("sources",)) + with pytest.raises(TypeError, match="non-negative int"): + BalanceLedger("bad-component", component=-1, automatic_terms=("projection",)) + + # --- Integral / MinMax ------------------------------------------------------------------ def test_integral_is_a_sum_reduction(): - mass = Integral(role=Density()) + mass = Integral(role=Density(), coefficient=-2.0) assert isinstance(mass, Descriptor) assert mass.category == "diagnostic_integral" assert mass.options()["scheme"] == "integral" assert mass.options()["role"] == "Density" assert mass.options()["block"] is None + assert mass.options()["coefficient"] == (-2.0).hex() assert mass.capabilities().to_dict()["reduction"] == "sum" + assert mass.diagnostic_execution()["operations"][0]["coefficient"] == (-2.0).hex() + + +@pytest.mark.parametrize("coefficient", [True, "1", object()]) +def test_integral_rejects_untyped_coefficients(coefficient): + with pytest.raises(TypeError, match="coefficient"): + Integral(coefficient=coefficient) + + +@pytest.mark.parametrize( + "coefficient", + [ + 0.0, + float("inf"), + float("-inf"), + float("nan"), + pytest.param(10**10_000, id="overflowing-int"), + ], +) +def test_integral_rejects_nonfinite_or_zero_coefficients(coefficient): + with pytest.raises(ValueError, match="coefficient"): + Integral(coefficient=coefficient) def test_minmax_is_a_minmax_reduction(): @@ -181,7 +282,7 @@ def test_measures_expose_closed_native_execution_plans(): } assert plans["l1"]["operations"] == [{ "name": "l1", "reduction": "abs_sum", "transform": "identity", - "metric_weighted": True, + "metric_weighted": True, "coefficient": (1.0).hex(), }] assert plans["l2"]["operations"][0]["transform"] == "sqrt" assert plans["linf"]["operations"][0]["reduction"] == "abs_max" @@ -204,6 +305,7 @@ def test_conservation_check_rejects_invalid_tolerance_and_multivalued_quantity() # --- inspect() / options() / __repr__ (Spec 5 sec.12.1 printable rule) ------------------ @pytest.mark.parametrize("measure,cls_name,category", [ + (Balance(BalanceLedger("mass"), block=_NE_BLOCK), "Balance", "diagnostic_balance"), (Norm(L2(), block=_NE_BLOCK), "Norm", "diagnostic_norm"), (Integral(role=Density()), "Integral", "diagnostic_integral"), (MinMax(block=_NE_BLOCK), "MinMax", "diagnostic_minmax"), diff --git a/tests/python/unit/runtime/test_numerical_defaults_reports.py b/tests/python/unit/runtime/test_numerical_defaults_reports.py index f79c4d174..6a40a0d39 100644 --- a/tests/python/unit/runtime/test_numerical_defaults_reports.py +++ b/tests/python/unit/runtime/test_numerical_defaults_reports.py @@ -76,7 +76,6 @@ def test_system_inspect_reports_effective_block_and_solver_options(): newton_rel_tol=1e-6, newton_fd_eps=2e-7, newton_damping=0.8, - newton_diagnostics=True, ), spatial=engine.Spatial(positivity_floor=1e-12), ) @@ -97,12 +96,25 @@ def test_system_inspect_reports_effective_block_and_solver_options(): assert block["newton"]["rel_tol"] == pytest.approx(1e-6) assert block["newton"]["fd_eps"] == pytest.approx(2e-7) assert "fail_policy" not in block["newton"] - assert block["newton"]["diagnostics"] is True + assert block["newton"]["diagnostics"] is False assert block["physical"]["cs2"] == pytest.approx(0.7) assert block["physical"]["q"] == pytest.approx(-2.0) assert block["positivity_floor"] == pytest.approx(1e-12) +def test_system_rejects_unpublished_newton_diagnostics(): + sim = System(n=8, L=1.0, periodicity=(True, True)) + with pytest.raises( + ValueError, + match="no typed implicit Program consumer publishes that report", + ): + sim.add_equation( + "ion", + _isothermal_model(), + time=engine.IMEX(newton_diagnostics=True), + ) + + def test_invalid_newton_values_are_rejected(): with pytest.raises(ValueError, match="newton_max_iters"): engine.IMEX(newton_max_iters=0) diff --git a/tests/python/unit/runtime/test_platform_manifest.py b/tests/python/unit/runtime/test_platform_manifest.py index 135559457..599ab0254 100644 --- a/tests/python/unit/runtime/test_platform_manifest.py +++ b/tests/python/unit/runtime/test_platform_manifest.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import replace +from types import SimpleNamespace import pytest @@ -14,6 +15,7 @@ launch_checked, proven_serial_manifest, validate_component_launch, + validate_component_runtime, validate_launch, ) from pops.identity import make_identity @@ -120,11 +122,49 @@ def test_unknown_is_missing_proof_and_3d_is_representable_then_refused(): launch_checked(_platform(), _context(), [three_d], lambda *_: None) +def test_platform_support_set_is_distinct_from_layout_resolved_dimension(): + platform = _platform() + context = _context() + supported = _proof((1, 2, 3)) + platform = replace( + platform, + capabilities=dict(platform.capabilities, supported_dimensions=supported), + ) + context = replace( + context, + backend=replace( + context.backend, + capabilities=dict( + context.backend.capabilities, + supported_dimensions=supported, + ), + ), + ) + plan = SimpleNamespace( + artifact=SimpleNamespace( + platform_manifest=platform, + plan=SimpleNamespace(resolved_dimension=2), + ), + execution_context=context, + ) + + from pops.runtime._runtime_plan_io import proved_platform + + _, _, _, facts = proved_platform(plan) + assert facts["supported_dimensions"] == (1, 2, 3) + assert facts["dimension"] == 2 + + @pytest.mark.parametrize("changed", [ {"centering": "node"}, {"scalar": "float32"}, {"extents": (15, 12)}, {"memory_space": "device"}, + {"strides": (1, 16)}, + {"ghosts": ((1, 0), (0, 0))}, + {"patch": "patch-1"}, + {"layout": "left"}, + {"ownership": "owned"}, ]) def test_field_mismatch_refuses_before_kernel(changed): launched = [] @@ -135,6 +175,62 @@ def test_field_mismatch_refuses_before_kernel(changed): assert launched == [] +def test_field_view_requires_exact_capability_proofs_before_kernel(): + launched = [] + platform = _platform() + context = _context() + + missing = dict(platform.capabilities) + missing.pop("ownership") + with pytest.raises(PlatformContractError, match="omitted required field-view capability"): + launch_checked( + replace(platform, capabilities=missing), context, [_field()], + lambda *_: launched.append(True)) + + unsupported_layout = _proof(("left",)) + artifact_capabilities = dict(platform.capabilities, layouts=unsupported_layout) + runtime_capabilities = dict(context.backend.capabilities, layouts=unsupported_layout) + with pytest.raises(PlatformContractError, match="unsupported layout='right'"): + launch_checked( + replace(platform, capabilities=artifact_capabilities), + replace(context, backend=replace( + context.backend, capabilities=runtime_capabilities)), + [_field()], lambda *_: launched.append(True)) + + generic_disabled = _proof(False) + artifact_capabilities = dict(platform.capabilities, generic_field_view=generic_disabled) + runtime_capabilities = dict(context.backend.capabilities, generic_field_view=generic_disabled) + with pytest.raises(PlatformContractError, match="does not prove the generic field-view"): + launch_checked( + replace(platform, capabilities=artifact_capabilities), + replace(context, backend=replace( + context.backend, capabilities=runtime_capabilities)), + [_field()], lambda *_: launched.append(True)) + + assert launched == [] + + +@pytest.mark.parametrize("expected", [False, True]) +def test_duplicate_field_names_refuse_before_kernel(expected): + launched = [] + actual_fields = [_field(), _field()] + expected_fields = [_field(), _field()] if expected else [_field()] + if expected: + actual_fields = [_field()] + with pytest.raises(PlatformContractError, match="descriptors contain duplicate name"): + launch_checked( + _platform(), _context(), actual_fields, lambda *_: launched.append(True), + expected_fields=expected_fields) + assert launched == [] + + +def test_field_view_ghosts_must_leave_positive_interior(): + with pytest.raises(ValueError, match="positive interior"): + _field(ghosts=((16, 0), (0, 0))) + with pytest.raises(ValueError, match="positive interior"): + _field(ghosts=((8, 8), (0, 0))) + + def test_generic_2d_double_descriptor_launches_once(): launched = [] assert launch_checked( @@ -163,6 +259,16 @@ def test_aot_component_build_route_is_checked_against_simulation_execution_facts validate_component_launch(_platform(), context, ()) +def test_aot_component_field_capabilities_fail_before_native_load(): + component = proven_serial_manifest( + backend="aot-component", target="component", abi="headers|clang|c++23") + runtime = _context().backend + missing = dict(component.capabilities) + missing.pop("layouts") + with pytest.raises(PlatformContractError, match="omitted required field-view capability"): + validate_component_runtime(replace(component, capabilities=missing), runtime) + + def test_aot_component_rejects_openmpi_mpich_abi_mix_even_with_same_headers_and_standard(): openmpi = ( "compiler=clang;std=202002;headers=same;kokkos=1;stdlib=libc++;" diff --git a/tests/python/unit/runtime/test_predictor_corrector.py b/tests/python/unit/runtime/test_predictor_corrector.py index 359279aac..077e11d4f 100644 --- a/tests/python/unit/runtime/test_predictor_corrector.py +++ b/tests/python/unit/runtime/test_predictor_corrector.py @@ -325,10 +325,11 @@ def _free_source_program(): pc_plan = _resolved_case("pc_public", "predictor_corrector") pc_src = _emit_resolved(pc_plan) chk( - pc_src.count("ctx.solve_fields_from_state(") == 2 + pc_src.count("ctx.solve_fields_from_state_at(") == 2 + and pc_src.count("const auto field_boundary_point_") == 2 and ", 0, u0);" in pc_src and ", 0, u7);" in pc_src, - "predictor and corrector re-solve fields from their own stage states", + "predictor and corrector re-solve exact providers from their own level/stage states", ) chk( bool(pc_src), diff --git a/tests/python/unit/runtime/test_program_report.py b/tests/python/unit/runtime/test_program_report.py index 005d933eb..a53748088 100644 --- a/tests/python/unit/runtime/test_program_report.py +++ b/tests/python/unit/runtime/test_program_report.py @@ -4,6 +4,7 @@ executor. These unit checks exercise the single report owner directly: no legacy ``System`` is constructed and no native state array is read. """ + from __future__ import annotations import json @@ -12,6 +13,8 @@ import pytest +from pops.identity import make_identity +from pops.runtime._multi_layout_executor import _MultiLayoutUniformExecutor from pops.runtime.program_report import ProgramRuntimeReport, build_program_report @@ -82,8 +85,14 @@ def checkpoint_temporal_relations(self): return [(0, 1, 1, 2, "exact")] def program_flux_ledger_manifest(self): - return [("fluid", "U", "rhs", "transport", 1, 4, 1, 2, 1, 2, - "outward", 0.25, 0.125)] + return [("fluid", "U", "rhs", "transport", 1, 4, 1, 2, 1, 2, "outward", 0.25, 0.125)] + + def program_temporal_partition_manifest(self): + return [ + ("summary", "cell_local", "test.partition@1", 7, 16, 32, 5), + ("rung", 0, 3), + ("rung", 1, 2), + ] def program_sync_manifest(self): return [(0, 1, 0, "reflux", 4, 1, 2)] @@ -108,6 +117,7 @@ def test_empty_authority_produces_an_honest_empty_report(): assert report.level_relations == [] assert report.flux_ledger == [] assert report.synchronization == [] + assert report.temporal_partition == {} assert report.temporal == {} assert report.profiler == {"enabled": None} @@ -123,23 +133,44 @@ def test_accepted_program_report_preserves_owned_metadata(): assert report.params[0]["count"] == 2 assert report.params[0]["limit"] > 0 assert report.diagnostics == {"mass": 3.5} - assert report.histories == [{ - "name": "u_prev", "depth": 2, "ncomp": 3, "initialized": True, - }] - assert report.cache == [{ - "node_id": 7, - "name": "stage_rhs", - "last_update_step": 4, - "accumulated_dt": 0.125, - }] + assert report.histories == [ + { + "name": "u_prev", + "depth": 2, + "ncomp": 3, + "initialized": True, + } + ] + assert report.cache == [ + { + "node_id": 7, + "name": "stage_rhs", + "last_update_step": 4, + "accumulated_dt": 0.125, + } + ] assert report.clocks == [ {"kind": "logical", "clock": "main", "tick": 4}, - {"kind": "level", "level": 1, "macro_step": 4, - "phase": {"numerator": 1, "denominator": 2}, "physical_time": 0.5}, + { + "kind": "level", + "level": 1, + "macro_step": 4, + "phase": {"numerator": 1, "denominator": 2}, + "physical_time": 0.5, + }, ] assert report.level_relations[0]["remainder_policy"] == "exact" assert report.flux_ledger[0]["flux"] == "transport" assert report.synchronization[0]["phase"] == "reflux" + assert report.temporal_partition == { + "kind": "cell_local", + "provider_identity": "test.partition@1", + "topology_epoch": 7, + "synchronization_tick": 16, + "tick_denominator": 32, + "cell_count": 5, + "rungs": [{"rung": 0, "cells": 3}, {"rung": 1, "cells": 2}], + } assert report.temporal == {"schema_version": 1, "accepted_step": 4} @@ -147,7 +178,7 @@ def test_report_serialization_is_array_free_and_detached(): report = build_program_report(_AcceptedProgramAuthority()) data = report.to_dict() - assert data["schema_version"] == 3 + assert data["schema_version"] == 4 assert data["report_type"] == "program_runtime" assert json.loads(report.to_json()) == data assert "accepted-program" in str(report) @@ -157,5 +188,47 @@ def test_report_serialization_is_array_free_and_detached(): assert report.histories +def test_multi_layout_report_preserves_the_common_temporal_partition(): + partition = { + "kind": "global", + "provider_identity": "pops.temporal-partition.global.v1", + } + + def child(layout_id, block): + layout_program = SimpleNamespace( + layout_id=layout_id, + identity=make_identity("layout-program", {"layout": layout_id}), + ) + report = ProgramRuntimeReport( + installed=True, + program_hash="sha256:%s" % layout_id, + step_transaction={"strategy": {"kind": "fixed"}}, + block_map=[0], + params=[{"program_block": 0, "count": 0, "limit": 8}], + diagnostics={}, + histories=[], + cache=[], + profiler={"enabled": False}, + clocks=[], + level_relations=[], + flux_ledger=[], + synchronization=[], + temporal_partition=partition, + temporal={"schema_version": 1, "accepted_step": 0}, + ) + return layout_program, (block,), report + + executor = object.__new__(_MultiLayoutUniformExecutor) + executor._ordered_program_reports = lambda: ( + child("layout-a", "fluid"), + child("layout-b", "field"), + ) + executor.block_names = lambda: ("fluid", "field") + + report = executor.program_report() + + assert report.temporal_partition == partition + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-q"])) diff --git a/tests/python/unit/runtime/test_runtime_executor_context.py b/tests/python/unit/runtime/test_runtime_executor_context.py index 034ce4183..de7a69170 100644 --- a/tests/python/unit/runtime/test_runtime_executor_context.py +++ b/tests/python/unit/runtime/test_runtime_executor_context.py @@ -13,9 +13,15 @@ ExecutionResource, proven_serial_manifest, ) +from pops.identity import make_identity from pops.runtime import _multi_layout_executor as multi_executor from pops.runtime import _platform_manifest as platform_manifest from pops.runtime import _runtime_executor as executor +from pops.runtime import _runtime_planning as runtime_planning +from pops.runtime._runtime_plan_contracts import ( + DeterminismGuarantee, + RuntimePlanningError, +) from pops.runtime._runtime_planning import build_runtime_plans from tests.python.unit.runtime.test_runtime_planning import _install, _manifest @@ -89,6 +95,7 @@ def forbidden_constructor(*args, **kwargs): assert len(memory_spaces) == 1 facts = { "mpi_active": False, + "mpi_ranks": 1, "kokkos_backend": backend.capabilities["execution_backend"].require( "runtime.execution_backend" ), @@ -139,6 +146,206 @@ def forbidden_preflight(*args, **kwargs): assert calls == [] +def test_determinism_assumptions_are_rechecked_before_native_preflight(monkeypatch): + plan = SimpleNamespace(execution_context=SimpleNamespace()) + runtime_plan = SimpleNamespace( + determinism=DeterminismGuarantee( + "reproducible", + ("rank_count",), + {"rank_count": 1}, + {}, + make_identity("execution-context", {"test": "runtime-executor"}), + ), + communication=SimpleNamespace(collectives=()), + ) + calls = [] + + def forbidden_preflight(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("native preflight became reachable") + + monkeypatch.setattr(executor, "require_install_plan", lambda value: value) + monkeypatch.setattr(executor, "_require_supported_execution_context", forbidden_preflight) + monkeypatch.setattr( + runtime_planning, + "require_runtime_plan_bundle", + lambda _plan, value: value, + ) + monkeypatch.setattr( + executor, + "_native_runtime_facts", + lambda: { + "mpi_ranks": 2, + }, + ) + with pytest.raises(RuntimePlanningError) as error: + executor.install_runtime_executor(plan, runtime_plan) + assert error.value.code == "determinism_assumption_mismatch" + assert calls == [] + + +def test_matching_runtime_determinism_assumptions_are_consumed(): + guarantee = DeterminismGuarantee( + "reproducible", + ("rank_count",), + {"rank_count": 1}, + {}, + make_identity("execution-context", {"test": "matching-runtime-executor"}), + ) + executor._require_runtime_determinism( + SimpleNamespace(execution_context=SimpleNamespace()), + SimpleNamespace( + determinism=guarantee, + communication=SimpleNamespace(collectives=()), + ), + {"mpi_ranks": 1}, + ) + + +def _single_layout_projection(): + layout = SimpleNamespace(handle=SimpleNamespace(qualified_id="layout::primary")) + plan = SimpleNamespace( + artifact=SimpleNamespace( + blocks=(SimpleNamespace(name="fluid"),), + layout_plan=SimpleNamespace( + layouts=(layout,), + assignments=( + SimpleNamespace( + subject_kind="block", + subject_id="block::fluid", + subject=SimpleNamespace( + local_id="fluid", qualified_id="block::fluid" + ), + layout=layout.handle, + ), + ), + ), + ) + ) + runtime_plan = SimpleNamespace( + calls=(SimpleNamespace(block_id="block::fluid", layout_id="layout::primary"),), + communication=SimpleNamespace( + transfers=(), + halos=(SimpleNamespace(layout_id="layout::primary"),), + ), + resources=SimpleNamespace(mapping_provider_ids=()), + ) + return plan, runtime_plan + + +def test_single_layout_provider_consumes_exact_call_and_halo_projection(): + plan, runtime_plan = _single_layout_projection() + + executor._require_single_layout_runtime_plan(plan, runtime_plan) + + runtime_plan.calls[0].layout_id = "layout::other" + with pytest.raises(ValueError, match="calls differ"): + executor._require_single_layout_runtime_plan(plan, runtime_plan) + runtime_plan.calls[0].layout_id = "layout::primary" + + runtime_plan.communication.halos[0].layout_id = "layout::other" + with pytest.raises(ValueError, match="halo differs"): + executor._require_single_layout_runtime_plan(plan, runtime_plan) + + +@pytest.mark.parametrize("transfers,providers,match", [ + ((object(),), (), "layout Transfers"), + ((), ("pops://mapping/test",), "mapping providers"), +]) +def test_single_layout_provider_refuses_unconsumed_mapping_routes( + transfers, providers, match +): + plan, runtime_plan = _single_layout_projection() + runtime_plan.communication.transfers = transfers + runtime_plan.resources.mapping_provider_ids = providers + + with pytest.raises(ValueError, match=match): + executor._require_single_layout_runtime_plan(plan, runtime_plan) + + +@pytest.mark.parametrize( + "provider", + (executor._UniformNativeProvider(), executor._AdaptiveNativeProvider()), +) +def test_single_layout_providers_refuse_call_mismatch_before_geometry( + monkeypatch, provider +): + plan, runtime_plan = _single_layout_projection() + runtime_plan.calls[0].block_id = "block::other" + reached = [] + monkeypatch.setattr(executor, "require_install_plan", lambda value: value) + monkeypatch.setattr(executor, "_require_native_geometry", reached.append) + + with pytest.raises(ValueError, match="calls differ"): + provider.install(plan, runtime_plan) + assert reached == [] + + +def _multi_layout_projection(): + primary = SimpleNamespace(qualified_id="layout::primary") + secondary = SimpleNamespace(qualified_id="layout::secondary") + blocks = ( + SimpleNamespace(name="fluid"), + SimpleNamespace(name="solid"), + ) + assignments = tuple( + SimpleNamespace( + subject_kind="block", + subject_id=block_id, + subject=SimpleNamespace(local_id=name), + layout=layout, + ) + for name, block_id, layout in ( + ("fluid", "block::fluid", primary), + ("solid", "block::solid", secondary), + ) + ) + plan = SimpleNamespace( + artifact=SimpleNamespace( + blocks=blocks, + layout_plan=SimpleNamespace(assignments=assignments), + ) + ) + transfer = SimpleNamespace(provider_id="pops://mapping/primary-secondary") + runtime_plan = SimpleNamespace( + calls=tuple( + SimpleNamespace(block_id=block_id, layout_id=layout.qualified_id) + for block_id, layout in ( + ("block::fluid", primary), + ("block::solid", secondary), + ) + ), + communication=SimpleNamespace(halos=()), + resources=SimpleNamespace( + mapping_provider_ids=("pops://mapping/primary-secondary",) + ), + ) + return plan, runtime_plan, (transfer,) + + +def test_multi_layout_provider_consumes_exact_call_and_mapping_projection(): + plan, runtime_plan, transfers = _multi_layout_projection() + + multi_executor._require_runtime_plan_projection(plan, runtime_plan, transfers) + + runtime_plan.calls[1].layout_id = "layout::primary" + with pytest.raises(ValueError, match="calls differ"): + multi_executor._require_runtime_plan_projection(plan, runtime_plan, transfers) + runtime_plan.calls[1].layout_id = "layout::secondary" + + runtime_plan.resources.mapping_provider_ids = ("pops://mapping/other",) + with pytest.raises(ValueError, match="mapping providers differ"): + multi_executor._require_runtime_plan_projection(plan, runtime_plan, transfers) + + +def test_multi_layout_provider_refuses_unconsumed_halo_plan(): + plan, runtime_plan, transfers = _multi_layout_projection() + runtime_plan.communication.halos = (object(),) + + with pytest.raises(NotImplementedError, match="explicit per-layout halo scheduler"): + multi_executor._require_runtime_plan_projection(plan, runtime_plan, transfers) + + def test_before_step_transfer_cycle_captures_every_native_source_before_any_apply(): @@ -321,40 +528,52 @@ def test_runtime_install_rejects_concurrent_overwrite_transfer_targets(): def test_cartesian_grid_lowering_is_exact_and_refuses_unrepresentable_geometry(): from pops.domain import Rectangle from pops.frames import Cartesian2D + from pops.layouts import Uniform from pops.mesh import CartesianGrid, PeriodicAxes + from pops.mesh import normalize_layout_plan + from pops.model import OwnerPath from pops.runtime._runtime_mesh_lowering import _uniform_system_values - with pytest.raises(NotImplementedError, match="exact pops.mesh.CartesianGrid"): + def native(grid, name): + normalized, = normalize_layout_plan( + Uniform(grid), owner=OwnerPath.case(name)).layouts + assert normalized.native_spatial_layout is not None + return normalized.native_spatial_layout + + with pytest.raises(TypeError, match="exact NativeSpatialLayout"): _uniform_system_values(SimpleNamespace(n=16, L=2.0, periodic=False)) square = CartesianGrid( frame=Rectangle("square", (0.0, 0.0), (2.0, 2.0)).frame(Cartesian2D()), cells=(16, 16), ) - assert _uniform_system_values(square) == (16, 2.0, (False, False), 0.0, 0.0) + assert _uniform_system_values(native(square, "square")) == ( + 16, 2.0, (False, False), 0.0, 0.0) periodic = CartesianGrid( frame=square.frame, cells=(16, 16), periodic=PeriodicAxes(square.frame.axes), ) - assert _uniform_system_values(periodic) == (16, 2.0, (True, True), 0.0, 0.0) + assert _uniform_system_values(native(periodic, "periodic")) == ( + 16, 2.0, (True, True), 0.0, 0.0) partial = CartesianGrid( frame=square.frame, cells=(16, 16), periodic=PeriodicAxes((square.frame.x,)), ) - assert _uniform_system_values(partial) == (16, 2.0, (True, False), 0.0, 0.0) + assert _uniform_system_values(native(partial, "partial")) == ( + 16, 2.0, (True, False), 0.0, 0.0) rectangular_cells = CartesianGrid(frame=square.frame, cells=(16, 8)) with pytest.raises(NotImplementedError, match="rectangular CartesianGrid"): - _uniform_system_values(rectangular_cells) + _uniform_system_values(native(rectangular_cells, "rectangular")) shifted = CartesianGrid( frame=Rectangle("shifted", (1.0, 0.0), (3.0, 2.0)).frame(Cartesian2D()), cells=(16, 16), ) - assert _uniform_system_values(shifted) == ( + assert _uniform_system_values(native(shifted, "shifted")) == ( 16, 2.0, (False, False), 1.0, 0.0, ) diff --git a/tests/python/unit/runtime/test_runtime_instance_gate.py b/tests/python/unit/runtime/test_runtime_instance_gate.py index 20df81037..1f9ea3177 100644 --- a/tests/python/unit/runtime/test_runtime_instance_gate.py +++ b/tests/python/unit/runtime/test_runtime_instance_gate.py @@ -4,6 +4,7 @@ from dataclasses import replace import json +import os from pathlib import Path import threading from types import SimpleNamespace @@ -44,18 +45,26 @@ from pops.time import ( AcceptedStep, AdaptiveCFL, + Always, AtEnd, + AtStart, Clock, Every, ExternalTimeGrid, FixedDt, Schedule, + When, every_dt, ) from tests.python.support.native_execution_context import artifact_execution_context from tests.python.unit.runtime.test_runtime_planning import _artifact as _planning_artifact +def _path_owner(path: Path) -> tuple[int, int]: + status = path.lstat() + return int(status.st_dev), int(status.st_ino) + + def _install(names=("fluid",), *, heterogeneous=False, memory_spaces=("host",)): """Build the planning fixture against the exact loaded native ABI and resources.""" from pops import _pops @@ -259,13 +268,13 @@ def output_state_local_pieces(self, block, level): ) def output_state_root_pieces(self, communicator, block, level): - """Expose the exact singleton-world gather required by ROOT publication tests.""" - from pops._native_collectives import require_world, size + """Expose the exact duplicated consumer lane required by ROOT publication tests.""" + from pops._native_collectives import require_communicator, size expected = self._plan.execution_context.communicator - if communicator is not expected.handle: - raise ValueError("ROOT gather did not receive the installed communicator handle") - native = require_world(communicator) + native = require_communicator(communicator, allow_world=False) + if expected.identity != "MPI_COMM_WORLD": + raise ValueError("ROOT gather requires an MPI execution context") if size(native) != 1: raise RuntimeError( "runtime-instance unit executor only implements a singleton ROOT gather" @@ -385,6 +394,15 @@ def _with_graph( "state:u", layout.qualified_id, ) + resolved_mode = ( + parallel_mode + if kind is ConsumerKind.SCIENTIFIC_OUTPUT + else ( + ParallelMode(operation.consumer_data()["parallel_mode"]) + if kind is ConsumerKind.MONITOR + else ParallelMode.SERIAL + ) + ) manifest = ConsumerManifest( Handle("density", kind="consumer", owner=OwnerPath.consumer("adc-687")), kind, @@ -394,7 +412,7 @@ def _with_graph( NPZ(mode=parallel_mode) if output_format is None and kind is ConsumerKind.SCIENTIFIC_OUTPUT else output_format, - parallel_mode if kind is ConsumerKind.SCIENTIFIC_OUTPUT else ParallelMode.SERIAL, + resolved_mode, operation=operation, ) graph = ConsumerGraph((manifest,)) @@ -464,6 +482,14 @@ def test_runtime_instance_inspection_exposes_install_and_consumer_evidence(): assert payload["runtime"] == "uniform" assert payload["instance"]["bind_identity"] == plan.bind_identity.to_data() assert payload["instance"]["plan_identity"] == plan.artifact.plan.plan_identity.to_data() + assert payload["instance"]["resolved_dimension"] == 2 + assert payload["instance"]["supported_dimensions"] == [2] + assert payload["runtime_environment"]["dimension"] == 2 + assert payload["runtime_environment"]["supported_dimensions"] == [2] + assert payload["instance"]["native_spatial_layouts"] == { + layout_id: row.to_data() + for layout_id, row in plan.artifact.native_layouts.items() + } assert payload["instance"]["runtime_plan"] == runtime._runtime_plan.to_data() assert ( payload["instance"]["runtime_plan"]["communication"]["layout_plan_id"] @@ -503,6 +529,638 @@ def test_checkpoint_graph_provider_is_the_resolved_restart_authority(tmp_path): assert graph.to_data()["identity"] == runtime.consumer_graph.to_data()["identity"] +def test_checkpoint_reseal_failure_removes_its_owned_native_staging(monkeypatch, tmp_path): + from pops.runtime import _checkpoint_manifest + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-reseal-cleanup"} + ) + original_seal = _checkpoint_manifest.seal_checkpoint_payload + calls = 0 + + def fail_runtime_envelope(owner, payload, *, runtime_kind): + nonlocal calls + calls += 1 + if calls == 2: + raise RuntimeError("injected RuntimeInstance envelope reseal failure") + return original_seal(owner, payload, runtime_kind=runtime_kind) + + monkeypatch.setattr(_checkpoint_manifest, "seal_checkpoint_payload", fail_runtime_envelope) + + with pytest.raises(RuntimeError, match="injected RuntimeInstance envelope reseal failure"): + runtime.checkpoint(tmp_path / "restart") + + assert calls == 2 + assert not (tmp_path / "restart.npz").exists() + assert not tuple(tmp_path.glob(".pops-restart-transaction.*")) + + +def test_checkpoint_reseal_failure_never_deletes_a_replaced_staging_inode(monkeypatch, tmp_path): + from pops.runtime import _checkpoint_manifest + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-reseal-replacement"} + ) + original_seal = _checkpoint_manifest.seal_checkpoint_payload + calls = 0 + replacement = b"third-party checkpoint staging replacement" + evidence = {} + + def replace_staging_and_fail(owner, payload, *, runtime_kind): + nonlocal calls + calls += 1 + if calls == 2: + (transaction,) = tuple(tmp_path.glob(".pops-restart-transaction.*")) + staging = transaction / "native.npz" + owned_inode = _path_owner(staging) + third_party = tmp_path / "third-party-replacement.npz" + third_party.write_bytes(replacement) + replacement_inode = _path_owner(third_party) + assert replacement_inode != owned_inode + os.replace(third_party, staging) + evidence.update(path=staging, inode=replacement_inode) + raise RuntimeError("injected reseal failure after staging replacement") + return original_seal(owner, payload, runtime_kind=runtime_kind) + + monkeypatch.setattr( + _checkpoint_manifest, + "seal_checkpoint_payload", + replace_staging_and_fail, + ) + + with pytest.raises( + RuntimeError, + match="injected reseal failure after staging replacement", + ) as caught: + runtime.checkpoint(tmp_path / "restart") + + staging = evidence["path"] + assert staging.read_bytes() == replacement + assert _path_owner(staging) == evidence["inode"] + assert any( + "refuses to delete replaced transaction entry" in note + for note in getattr(caught.value, "__notes__", ()) + ) + assert not (tmp_path / "restart.npz").exists() + + +def test_checkpoint_refuses_path_only_capture_without_a_private_transaction_receipt(tmp_path): + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + + with pytest.raises(RuntimeError, match="path-only native ABI cannot prove creator ownership"): + runtime._checkpoint_payload(tmp_path / "unreceipted") + + assert not (tmp_path / "unreceipted.npz").exists() + + +def test_checkpoint_replacement_before_entry_acquisition_is_never_cleaned(monkeypatch, tmp_path): + from pops.runtime import _checkpoint_manifest + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-pre-acquisition-replacement"} + ) + original_authenticate = _checkpoint_manifest.authenticate_checkpoint_payload + replacement = b"third-party replacement before entry ownership" + evidence = {} + + def replace_after_native_authentication(owner, payload, *, runtime_kind): + identity = original_authenticate(owner, payload, runtime_kind=runtime_kind) + (transaction,) = tuple(tmp_path.glob(".pops-restart-transaction.*")) + staging = transaction / "native.npz" + third_party = tmp_path / "third-party-before-acquisition.npz" + third_party.write_bytes(replacement) + os.replace(third_party, staging) + evidence.update(path=staging, inode=_path_owner(staging)) + return identity + + monkeypatch.setattr( + _checkpoint_manifest, + "authenticate_checkpoint_payload", + replace_after_native_authentication, + ) + + with pytest.raises( + RuntimeError, + match="replaced before ownership acquisition", + ) as caught: + runtime.checkpoint(tmp_path / "restart") + + staging = evidence["path"] + assert staging.read_bytes() == replacement + assert _path_owner(staging) == evidence["inode"] + assert any( + "transaction directory is not empty" in note + for note in getattr(caught.value, "__notes__", ()) + ) + assert not (tmp_path / "restart.npz").exists() + + +def test_checkpoint_never_reacquires_created_at_ownership_from_a_valid_replacement( + monkeypatch, tmp_path +): + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-valid-native-replacement"} + ) + original_checkpoint = runtime._executor.checkpoint + evidence = {} + + def replace_valid_native_checkpoint(path): + target = Path(original_checkpoint(path)) + payload = target.read_bytes() + replacement = tmp_path / "valid-native-replacement.npz" + replacement.write_bytes(payload) + os.replace(replacement, target) + evidence.update(path=target, payload=payload, owner=_path_owner(target)) + return str(target) + + monkeypatch.setattr(runtime._executor, "checkpoint", replace_valid_native_checkpoint) + + with pytest.raises(RuntimeError, match="replaced its created-at staging inode"): + runtime.checkpoint(tmp_path / "restart") + + assert evidence["path"].read_bytes() == evidence["payload"] + assert _path_owner(evidence["path"]) == evidence["owner"] + assert not (tmp_path / "restart.npz").exists() + + +def test_checkpoint_eexist_same_inode_never_grants_expected_entry_ownership(monkeypatch, tmp_path): + from pops.output._writers import common + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-eexist-same-inode"} + ) + original_rename = common._rename_no_replace + evidence = {} + + def create_same_inode_entry_before_rename(source, destination, *args, **kwargs): + if destination == "native.npz" and source.endswith(".runtime-instance.tmp"): + os.link( + source, + destination, + src_dir_fd=kwargs["src_dir_fd"], + dst_dir_fd=kwargs["dst_dir_fd"], + follow_symlinks=False, + ) + linked = os.stat( + destination, + dir_fd=kwargs["dst_dir_fd"], + follow_symlinks=False, + ) + evidence["inode"] = (int(linked.st_dev), int(linked.st_ino)) + return original_rename(source, destination, *args, **kwargs) + + monkeypatch.setattr(common, "_rename_no_replace", create_same_inode_entry_before_rename) + + with pytest.raises(OSError, match="appeared during envelope publication") as caught: + runtime.checkpoint(tmp_path / "restart") + + (transaction,) = tuple(tmp_path.glob(".pops-restart-transaction.*")) + expected = transaction / "native.npz" + assert _path_owner(expected) == evidence["inode"] + assert not tuple(transaction.glob("*.runtime-instance.tmp")) + assert any( + "transaction directory is not empty" in note + for note in getattr(caught.value, "__notes__", ()) + ) + assert not (tmp_path / "restart.npz").exists() + + +def test_checkpoint_temporary_substitution_preserves_primary_error_and_replacement( + monkeypatch, tmp_path +): + from pops.output import _restart_provider + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-temporary-substitution"} + ) + original_authenticate = _restart_provider._CheckpointTransactionReceipt.authenticate_entry_at + replacement = b"third-party runtime envelope temporary" + evidence = {} + + def replace_temporary_before_authentication(transaction, authority): + if authority.name.endswith(".runtime-instance.tmp") and not evidence: + third_party = tmp_path / "third-party-temporary.npz" + third_party.write_bytes(replacement) + temporary = transaction.directory / authority.name + os.replace(third_party, temporary) + evidence.update( + path=temporary, + inode=_path_owner(temporary), + ) + return original_authenticate(transaction, authority) + + monkeypatch.setattr( + _restart_provider._CheckpointTransactionReceipt, + "authenticate_entry_at", + replace_temporary_before_authentication, + ) + + with pytest.raises( + RuntimeError, + match="transaction entry was replaced before ownership acquisition", + ) as caught: + runtime.checkpoint(tmp_path / "restart") + + temporary = evidence["path"] + assert temporary.read_bytes() == replacement + assert _path_owner(temporary) == evidence["inode"] + notes = getattr(caught.value, "__notes__", ()) + assert any("temporary cleanup" in note for note in notes) + assert any("transaction directory is not empty" in note for note in notes) + assert not (tmp_path / "restart.npz").exists() + + +def test_checkpoint_transaction_directory_substitution_never_uses_the_replacement( + monkeypatch, tmp_path +): + from pops.output import _restart_provider + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-transaction-directory-substitution"} + ) + receipt_type = _restart_provider._CheckpointTransactionReceipt + original_open = receipt_type.open_candidate_at + replacement = b"third-party transaction directory" + evidence = {} + + def substitute_directory(receipt, name): + if not evidence: + detached = tmp_path / "detached-owned-transaction" + os.replace(receipt.directory, detached) + receipt.directory.mkdir(mode=0o700) + marker = receipt.directory / "third-party-marker" + marker.write_bytes(replacement) + evidence.update(detached=detached, marker=marker) + return original_open(receipt, name) + + monkeypatch.setattr(receipt_type, "open_candidate_at", substitute_directory) + + with pytest.raises(RuntimeError, match="transaction directory authority changed"): + runtime.checkpoint(tmp_path / "restart") + + assert evidence["marker"].read_bytes() == replacement + assert evidence["detached"].is_dir() + assert not (tmp_path / "restart.npz").exists() + + +def test_checkpoint_post_reseal_handoff_substitution_preserves_replacement(monkeypatch, tmp_path): + from pops.output import _restart_provider + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-post-reseal-handoff-substitution"} + ) + proof_type = _restart_provider._CheckpointPayloadProof + original_to_data = proof_type.to_data + replacement = b"third-party replacement after reseal" + evidence = {} + calls = 0 + + def substitute_before_second_handoff(proof): + nonlocal calls + calls += 1 + if calls == 2: + third_party = tmp_path / "third-party-after-reseal.npz" + third_party.write_bytes(replacement) + os.replace(third_party, proof.path) + evidence.update(path=proof.path, owner=_path_owner(proof.path)) + return original_to_data(proof) + + monkeypatch.setattr(proof_type, "to_data", substitute_before_second_handoff) + + with pytest.raises( + RuntimeError, + match="transaction entry was replaced before ownership acquisition", + ) as caught: + runtime.checkpoint(tmp_path / "restart") + + assert calls == 2 + assert evidence["path"].read_bytes() == replacement + assert _path_owner(evidence["path"]) == evidence["owner"] + assert any( + "rank-zero checkpoint cleanup also failed" in note + for note in getattr(caught.value, "__notes__", ()) + ) + assert not (tmp_path / "restart.npz").exists() + + +def test_checkpoint_resealed_descriptor_survives_handoff_until_rollback(tmp_path): + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-resealed-descriptor-lifecycle"} + ) + operation = runtime._restart_operation() + snapshot = operation.snapshot(runtime, tmp_path) + proof = snapshot._proof + assert proof is not None + retained_fd = proof.entry.fileno() + retained_owner = _path_owner(snapshot.path) + assert (int(os.fstat(retained_fd).st_dev), int(os.fstat(retained_fd).st_ino)) == retained_owner + + target = operation.write(snapshot, tmp_path / "restart") + + assert target.is_file() + assert snapshot._published_entry is not None + assert snapshot._published_entry.fileno() == retained_fd + assert (int(os.fstat(retained_fd).st_dev), int(os.fstat(retained_fd).st_ino)) == retained_owner + snapshot.rollback() + snapshot.rollback() + snapshot.finalize() + snapshot.finalize() + assert not target.exists() + with pytest.raises(OSError): + os.fstat(retained_fd) + + +def test_checkpoint_discard_aggregates_independent_cleanup_failures(monkeypatch, tmp_path): + from pops.output import _restart_provider + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-aggregate-discard-cleanup"} + ) + snapshot = runtime._restart_operation().snapshot(runtime, tmp_path) + receipt_type = _restart_provider._CheckpointTransactionReceipt + calls = [] + + def fail_quarantine(_receipt, entry, *, phase, close_entry=True): + calls.append(("quarantine", phase)) + if close_entry: + entry.close() + raise RuntimeError("injected staging quarantine failure") + + def fail_directory_cleanup(receipt): + calls.append(("directory", receipt.directory_name)) + receipt.close() + raise RuntimeError("injected transaction directory cleanup failure") + + monkeypatch.setattr(receipt_type, "quarantine_entry_at", fail_quarantine) + monkeypatch.setattr(receipt_type, "cleanup_empty", fail_directory_cleanup) + + with pytest.raises(RuntimeError, match="checkpoint snapshot cleanup failed") as caught: + snapshot.discard() + + message = str(caught.value) + assert "injected staging quarantine failure" in message + assert "injected transaction directory cleanup failure" in message + assert [row[0] for row in calls] == ["quarantine", "directory"] + snapshot.finalize() + snapshot.finalize() + + +def test_checkpoint_openat_refuses_symlink_entries_without_following(tmp_path): + import errno + import stat as stat_module + + from pops.output import _restart_provider + + receipt = _restart_provider._CheckpointTransactionReceipt.created(tmp_path) + native = receipt.take_native_entry() + os.symlink("missing-target", "candidate", dir_fd=receipt.directory_fileno()) + try: + with pytest.raises(FileExistsError): + receipt.created_at("candidate") + with pytest.raises(OSError) as caught: + receipt.open_candidate_at("candidate") + assert caught.value.errno in {errno.ELOOP, errno.EMLINK} + status = os.stat( + "candidate", + dir_fd=receipt.directory_fileno(), + follow_symlinks=False, + ) + assert stat_module.S_ISLNK(status.st_mode) + finally: + os.unlink("candidate", dir_fd=receipt.directory_fileno()) + receipt.quarantine_entry_at(native, phase="symlink refusal test cleanup") + receipt.cleanup_empty() + + +def test_checkpoint_peer_proofs_are_opaque_scalars_without_rank_local_stat(monkeypatch, tmp_path): + from pops.output import _restart_provider + + receipt_type = _restart_provider._CheckpointTransactionReceipt + proof_type = _restart_provider._CheckpointPayloadProof + receipt = receipt_type.created(tmp_path) + receipt_data = receipt.to_data() + native = receipt.take_native_entry() + proof = proof_type(receipt, native) + proof_data = proof.to_data() + + def forbid_rank_local_stat(*_args, **_kwargs): + raise AssertionError("a peer compared root inode evidence with its local mount") + + with monkeypatch.context() as isolated: + isolated.setattr(os, "stat", forbid_rank_local_stat) + peer_receipt = receipt_type.observed(receipt_data) + peer_proof = proof_type.observed(peer_receipt, proof_data) + + assert not peer_receipt.has_root_descriptor + assert not peer_proof.entry.is_open + assert peer_receipt.owner == receipt.owner + assert peer_proof.owner == proof.owner + receipt.quarantine_entry_at(native, phase="opaque peer proof test cleanup") + receipt.cleanup_empty() + + +def test_checkpoint_root_attempt_broadcasts_exact_opaque_proof(monkeypatch): + from pops.output import _checkpoint_collective + + communicator = object() + topology = _checkpoint_collective.CheckpointTopology(0, 2, communicator) + proof = { + "path": "/opaque/root/path/native.npz", + "entry_name": "native.npz", + "entry_owner": [17, 23], + "directory_name": ".pops-restart-transaction.test", + "directory_owner": [5, 11], + } + envelopes = [] + + def broadcast(actual_communicator, envelope, *, root): + assert actual_communicator is communicator + assert root == 0 + envelopes.append(envelope) + return envelope + + monkeypatch.setattr(_checkpoint_collective, "broadcast_value", broadcast) + + attempt = _checkpoint_collective.root_attempt(topology, "proof handoff", lambda: proof) + + assert attempt.value == proof + assert attempt.producer_error is None + assert attempt.transport_error is None + assert envelopes == [{"value": proof, "error": None}] + + +def test_checkpoint_root_attempt_keeps_producer_and_transport_failures_distinct(monkeypatch): + from pops.output import _checkpoint_collective + + communicator = object() + topology = _checkpoint_collective.CheckpointTopology(0, 2, communicator) + producer_error = ValueError("injected producer failure") + broadcasts = 0 + + def fail_broadcast(_communicator, _envelope, *, root): + nonlocal broadcasts + assert root == 0 + broadcasts += 1 + raise OSError("injected transport failure") + + def fail_producer(): + raise producer_error + + monkeypatch.setattr(_checkpoint_collective, "broadcast_value", fail_broadcast) + + attempt = _checkpoint_collective.root_attempt(topology, "broken proof", fail_producer) + + assert broadcasts == 1 + assert attempt.producer_error is producer_error + assert isinstance(attempt.transport_error, OSError) + assert "injected transport failure" in str(attempt.transport_error) + + +def test_checkpoint_discard_transport_failure_performs_no_second_collective(monkeypatch, tmp_path): + from pops.output import _checkpoint_collective, _restart_provider + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-discard-transport-failure"} + ) + snapshot = runtime._restart_operation().snapshot(runtime, tmp_path) + attempts = 0 + + def break_after_root_cleanup(_topology, _phase, producer): + nonlocal attempts + attempts += 1 + producer() + return _checkpoint_collective.RootAttempt( + transport_error=OSError("injected post-cleanup transport failure") + ) + + monkeypatch.setattr(_checkpoint_collective, "root_attempt", break_after_root_cleanup) + + with pytest.raises( + _restart_provider._CheckpointTransportFailure, + match="transport failed during discard", + ): + snapshot.discard() + + assert attempts == 1 + assert not tuple(tmp_path.glob(".pops-restart-transaction.*")) + + +def test_checkpoint_reseal_fails_closed_when_atomic_quarantine_is_unavailable( + monkeypatch, tmp_path +): + from pops.output._writers import common + + plan, _, _ = _with_graph( + tmp_path, + kind=ConsumerKind.CHECKPOINT, + output_format=None, + operation=RestartV3(), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + runtime._executor._last_run_identity = make_identity( + "run", {"test": "checkpoint-atomic-quarantine-unavailable"} + ) + + def unavailable(*_args, **_kwargs): + raise RuntimeError("injected atomic rename primitive unavailable") + + monkeypatch.setattr(common, "_rename_no_replace", unavailable) + + with pytest.raises(RuntimeError, match="atomic rename primitive unavailable") as caught: + runtime.checkpoint(tmp_path / "restart") + + (transaction,) = tuple(tmp_path.glob(".pops-restart-transaction.*")) + assert (transaction / "native.npz").is_file() + assert tuple(transaction.glob("*.runtime-instance.tmp")) + notes = getattr(caught.value, "__notes__", ()) + assert any("failed runtime checkpoint staging cleanup" in note for note in notes) + assert any("transaction directory is not empty" in note for note in notes) + assert not (tmp_path / "restart.npz").exists() + + def test_runtime_instance_has_one_authored_execution_route(): plan = _install() runtime = RuntimeInstance(plan, executor=_Executor(plan)) @@ -720,7 +1378,8 @@ def prepare_session(self, snapshot, request, target, *, communicator=None): class _BlockingFormat: __pops_ir_immutable__ = True - def __init__(self): + def __init__(self, mode: ParallelMode): + self._mode = mode self.writer_started = threading.Event() self.release_writer = threading.Event() self.paths = [] @@ -731,7 +1390,7 @@ def consumer_data(self): "provider_id": "pops.test.blocking-async.v1", "format_name": "blocking-test", "extension": ".async", - "parallel_mode": "serial", + "parallel_mode": self._mode.value, } def writer(self): @@ -741,7 +1400,7 @@ def writer(self): def test_async_scientific_output_overlaps_next_step_and_flushes_real_receipts(tmp_path): output_root = tmp_path / "async-output" output_root.mkdir() - format_provider = _BlockingFormat() + format_provider = _BlockingFormat(_scientific_output_mode(_install().artifact)) authoring_clock = Clock("async-authoring") descriptor = AsyncScientificOutput( format=format_provider, @@ -1182,26 +1841,157 @@ def test_run_fails_explicitly_when_max_steps_cannot_reach_t_end(tmp_path): assert tuple(tmp_path.glob("*.npz")) == () -def test_scientific_format_is_a_structural_provider_without_name_dispatch(tmp_path): - plan, _, _ = _with_graph(tmp_path, output_format=_CustomNPZ) - runtime = RuntimeInstance(plan, executor=_Executor(plan)) +def test_failed_run_keeps_identity_sealed_when_entry_rollback_fails(): + class _RollbackFailureExecutor(_Executor): + def _restore_temporal_restart_state(self, _state): + raise RuntimeError("injected run-entry rollback failure") - runtime._run(t_end=1.0, max_steps=1) + plan = _install() + runtime = RuntimeInstance(plan, executor=_RollbackFailureExecutor(plan)) + calls = [] + close_failed = runtime._publisher.close_failed_run_consumers + + def capture_close(run_identity, *, release_identity, entry_effect_fence=None): + calls.append((run_identity, release_identity)) + return close_failed( + run_identity, + release_identity=release_identity, + entry_effect_fence=entry_effect_fence, + ) - assert len(tuple(tmp_path.glob("*.npz"))) == 1 + runtime._publisher.close_failed_run_consumers = capture_close + with pytest.raises(RuntimeError, match="max_steps exhausted") as caught: + runtime._run(t_end=1.0, max_steps=0, console=False) + assert "injected run-entry rollback failure" in "\n".join(caught.value.__notes__) + assert len(calls) == 1 + run_identity, release_identity = calls[0] + assert release_identity is False + assert run_identity.token in runtime._publisher._closed_observer_runs -def test_malformed_format_provider_is_refused_before_an_effect_exists(tmp_path): - class _Malformed: - __pops_ir_immutable__ = True - def consumer_data(self): - return {"schema_version": 1} +def test_runtime_world_collective_loss_skips_post_commit_cleanup_and_stays_sealed( + monkeypatch, request +): + from pops.runtime import _runtime_consumers + from pops.runtime._observer_runtime import PostCommitObserverWorker - def writer(self): - return object() + plan = _install() + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + publisher = runtime._publisher + original_begin = publisher.begin_post_commit_consumers + cleanup_calls = [] + workers = [] + + def cleanup_workers(): + for worker in workers: + if worker.close_succeeded is not True: + worker.seal_local(RuntimeError("test cleanup")) + + request.addfinalizer(cleanup_workers) + + def lose_world(run_identity): + worker = PostCommitObserverWorker( + thread_name="test-runtime-world-loss-local-seal", + run_identity=run_identity, + ) + workers.append(worker) + publisher._observer_workers[run_identity.token] = worker + raise _runtime_consumers._ObserverCollectiveLost( + "injected runtime MPI_COMM_WORLD proof loss" + ) - with pytest.raises((TypeError, ValueError), match="provider|writer|keys"): + def forbidden_cleanup(*_args, **_kwargs): + cleanup_calls.append(True) + raise AssertionError("WORLD loss must skip post-commit cleanup") + + publisher.begin_post_commit_consumers = lose_world + publisher.close_failed_run_consumers = forbidden_cleanup + publisher.close_live_visualizations = forbidden_cleanup + + with pytest.raises( + _runtime_consumers._ObserverCollectiveLost, + match="injected runtime MPI_COMM_WORLD proof loss", + ) as caught: + runtime._run(t_end=0.0, max_steps=0, console=False) + + assert cleanup_calls == [] + assert len(workers) == 1 + assert workers[0].close_succeeded is True + assert publisher._observer_workers[runtime.last_run_identity.token] is workers[0] + assert "cleanup was skipped" in "\n".join(caught.value.__notes__) + assert "injected runtime MPI_COMM_WORLD proof loss" in ( + publisher._observer_world_collective_lost + ) + assert publisher.seal_observer_collective_loss(RuntimeError("later local refusal")) is True + + publisher.begin_post_commit_consumers = original_begin + monkeypatch.setattr( + RuntimeInstance, + "_step_transaction_methods", + lambda self: pytest.fail( + "a sealed observer WORLD must refuse before native run preparation" + ), + ) + with pytest.raises(RuntimeError, match="MPI_COMM_WORLD is sealed"): + runtime._run(t_end=0.0, max_steps=0, console=False) + assert cleanup_calls == [] + + +@pytest.mark.parametrize( + "schedule", + ( + lambda clock: Schedule(Always(AcceptedStep(clock))), + lambda clock: Schedule(Every(AcceptedStep(clock), 1)), + lambda clock: Schedule(AtEnd(AcceptedStep(clock))), + lambda clock: Schedule(When(AcceptedStep(clock), True)), + ), +) +def test_zero_step_run_does_not_fabricate_an_accepted_consumer_occurrence(tmp_path, schedule): + plan, _, manifest = _with_graph(tmp_path, schedule=schedule) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + + report = runtime._run(t_end=0.0, max_steps=0) + + assert report.accepted_steps == 0 + assert runtime.consumer_cursors.for_consumer(manifest.qualified_id).committed_samples == 0 + assert tuple(tmp_path.glob("*.npz")) == () + + +def test_zero_step_run_keeps_exactly_one_start_occurrence(tmp_path): + plan, _, manifest = _with_graph( + tmp_path, + schedule=lambda clock: Schedule(AtStart(AcceptedStep(clock))), + ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + + report = runtime._run(t_end=0.0, max_steps=0) + + assert report.accepted_steps == 0 + assert runtime.consumer_cursors.for_consumer(manifest.qualified_id).committed_samples == 1 + assert _published_times(tmp_path) == [0.0] + + +def test_scientific_format_is_a_structural_provider_without_name_dispatch(tmp_path): + plan, _, _ = _with_graph(tmp_path, output_format=_CustomNPZ) + runtime = RuntimeInstance(plan, executor=_Executor(plan)) + + runtime._run(t_end=1.0, max_steps=1) + + assert len(tuple(tmp_path.glob("*.npz"))) == 1 + + +def test_malformed_format_provider_is_refused_before_an_effect_exists(tmp_path): + class _Malformed: + __pops_ir_immutable__ = True + + def consumer_data(self): + return {"schema_version": 1} + + def writer(self): + return object() + + with pytest.raises((TypeError, ValueError), match="provider|writer|keys"): _with_graph(tmp_path, output_format=_Malformed()) @@ -1577,12 +2367,8 @@ def test_regrid_restart_derives_distinct_run_identity_from_global_receipt(monkey "history_consensus_identity_after": make_identity( "restart-history-image", {"phase": "after"} ).token, - "composite_integrals_before": [ - {"block": "tracer", "component": 0, "value": 1.25} - ], - "composite_integrals_after": [ - {"block": "tracer", "component": 0, "value": 1.25} - ], + "composite_integrals_before": [{"block": "tracer", "component": 0, "value": 1.25}], + "composite_integrals_after": [{"block": "tracer", "component": 0, "value": 1.25}], } published = [] @@ -1668,12 +2454,10 @@ def restore_checkpoint_payload( **receipt, "accepted_time": receipt["accepted_time"].hex(), "composite_integrals_before": [ - {**row, "value": row["value"].hex()} - for row in receipt["composite_integrals_before"] + {**row, "value": row["value"].hex()} for row in receipt["composite_integrals_before"] ], "composite_integrals_after": [ - {**row, "value": row["value"].hex()} - for row in receipt["composite_integrals_after"] + {**row, "value": row["value"].hex()} for row in receipt["composite_integrals_after"] ], } expected = make_identity( @@ -1743,6 +2527,1638 @@ def test_checkpoint_diagnostic_baseline_schema_is_finite_and_canonical(): ) +def test_root_output_lane_requires_one_active_run_scoped_communicator(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + publisher = object.__new__(RuntimeConsumerPublisher) + lane = SimpleNamespace(active=True, closed=False) + publisher._root_output_consumers = ("scientific_output/root",) + publisher._root_output_lanes = {"run": lane} + assert publisher._root_output_communicator() is lane + + publisher._root_output_lanes = {} + with pytest.raises(RuntimeError, match="exactly one active"): + publisher._root_output_communicator() + + publisher._root_output_lanes = {"run": SimpleNamespace(active=False, closed=False)} + with pytest.raises(RuntimeError, match="not active"): + publisher._root_output_communicator() + + publisher._root_output_consumers = () + with pytest.raises(RuntimeError, match="declares no ROOT"): + publisher._root_output_communicator() + + +def test_root_output_lane_is_materialized_and_closed_once_per_run(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + class _Lane: + active = True + closed = False + + def __init__(self): + self.close_calls = 0 + self.identity = "" + + def close_collectively(self): + self.close_calls += 1 + self.active = False + self.closed = True + + class _World: + identity = "MPI_COMM_WORLD" + + def __init__(self, lane): + self.lane = lane + self.identities = [] + + def duplicate_observer_lane(self, identity): + self.identities.append(identity) + self.lane.identity = "%s/%s" % (self.identity, identity) + return self.lane + + run_identity = make_identity("run", {"case": "root-output-lane"}) + lane = _Lane() + world = _World(lane) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._root_output_consumers = ("scientific_output/root",) + publisher._root_output_lanes = {} + publisher._communicator = world + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {} + publisher._builtin_catalyst_consumers = () + publisher._builtin_catalyst_run_started = False + publisher._owner = SimpleNamespace( + _consumer_graph=SimpleNamespace(nodes=()), + ) + publisher._observer_diagnostics = [] + publisher._observer_workers = {} + publisher._observer_reports = {} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_pending_failures = {} + + publisher.begin_post_commit_consumers(run_identity) + assert world.identities == ["scientific-output/root/%s" % run_identity.token] + assert publisher._root_output_communicator() is lane + + assert publisher.close_live_visualizations(run_identity) == () + assert lane.close_calls == 1 + assert publisher.close_live_visualizations(run_identity) == () + assert lane.close_calls == 1 + with pytest.raises(RuntimeError, match="already closed"): + publisher.begin_post_commit_consumers(run_identity) + + +def test_root_lane_close_retains_cleanup_authority_for_retry(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + class _Lane: + active = True + closed = False + fail = True + identity = "" + + def close_collectively(self): + if self.fail: + raise RuntimeError("injected collective close failure") + self.active = False + self.closed = True + + class _World: + identity = "MPI_COMM_WORLD" + + def duplicate_observer_lane(self, identity): + lane.identity = "%s/%s" % (self.identity, identity) + return lane + + run_identity = make_identity("run", {"case": "retained-root-lane-cleanup"}) + lane = _Lane() + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._root_output_consumers = ("scientific_output/root",) + publisher._root_output_lanes = {} + publisher._communicator = _World() + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {} + publisher._builtin_catalyst_consumers = () + publisher._builtin_catalyst_run_started = False + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=())) + publisher._observer_diagnostics = [] + publisher._observer_workers = {} + publisher._observer_reports = {} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_journals = {} + publisher._observer_pending_failures = {} + + publisher.begin_post_commit_consumers(run_identity) + with pytest.raises(RuntimeError, match="injected collective close failure"): + publisher.close_live_visualizations(run_identity) + assert publisher._root_output_lanes[run_identity.token] is lane + assert run_identity.token in publisher._closed_observer_runs + + lane.fail = False + publisher.close_live_visualizations(run_identity) + assert run_identity.token not in publisher._root_output_lanes + assert lane.closed is True + assert run_identity.token in publisher._closed_observer_runs + + +@pytest.mark.parametrize( + ("peer_error", "peer_present", "sentinel_retained"), + ( + ("injected peer ROOT lane construction failure", False, False), + (None, True, True), + ), + ids=("all-ranks-fail", "mixed-rank-success"), +) +def test_root_lane_construction_failure_reaches_world_consensus_before_exit( + monkeypatch, + peer_error, + peer_present, + sentinel_retained, +): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + class _World: + identity = "MPI_COMM_WORLD" + + def __init__(self): + self.duplicate_calls = 0 + + def duplicate_observer_lane(self, _identity): + self.duplicate_calls += 1 + raise RuntimeError("injected local ROOT lane construction failure") + + run_identity = make_identity("run", {"case": "root-lane-construction-consensus"}) + world = _World() + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = world + publisher._root_output_consumers = ("scientific_output/root",) + publisher._root_output_lanes = {} + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {} + publisher._builtin_catalyst_consumers = () + publisher._builtin_catalyst_run_started = False + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=())) + + consensus_envelopes = [] + + def gathered(_communicator, envelope): + consensus_envelopes.append(dict(envelope)) + peer = dict(envelope) + peer["rank"] = 1 + peer["error"] = peer_error + peer["present"] = peer_present + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", gathered) + + with pytest.raises( + _runtime_consumers._ObserverCollectiveRejected, + match="ROOT scientific-output lane construction failed", + ): + publisher.begin_post_commit_consumers(run_identity) + + assert world.duplicate_calls == 1 + assert len(consensus_envelopes) == 1 + assert "injected local ROOT lane construction failure" in consensus_envelopes[0]["error"] + assert (run_identity.token in publisher._root_output_lanes) is sentinel_retained + if sentinel_retained: + assert publisher._root_output_lanes[run_identity.token] is None + assert publisher._observer_run_phases[run_identity.token] == "opening" + + +def test_only_consumer_free_serial_failed_run_releases_its_identity_for_retry(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + class _Lane: + active = True + closed = False + identity = "" + + def close_collectively(self): + self.active = False + self.closed = True + + class _World: + identity = "MPI_COMM_WORLD" + + def __init__(self): + self.lanes = [] + + def duplicate_observer_lane(self, identity): + lane = _Lane() + lane.identity = "%s/%s" % (self.identity, identity) + self.lanes.append(lane) + return lane + + run_identity = make_identity("run", {"case": "retryable-consumer-free-serial"}) + world = _World() + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._communicator = None + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {} + publisher._builtin_catalyst_consumers = () + publisher._builtin_catalyst_run_started = False + publisher._owner = SimpleNamespace( + _consumer_graph=SimpleNamespace(nodes=()), + ) + publisher._observer_diagnostics = [] + publisher._observer_workers = {} + publisher._observer_reports = {} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_journals = {} + publisher._observer_preflight_sessions = {} + publisher._observer_pending_failures = {} + + entry_fence = publisher.failed_run_effect_fence() + publisher.begin_post_commit_consumers(run_identity) + publisher.close_failed_run_consumers( + run_identity, + release_identity=True, + entry_effect_fence=entry_fence, + ) + assert run_identity.token not in publisher._closed_observer_runs + + publisher.begin_post_commit_consumers(run_identity) + publisher.close_live_visualizations(run_identity) + assert run_identity.token in publisher._closed_observer_runs + publisher.close_failed_run_consumers(run_identity, release_identity=True) + assert run_identity.token in publisher._closed_observer_runs + + published_identity = make_identity("run", {"case": "published-at-start"}) + publisher.begin_post_commit_consumers(published_identity) + publisher.close_failed_run_consumers( + published_identity, + release_identity=False, + ) + assert published_identity.token in publisher._closed_observer_runs + + output_identity = make_identity("run", {"case": "root-output-opened"}) + publisher._root_output_consumers = ("scientific_output/root",) + publisher._communicator = world + output_fence = publisher.failed_run_effect_fence() + publisher.begin_post_commit_consumers(output_identity) + publisher.close_failed_run_consumers( + output_identity, + release_identity=True, + entry_effect_fence=output_fence, + ) + assert output_identity.token in publisher._closed_observer_runs + assert world.lanes[0].closed is True + publisher._root_output_consumers = () + publisher._communicator = None + + diagnostic_identity = make_identity("run", {"case": "diagnostic-before-failure"}) + diagnostic_fence = publisher.failed_run_effect_fence() + publisher.begin_post_commit_consumers(diagnostic_identity) + publisher._observer_diagnostics.append("provider initialization escaped rollback") + publisher.close_failed_run_consumers( + diagnostic_identity, + release_identity=True, + entry_effect_fence=diagnostic_fence, + ) + assert diagnostic_identity.token in publisher._closed_observer_runs + + catalyst_identity = make_identity("run", {"case": "catalyst-begin-failure"}) + publisher._builtin_catalyst_consumers = ("monitor/catalyst",) + catalyst_fence = publisher.failed_run_effect_fence() + publisher.begin_post_commit_consumers(catalyst_identity) + publisher.close_failed_run_consumers( + catalyst_identity, + release_identity=True, + entry_effect_fence=catalyst_fence, + ) + assert catalyst_identity.token in publisher._closed_observer_runs + + +def test_mpi_size_one_consumer_free_failed_run_releases_its_identity(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "mpi-size-one-reusable"}) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {} + publisher._builtin_catalyst_consumers = () + publisher._builtin_catalyst_run_started = False + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=())) + publisher._observer_diagnostics = [] + publisher._observer_workers = {} + publisher._observer_reports = {} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_journals = {} + publisher._observer_preflight_sessions = {} + publisher._observer_pending_failures = {} + + entry_fence = publisher.failed_run_effect_fence() + publisher.begin_post_commit_consumers(run_identity) + publisher.close_failed_run_consumers( + run_identity, + release_identity=True, + entry_effect_fence=entry_fence, + ) + assert run_identity.token not in publisher._closed_observer_runs + assert run_identity.token not in publisher._observer_run_phases + publisher.begin_post_commit_consumers(run_identity) + + +def test_mpi_multi_rank_consumer_free_failed_run_keeps_its_identity(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "mpi-multi-rank-sealed"}) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {} + publisher._builtin_catalyst_consumers = () + publisher._builtin_catalyst_run_started = False + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=())) + publisher._observer_diagnostics = [] + publisher._observer_workers = {} + publisher._observer_reports = {} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_journals = {} + publisher._observer_preflight_sessions = {} + publisher._observer_pending_failures = {} + + def consensus_rows(_communicator, envelope): + peer = dict(envelope) + peer["rank"] = 1 + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", consensus_rows) + entry_fence = publisher.failed_run_effect_fence() + publisher.begin_post_commit_consumers(run_identity) + publisher.close_failed_run_consumers( + run_identity, + release_identity=True, + entry_effect_fence=entry_fence, + ) + assert run_identity.token in publisher._closed_observer_runs + assert publisher._observer_run_phases[run_identity.token] == "closed" + + +def test_runtime_instance_exposes_only_exact_native_program_accepted_state(): + runtime = object.__new__(RuntimeInstance) + runtime._executor = SimpleNamespace(program_accepted_state=lambda: b"accepted-amr-state") + assert runtime.program_accepted_state() == b"accepted-amr-state" + + runtime._executor = SimpleNamespace() + with pytest.raises(NotImplementedError, match="accepted AMR Program state"): + runtime.program_accepted_state() + + runtime._executor = SimpleNamespace(program_accepted_state=lambda: bytearray(b"mutable")) + with pytest.raises(TypeError, match="must be exact bytes"): + runtime.program_accepted_state() + + +def test_failed_run_close_refuses_divergent_mpi_lane_inventory(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "rank-divergent-release"}) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = object() + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {run_identity.token: "opening"} + publisher._builtin_catalyst_consumers = () + publisher._builtin_catalyst_run_started = False + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/collective", + parallel_mode=ParallelMode.COLLECTIVE, + identity=make_identity("consumer-manifest", {"case": "collective-close"}), + operation_data={"observer": {"provider": {"provider_id": "test.collective-observer"}}}, + ) + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=(manifest,))) + publisher._observer_diagnostics = [] + publisher._observer_workers = {} + publisher._observer_reports = {} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_journals = {} + publisher._observer_pending_failures = {} + + entry_fence = publisher.failed_run_effect_fence() + + def divergent_rows(_communicator, envelope): + peer = dict(envelope) + peer["rank"] = 1 + peer["monitors"] = [dict(row) for row in envelope["monitors"]] + peer["monitors"][0]["lane"] = { + "identity": "MPI_COMM_WORLD/post-commit/%s/%s" + % (manifest.identity.token, run_identity.token), + "active": True, + "closed": False, + } + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", divergent_rows) + with pytest.raises(RuntimeError, match="divergent MPI monitor inventory"): + publisher.close_failed_run_consumers( + run_identity, + release_identity=True, + entry_effect_fence=entry_fence, + ) + assert run_identity.token in publisher._closed_observer_runs + + +def test_opened_root_output_refuses_close_after_lane_authority_disappears(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "missing-open-root-lane"}) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._communicator = object() + publisher._root_output_consumers = ("scientific_output/root",) + publisher._root_output_lanes = {} + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {run_identity.token: "open"} + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=())) + publisher._observer_workers = {} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_reports = {} + publisher._observer_pending_failures = {} + publisher._observer_diagnostics = [] + + with pytest.raises(RuntimeError, match="lost its opened ROOT output lane"): + publisher.close_live_visualizations(run_identity) + assert run_identity.token in publisher._closed_observer_runs + + +def test_close_preflight_refuses_queue_owned_by_another_run(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "queue-owner"}) + other_identity = make_identity("run", {"case": "other-queue-owner"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/serial", + parallel_mode=ParallelMode.SERIAL, + operation_data={"observer": {"provider": {"provider_id": "test.serial-observer"}}}, + ) + queue = SimpleNamespace( + close_authority={ + "run_identity": other_identity.token, + "consumer_id": manifest.qualified_id, + "provider_id": "test.serial-observer", + }, + close_requested=False, + close_succeeded=False, + ) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._communicator = None + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._observer_run_phases = {run_identity.token: "open"} + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=(manifest,))) + publisher._observer_workers = {} + publisher._observer_queues = {(manifest.qualified_id, run_identity.token): queue} + publisher._observer_lanes = {} + + with pytest.raises(RuntimeError, match="queue owned by another run or consumer"): + publisher._preflight_observer_close(run_identity) + + +def test_pending_observer_abort_retries_only_after_local_failure(): + from pops.output.observers import authenticate_observer_session + from pops.runtime._runtime_consumers import _PendingObserverSession + + class _Session: + authority = { + "schema_version": 1, + "provider_id": "test.pending-observer", + "delivery": "post_commit", + "threading": "dedicated_serial", + "worker_mpi": False, + } + + def __init__(self): + self.abort_calls = 0 + + def initialize(self, _run): + return None + + def execute(self, _frame): + raise AssertionError("unused") + + def finalize(self): + return None + + def abort(self): + self.abort_calls += 1 + if self.abort_calls == 1: + raise RuntimeError("transient abort failure") + + run_identity = make_identity("run", {"case": "pending-abort-retry"}) + session = _Session() + pending = _PendingObserverSession( + run_identity, + "monitor/pending", + session.authority["provider_id"], + False, + session, + ) + assert authenticate_observer_session(pending)["provider_id"] == "test.pending-observer" + + with pytest.raises(RuntimeError, match="transient abort failure"): + pending.abort() + assert not pending.abort_succeeded + + pending.abort() + pending.abort() + assert pending.abort_succeeded + assert session.abort_calls == 2 + + +def test_pending_observer_abort_marks_worker_collective_loss(): + from pops.output.observers import ObserverWorkerCollectiveLost + from pops.runtime._runtime_consumers import _PendingObserverSession + + class _Session: + authority = { + "schema_version": 1, + "provider_id": "test.pending-observer-lost-lane", + "delivery": "post_commit", + "threading": "dedicated_collective", + "worker_mpi": True, + } + + def initialize(self, _run): + return None + + def execute(self, _frame): + raise AssertionError("unused") + + def finalize(self): + return None + + def abort(self): + raise ObserverWorkerCollectiveLost("injected pending abort lane loss") + + run_identity = make_identity("run", {"case": "pending-abort-lost-lane"}) + session = _Session() + pending = _PendingObserverSession( + run_identity, + "monitor/pending-lost-lane", + session.authority["provider_id"], + True, + session, + ) + + with pytest.raises(ObserverWorkerCollectiveLost, match="pending abort lane loss"): + pending.abort() + assert pending.abort_succeeded is False + assert pending.worker_collective_lost is True + + +def test_failed_pending_session_retains_worker_until_owner_thread_retry(): + from pops.runtime._observer_runtime import PostCommitObserverWorker + from pops.runtime._runtime_consumers import ( + _PendingObserverSession, + RuntimeConsumerPublisher, + ) + + class _Session: + authority = { + "schema_version": 1, + "provider_id": "test.pending-worker-owner", + "delivery": "post_commit", + "threading": "dedicated_serial", + "worker_mpi": False, + } + + def __init__(self): + self.abort_threads = [] + + def initialize(self, _run): + return None + + def execute(self, _frame): + raise AssertionError("unused") + + def finalize(self): + return None + + def abort(self): + self.abort_threads.append(threading.get_ident()) + if len(self.abort_threads) == 1: + raise RuntimeError("transient owner-thread abort failure") + + run_identity = make_identity("run", {"case": "pending-worker-owner-retry"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/pending-worker-owner", + parallel_mode=ParallelMode.SERIAL, + identity=make_identity("consumer-manifest", {"case": "pending-worker-owner"}), + operation_data={ + "observer": {"provider": {"provider_id": _Session.authority["provider_id"]}}, + "on_failure": {"action": "raise_on_flush"}, + }, + ) + key = (manifest.qualified_id, run_identity.token) + session = _Session() + pending = _PendingObserverSession( + run_identity, + manifest.qualified_id, + session.authority["provider_id"], + False, + session, + ) + worker = PostCommitObserverWorker( + thread_name="test-pending-worker-owner", + run_identity=run_identity, + ) + try: + owner_thread = worker._thread.ident + assert owner_thread is not None + + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._communicator = None + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {run_identity.token: "opening"} + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=(manifest,))) + publisher._observer_workers = {run_identity.token: worker} + publisher._observer_pending_sessions = {key: pending} + publisher._observer_queues = {} + publisher._observer_lanes = {} + publisher._observer_pending_failures = {} + publisher._observer_reports = {} + publisher._observer_diagnostics = [] + + with pytest.raises(RuntimeError, match="transient owner-thread abort failure"): + publisher.close_live_visualizations(run_identity) + assert publisher._observer_pending_sessions[key] is pending + assert publisher._observer_workers[run_identity.token] is worker + assert worker._thread.is_alive() + assert worker.close_requested is False + assert session.abort_threads == [owner_thread] + + assert publisher.close_live_visualizations(run_identity) == () + assert session.abort_threads == [owner_thread, owner_thread] + assert key not in publisher._observer_pending_sessions + assert run_identity.token not in publisher._observer_workers + assert worker.close_succeeded is True + assert worker._thread.is_alive() is False + assert publisher._observer_run_phases[run_identity.token] == "closed" + finally: + if not worker.close_succeeded: + worker.close() + + +def test_close_preflight_refuses_divergent_mpi_run_lifecycle_phases(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "divergent-close-phases"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/divergent-close-phases", + parallel_mode=ParallelMode.COLLECTIVE, + identity=make_identity("consumer-manifest", {"case": "divergent-close-phases"}), + operation_data={ + "observer": {"provider": {"provider_id": "test.collective-observer"}}, + "on_failure": {"action": "raise_on_flush"}, + }, + ) + key = (manifest.qualified_id, run_identity.token) + authority = { + "run_identity": run_identity.token, + "consumer_id": manifest.qualified_id, + "provider_id": "test.collective-observer", + } + queue = SimpleNamespace( + close_authority=authority, + close_requested=True, + close_succeeded=False, + ) + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/post-commit/%s/%s" % (manifest.identity.token, run_identity.token), + active=True, + closed=False, + ) + worker = SimpleNamespace( + close_authority=run_identity.token, + close_requested=False, + close_succeeded=False, + ) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._closed_observer_runs = set() + publisher._observer_run_phases = {run_identity.token: "closing_opening"} + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=(manifest,))) + publisher._observer_workers = {run_identity.token: worker} + publisher._observer_pending_sessions = {} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {key: lane} + + def divergent_phase_rows(_communicator, envelope): + assert envelope["phase"] == "closing_opening" + peer = dict(envelope) + peer["rank"] = 1 + peer["phase"] = "closing_open" + assert all( + peer[name] == envelope[name] for name in ("error", "root_lane", "worker", "monitors") + ) + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", divergent_phase_rows) + drain_calls = [] + + def forbidden_drain(*_args, **_kwargs): + drain_calls.append(True) + raise AssertionError("divergent phases must be refused before abort or finalize") + + publisher._drain_observer_manifest = forbidden_drain + + with pytest.raises(RuntimeError, match="divergent run lifecycle phases"): + publisher.close_live_visualizations(run_identity) + + assert drain_calls == [] + assert publisher._observer_queues[key] is queue + assert publisher._observer_lanes[key] is lane + assert publisher._observer_workers[run_identity.token] is worker + + +def test_opening_preflight_refuses_complementary_mpi_owners_with_partial_worker(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "complementary-opening-owners"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/collective-opening", + parallel_mode=ParallelMode.COLLECTIVE, + identity=make_identity("consumer-manifest", {"case": "collective-opening"}), + operation_data={"observer": {"provider": {"provider_id": "test.collective-observer"}}}, + ) + key = (manifest.qualified_id, run_identity.token) + authority = { + "run_identity": run_identity.token, + "consumer_id": manifest.qualified_id, + "provider_id": "test.collective-observer", + } + queue = SimpleNamespace( + close_authority=authority, + close_requested=False, + close_succeeded=False, + ) + lane_identity = "MPI_COMM_WORLD/post-commit/%s/%s" % ( + manifest.identity.token, + run_identity.token, + ) + lane = SimpleNamespace(identity=lane_identity, active=True, closed=False) + worker = SimpleNamespace( + close_authority=run_identity.token, + close_requested=False, + close_succeeded=False, + ) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._observer_run_phases = {run_identity.token: "opening"} + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=(manifest,))) + publisher._observer_workers = {run_identity.token: worker} + publisher._observer_pending_sessions = {} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {key: lane} + + def complementary_peer(_communicator, envelope): + peer = dict(envelope) + peer["rank"] = 1 + peer["worker"] = None + peer["monitors"] = [dict(row) for row in envelope["monitors"]] + peer["monitors"][0]["session"] = { + "authority": authority, + "abort_succeeded": False, + "authenticated": True, + } + peer["monitors"][0]["queue"] = None + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", complementary_peer) + with pytest.raises(RuntimeError, match="without every run worker"): + publisher._preflight_observer_close(run_identity) + + +def test_rank_divergent_collective_abort_is_retained_without_unsafe_retry(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "partial-collective-abort"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/partial-abort", + parallel_mode=ParallelMode.COLLECTIVE, + operation_data={"on_failure": {"action": "raise_on_flush"}}, + ) + key = (manifest.qualified_id, run_identity.token) + + class _Queue: + close_requested = False + close_succeeded = False + reports = () + + def __init__(self): + self.abort_calls = 0 + self.prepare_calls = 0 + + def prepare_abort_close(self): + self.prepare_calls += 1 + self.close_requested = True + return () + + def prepare_complete_abort_close(self): + return None + + def cancel_complete_abort_close(self, _error): + return None + + def arm_complete_abort_close(self): + return None + + def complete_abort_close(self): + self.abort_calls += 1 + self.close_succeeded = True + return () + + def close(self): + raise AssertionError("failed opening must not finalize its observer queue") + + queue = _Queue() + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._observer_run_phases = {run_identity.token: "closing_opening"} + publisher._observer_pending_sessions = {} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {key: SimpleNamespace(closed=False)} + publisher._observer_pending_failures = {} + publisher._observer_abort_retry_blocked = set() + publisher._observer_reports = {} + publisher._observer_diagnostics = [] + + abort_phases = 0 + + def peer_abort_failure(_communicator, envelope): + nonlocal abort_phases + peer = dict(envelope) + peer["rank"] = 1 + if set(envelope) == {"rank", "lost"}: + peer["lost"] = False + elif set(envelope) in ( + {"rank", "owned", "ready"}, + {"rank", "owned", "ready", "worker_lane_lost"}, + ): + abort_phases += 1 + peer["owned"] = True + peer["ready"] = abort_phases == 1 + if "worker_lane_lost" in envelope: + peer["worker_lane_lost"] = False + elif set(envelope) == {"rank", "owned", "error"}: + peer["owned"] = True + peer["error"] = None + elif set(envelope) == {"rank", "ready"}: + peer["ready"] = False + elif set(envelope) == {"rank", "reports", "diagnostics"}: + peer["reports"] = [] + peer["diagnostics"] = [] + else: # pragma: no cover - every collective phase is authenticated above + raise AssertionError("unexpected close collective") + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", peer_abort_failure) + + first = publisher._drain_observer_manifest(manifest, run_identity, close=True) + assert any("subset of MPI ranks" in failure for failure in first) + assert queue.prepare_calls == 1 + assert queue.abort_calls == 1 + assert key in publisher._observer_queues + assert key in publisher._observer_abort_retry_blocked + + second = publisher._drain_observer_manifest(manifest, run_identity, close=True) + assert any("retry refused" in failure for failure in second) + assert queue.prepare_calls == 1 + assert queue.abort_calls == 1 + assert key in publisher._observer_queues + + +def test_poisoned_worker_lane_refuses_provider_and_lane_cleanup(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "poisoned-worker-lane-close"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/poisoned-worker-lane", + parallel_mode=ParallelMode.COLLECTIVE, + operation_data={"on_failure": {"action": "raise_on_flush"}}, + ) + key = (manifest.qualified_id, run_identity.token) + + class _Queue: + worker_collective_lost = True + close_requested = False + close_succeeded = False + reports = () + + def __init__(self): + self.seal_calls = 0 + + def seal_local(self, _error): + self.seal_calls += 1 + + def __getattr__(self, name): + if name.startswith(("prepare", "arm", "complete", "close", "abort", "flush")): + raise AssertionError("poisoned worker lane must not reenter queue lifecycle") + raise AttributeError(name) + + class _Lane: + def __init__(self): + self.closed = False + self.close_calls = 0 + + def close_collectively(self): + self.close_calls += 1 + raise AssertionError("poisoned worker lane must not be reused or closed") + + class _Worker: + def __init__(self): + self.seal_calls = 0 + self.close_succeeded = False + self.stopped = False + + def seal_local(self, _error): + self.seal_calls += 1 + self.close_succeeded = True + self.stopped = True + + queue = _Queue() + lane = _Lane() + worker = _Worker() + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._observer_run_phases = {run_identity.token: "closing_open"} + publisher._observer_pending_sessions = {} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {key: lane} + publisher._observer_workers = {run_identity.token: worker} + publisher._observer_pending_failures = {} + publisher._observer_abort_retry_blocked = set() + publisher._observer_finalize_retry_blocked = set() + publisher._observer_reports = {} + publisher._observer_diagnostics = [] + + collective_phases = [] + + def gathered(_communicator, envelope): + peer = dict(envelope) + peer["rank"] = 1 + if set(envelope) == {"rank", "lost"}: + collective_phases.append("health") + peer["lost"] = True + elif set(envelope) == {"rank", "error", "closed"}: + collective_phases.append("local-worker-seal") + assert worker.stopped is True + assert envelope["error"] is None + assert envelope["closed"] is True + else: # pragma: no cover - every worker-loss phase is authenticated above + raise AssertionError("unexpected poisoned worker collective envelope") + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", gathered) + + with pytest.raises( + _runtime_consumers._ObserverWorkerLaneLost, + match="worker lane lost collective proof", + ): + publisher._drain_observer_manifest(manifest, run_identity, close=True) + + assert collective_phases == ["health", "local-worker-seal"] + assert queue.seal_calls == 1 + assert worker.seal_calls == 1 + assert worker.close_succeeded is True + assert worker.stopped is True + assert publisher._observer_workers[run_identity.token] is worker + assert publisher._observer_queues[key] is queue + assert publisher._observer_lanes[key] is lane + assert lane.close_calls == 0 + assert "worker lane lost collective proof" in publisher._observer_pending_failures[key][0] + + +def test_pending_abort_worker_lane_loss_stops_worker_without_lane_reentry(monkeypatch, request): + from pops.output.observers import ObserverWorkerCollectiveLost + from pops.runtime import _runtime_consumers + from pops.runtime._observer_runtime import PostCommitObserverWorker + from pops.runtime._runtime_consumers import ( + _PendingObserverSession, + RuntimeConsumerPublisher, + ) + + class _Session: + authority = { + "schema_version": 1, + "provider_id": "test.pending-abort-lost-worker-lane", + "delivery": "post_commit", + "threading": "dedicated_collective", + "worker_mpi": True, + } + + def __init__(self): + self.abort_calls = 0 + + def initialize(self, _run): + return None + + def execute(self, _frame): + raise AssertionError("unused") + + def finalize(self): + return None + + def abort(self): + self.abort_calls += 1 + raise ObserverWorkerCollectiveLost("injected pending abort worker-lane loss") + + class _Lane: + closed = False + + def close_collectively(self): + raise AssertionError("a poisoned worker lane must remain retained") + + run_identity = make_identity("run", {"case": "pending-abort-worker-lane-loss"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/pending-abort-worker-lane-loss", + parallel_mode=ParallelMode.COLLECTIVE, + operation_data={"on_failure": {"action": "raise_on_flush"}}, + ) + key = (manifest.qualified_id, run_identity.token) + session = _Session() + pending = _PendingObserverSession( + run_identity, + manifest.qualified_id, + session.authority["provider_id"], + True, + session, + ) + worker = PostCommitObserverWorker( + thread_name="test-pending-abort-worker-lane-loss", + run_identity=run_identity, + ) + request.addfinalizer( + lambda: None if worker.close_succeeded else worker.seal_local(RuntimeError("test cleanup")) + ) + lane = _Lane() + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._observer_run_phases = {run_identity.token: "closing_opening"} + publisher._observer_pending_sessions = {key: pending} + publisher._observer_queues = {} + publisher._observer_lanes = {key: lane} + publisher._observer_workers = {run_identity.token: worker} + publisher._observer_pending_failures = {} + publisher._observer_abort_retry_blocked = set() + publisher._observer_finalize_retry_blocked = set() + publisher._observer_reports = {} + publisher._observer_diagnostics = [] + + phases = [] + + def gathered(_communicator, envelope): + peer = dict(envelope) + peer["rank"] = 1 + keys = set(envelope) + if keys == {"rank", "lost"}: + phases.append("initial-health") + peer["lost"] = False + elif keys == {"rank", "owned", "ready"}: + phases.append("abort-preparation") + elif keys == {"rank", "owned", "error"}: + phases.append("abort-admission") + elif keys == {"rank", "owned", "ready", "worker_lane_lost"}: + phases.append("abort-completion") + assert envelope["worker_lane_lost"] is True + peer["worker_lane_lost"] = True + elif keys == {"rank", "error", "closed"}: + phases.append("local-worker-seal") + assert worker.close_succeeded is True + else: # pragma: no cover - every phase is authenticated above + raise AssertionError("unexpected pending-abort collective envelope") + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", gathered) + + with pytest.raises( + _runtime_consumers._ObserverWorkerLaneLost, + match="abort lost worker-lane collective proof", + ): + publisher._drain_observer_manifest(manifest, run_identity, close=True) + + assert phases == [ + "initial-health", + "abort-preparation", + "abort-admission", + "abort-completion", + "local-worker-seal", + ] + assert session.abort_calls == 1 + assert pending.worker_collective_lost is True + assert worker.close_succeeded is True + assert publisher._observer_pending_sessions[key] is pending + assert publisher._observer_workers[run_identity.token] is worker + assert publisher._observer_lanes[key] is lane + + +def test_durable_journal_world_loss_is_not_downgraded_to_local_failure(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + manifest = SimpleNamespace(parallel_mode=ParallelMode.COLLECTIVE) + journal = SimpleNamespace(list_committed=lambda: ()) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + collective_calls = 0 + + def lost_world(_communicator, _envelope): + nonlocal collective_calls + collective_calls += 1 + raise RuntimeError("injected durable WORLD loss") + + monkeypatch.setattr(_runtime_consumers, "allgather_value", lost_world) + + with pytest.raises( + _runtime_consumers._ObserverCollectiveLost, + match="lost its WORLD inspection proof", + ): + publisher._inspect_observer_journal(manifest, journal) + + assert collective_calls == 1 + + +def test_mpi_finalize_enqueue_consensus_failure_cancels_prepared_provider_call( + monkeypatch, + request: pytest.FixtureRequest, +): + from pops import _native_collectives + from pops.runtime import _runtime_consumers + from pops.runtime._observer_runtime import ( + ObserverRun, + PostCommitObserverQueue, + PostCommitObserverWorker, + _PreparedWorkerCall, + ) + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "finalize-enqueue-consensus-failure"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/finalize-enqueue-consensus-failure", + parallel_mode=ParallelMode.COLLECTIVE, + operation_data={"on_failure": {"action": "raise_on_flush"}}, + ) + key = (manifest.qualified_id, run_identity.token) + + class _Session: + authority = { + "schema_version": 1, + "provider_id": "test.collective-finalize", + "delivery": "post_commit", + "threading": "dedicated_collective", + "worker_mpi": True, + } + + def __init__(self): + self.finalize_calls = 0 + + def initialize(self, _run): + return None + + def execute(self, _frame): + raise AssertionError("finalization admission must not execute an observer frame") + + def finalize(self): + self.finalize_calls += 1 + + def abort(self): + raise AssertionError("normal finalization must not enter provider abort") + + class _Lane: + def __init__(self): + self.closed = False + self.close_calls = 0 + + def close_collectively(self): + self.close_calls += 1 + self.closed = True + + monkeypatch.setattr( + _native_collectives, + "require_communicator", + lambda communicator, *, allow_world=True: communicator, + ) + session = _Session() + lane = _Lane() + worker = PostCommitObserverWorker( + thread_name="test-finalize-enqueue-consensus-failure", + run_identity=run_identity, + ) + try: + queue = PostCommitObserverQueue( + session, + ObserverRun(run_identity), + consumer_id=manifest.qualified_id, + worker_communicator=lane, + shared_worker=worker, + defer_initialize=True, + ) + except BaseException: + worker.close() + raise + + def cleanup() -> None: + cleanup_error = RuntimeError("test cleanup cancelled an unresolved lifecycle call") + queue.cancel_initialize(cleanup_error) + queue.cancel_complete_close(cleanup_error) + queue.cancel_complete_abort_close(cleanup_error) + worker.close() + + request.addfinalizer(cleanup) + + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._observer_run_phases = {run_identity.token: "open"} + publisher._observer_pending_sessions = {} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {key: lane} + publisher._observer_pending_failures = {} + publisher._observer_abort_retry_blocked = set() + publisher._observer_finalize_retry_blocked = set() + publisher._observer_reports = {} + publisher._observer_diagnostics = [] + + prepared_attempts = [] + cancelled_attempts = [] + awaited_attempts = [] + original_cancel = _PreparedWorkerCall.cancel + original_result = _PreparedWorkerCall.result + + def tracked_cancel(attempt, error): + cancelled_attempts.append(attempt) + return original_cancel(attempt, error) + + def tracked_result(attempt): + awaited_attempts.append(attempt) + return original_result(attempt) + + monkeypatch.setattr(_PreparedWorkerCall, "cancel", tracked_cancel) + monkeypatch.setattr(_PreparedWorkerCall, "result", tracked_result) + + def close_rows(phase, envelope): + if phase == "MPI observer queue finalization enqueue": + assert queue._finalize_attempt is not None + prepared_attempts.append(queue._finalize_attempt) + raise RuntimeError("injected finalization enqueue consensus failure") + peer = dict(envelope) + peer["rank"] = 1 + return envelope, peer + + publisher._collective_close_rows = close_rows + + def peer_flush(_communicator, envelope): + return envelope, {"rank": 1, "reports": [], "diagnostics": []} + + monkeypatch.setattr(_runtime_consumers, "allgather_value", peer_flush) + + try: + with pytest.raises( + _runtime_consumers._ObserverCollectiveLost, + match="finalization enqueue consensus failed", + ): + publisher._drain_observer_manifest(manifest, run_identity, close=True) + assert len(prepared_attempts) == 1 + assert cancelled_attempts == prepared_attempts + assert awaited_attempts == prepared_attempts + assert prepared_attempts[0]._done.is_set() + assert queue._finalize_attempt is None + assert session.finalize_calls == 0 + assert key in publisher._observer_queues + assert key in publisher._observer_lanes + assert lane.close_calls == 0 + assert lane.closed is False + assert publisher._observer_finalize_retry_blocked == set() + finally: + cleanup() + + +def test_mpi_world_report_consensus_loss_is_sticky_and_keeps_reports_private(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._observer_runtime import ObserverDeliveryReport + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "retained-finalized-reports"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/retained-finalized-reports", + parallel_mode=ParallelMode.COLLECTIVE, + operation_data={"on_failure": {"action": "raise_on_flush"}}, + ) + key = (manifest.qualified_id, run_identity.token) + frame_identity = make_identity( + "post-commit-observer-frame", + {"case": "retained-finalized-reports"}, + ) + report = ObserverDeliveryReport( + manifest.qualified_id, + run_identity, + 0, + frame_identity, + "delivered", + 1, + receipt=ObserverReceipt(frame_identity, "test.collective-finalize"), + ) + + class _Queue: + close_requested = False + close_succeeded = False + abort_required = False + reports = (report,) + + def __init__(self): + self.finalize_calls = 0 + + def prepare_close(self): + self.close_requested = True + return self.reports + + def prepare_complete_close(self): + return None + + def cancel_complete_close(self, _error): + return None + + def arm_complete_close(self): + return None + + def complete_close(self): + self.finalize_calls += 1 + self.close_succeeded = True + return self.reports + + class _Lane: + def __init__(self): + self.closed = False + self.close_calls = 0 + + def close_collectively(self): + self.close_calls += 1 + self.closed = True + + queue = _Queue() + lane = _Lane() + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._observer_run_phases = {run_identity.token: "closing_open"} + publisher._observer_pending_sessions = {} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {key: lane} + publisher._observer_pending_reports = {} + publisher._observer_pending_failures = {} + publisher._observer_abort_retry_blocked = set() + publisher._observer_finalize_retry_blocked = set() + publisher._observer_reports = {} + publisher._observer_diagnostics = [] + assert publisher.post_commit_reports == () + + def close_rows(_phase, envelope): + peer = dict(envelope) + peer["rank"] = 1 + return envelope, peer + + publisher._collective_close_rows = close_rows + report_consensus_calls = 0 + + def fail_report_consensus(_communicator, envelope): + nonlocal report_consensus_calls + assert set(envelope) == {"rank", "reports", "diagnostics"} + report_consensus_calls += 1 + raise RuntimeError("injected report consensus loss") + + monkeypatch.setattr(_runtime_consumers, "allgather_value", fail_report_consensus) + + with pytest.raises( + _runtime_consumers._ObserverCollectiveLost, + match="flush lost its collective proof", + ): + publisher._drain_observer_manifest(manifest, run_identity, close=True) + + assert queue.finalize_calls == 1 + assert key not in publisher._observer_queues + assert publisher._observer_pending_reports[key] == (report,) + assert report.identity.token not in publisher._observer_reports + assert publisher.post_commit_reports == () + assert publisher._observer_lanes[key] is lane + assert lane.closed is True + assert lane.close_calls == 1 + assert report_consensus_calls == 1 + assert "flush lost its collective proof" in publisher._observer_world_collective_lost + + with pytest.raises(RuntimeError, match="MPI_COMM_WORLD is sealed"): + publisher._drain_observer_manifest(manifest, run_identity, close=True) + + assert report_consensus_calls == 1 + assert queue.finalize_calls == 1 + assert publisher._observer_pending_reports[key] == (report,) + assert publisher._observer_reports == {} + assert publisher.post_commit_reports == () + assert publisher._observer_lanes[key] is lane + assert lane.close_calls == 1 + + +def test_observer_drain_accepts_recovery_run_identity_and_refuses_foreign_run(): + from pops.runtime._observer_runtime import ObserverDeliveryReport, ObserverRun + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "active-report-authority"}) + recovery_identity = make_identity("run", {"case": "recovery-report-authority"}) + foreign_identity = make_identity("run", {"case": "foreign-report-authority"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/recovery-report-authority", + parallel_mode=ParallelMode.SERIAL, + operation_data={"on_failure": {"action": "raise_on_flush"}}, + ) + key = (manifest.qualified_id, run_identity.token) + + def delivered_report(identity, sequence): + frame_identity = make_identity( + "post-commit-observer-frame", + {"run": identity.token, "sequence": sequence}, + ) + return ObserverDeliveryReport( + manifest.qualified_id, + identity, + sequence, + frame_identity, + "delivered", + 1, + receipt=ObserverReceipt(frame_identity, "test.recovery-report-authority"), + ) + + recovery_report = delivered_report(recovery_identity, 0) + foreign_report = delivered_report(foreign_identity, 1) + + class _Queue: + close_requested = False + close_succeeded = False + + def __init__(self): + self.reports = (recovery_report,) + + def flush(self): + return self.reports + + queue = _Queue() + observer_run = ObserverRun( + run_identity, + recovery_run_identities=(recovery_identity,), + ) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 1 + publisher._communicator = None + publisher._observer_run_phases = {run_identity.token: "open"} + publisher._observer_pending_sessions = {} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {} + publisher._observer_pending_reports = {} + publisher._observer_report_run_authorities = { + key: frozenset(observer_run.accepted_run_identities) + } + publisher._observer_pending_failures = {} + publisher._observer_reports = {} + publisher._observer_diagnostics = [] + + assert publisher._drain_observer_manifest(manifest, run_identity, close=False) == () + assert publisher._observer_reports[recovery_report.identity.token] == recovery_report + + queue.reports = (foreign_report,) + with pytest.raises(RuntimeError, match="authenticates another run or session"): + publisher._drain_observer_manifest(manifest, run_identity, close=False) + assert foreign_report.identity.token not in publisher._observer_reports + + +def test_close_preflight_refuses_worker_missing_on_one_mpi_rank(monkeypatch): + from pops.runtime import _runtime_consumers + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + run_identity = make_identity("run", {"case": "missing-rank-worker"}) + manifest = SimpleNamespace( + kind=ConsumerKind.MONITOR, + qualified_id="monitor/collective-worker", + parallel_mode=ParallelMode.COLLECTIVE, + identity=make_identity("consumer-manifest", {"case": "collective-worker"}), + operation_data={"observer": {"provider": {"provider_id": "test.collective-observer"}}}, + ) + key = (manifest.qualified_id, run_identity.token) + queue = SimpleNamespace( + close_authority={ + "run_identity": run_identity.token, + "consumer_id": manifest.qualified_id, + "provider_id": "test.collective-observer", + }, + close_requested=False, + close_succeeded=False, + ) + lane = SimpleNamespace( + identity="MPI_COMM_WORLD/post-commit/%s/%s" % (manifest.identity.token, run_identity.token), + active=True, + closed=False, + ) + worker = SimpleNamespace( + close_authority=run_identity.token, + close_requested=False, + close_succeeded=False, + ) + publisher = object.__new__(RuntimeConsumerPublisher) + publisher._rank = 0 + publisher._size = 2 + publisher._communicator = SimpleNamespace(identity="MPI_COMM_WORLD") + publisher._root_output_consumers = () + publisher._root_output_lanes = {} + publisher._observer_run_phases = {run_identity.token: "open"} + publisher._owner = SimpleNamespace(_consumer_graph=SimpleNamespace(nodes=(manifest,))) + publisher._observer_workers = {run_identity.token: worker} + publisher._observer_queues = {key: queue} + publisher._observer_lanes = {key: lane} + + def missing_peer_worker(_communicator, envelope): + peer = dict(envelope) + peer["rank"] = 1 + peer["monitors"] = [dict(row) for row in envelope["monitors"]] + peer["worker"] = None + return envelope, peer + + monkeypatch.setattr(_runtime_consumers, "allgather_value", missing_peer_worker) + with pytest.raises(RuntimeError, match="without every run worker"): + publisher._preflight_observer_close(run_identity) + + def test_diagnostic_component_requires_one_explicit_role_for_multicomponent_state(): from pops.runtime._runtime_consumers import RuntimeConsumerPublisher @@ -1785,6 +4201,66 @@ def _step_change_l2(self): assert (value, composite) == (0.125, True) +def test_balance_diagnostic_accepts_only_the_exact_native_five_term_tuple(): + from pops.runtime._runtime_consumers import RuntimeConsumerPublisher + + class _Provider: + def _accepted_balance_terms(self, route): + assert route == "pops.balance-ledger-route.v1:sha256:" + "1" * 64 + return { + "storage_change": 11.0, + "outward_boundary_flux": 2.0, + "sources": 5.0, + "reflux": 3.0, + "projection": 1.0, + } + + terms = RuntimeConsumerPublisher._native_balance_terms( + _Provider(), + "pops.balance-ledger-route.v1:sha256:" + "1" * 64, + block="fluid", + component=0, + levels=(0,), + automatic_terms=(), + ) + assert terms.residual == 4.0 + assert terms.reflux == 3.0 + + class _Incomplete: + def _accepted_balance_terms(self, _route): + return {"storage_change": 1.0} + + with pytest.raises(TypeError, match="exactly storage_change"): + RuntimeConsumerPublisher._native_balance_terms( + _Incomplete(), + "route", + block="fluid", + component=0, + levels=(0,), + automatic_terms=(), + ) + + class _Coerced: + def _accepted_balance_terms(self, _route): + return { + "storage_change": "1.0", + "outward_boundary_flux": 2.0, + "sources": 5.0, + "reflux": 3.0, + "projection": 1.0, + } + + with pytest.raises(TypeError, match="exact floating-point"): + RuntimeConsumerPublisher._native_balance_terms( + _Coerced(), + "route", + block="fluid", + component=0, + levels=(0,), + automatic_terms=(), + ) + + def test_diagnostic_restart_restores_payload_terms_and_native_inspection_registry(): from pops.identity import make_identity from pops.output.data import DiagnosticKey, DiagnosticPayload diff --git a/tests/python/unit/runtime/test_runtime_output_geometry.py b/tests/python/unit/runtime/test_runtime_output_geometry.py index a23541277..b7b3e4488 100644 --- a/tests/python/unit/runtime/test_runtime_output_geometry.py +++ b/tests/python/unit/runtime/test_runtime_output_geometry.py @@ -258,6 +258,7 @@ def test_runtime_output_refuses_unknown_extension_cell_measure(): "pops://cell-measures/extension-area@1", ("a", "b"), (0.0, 0.0), (1.0, 1.0), (4, 4), ), + native_spatial_layout=None, ) owner = SimpleNamespace( _layout_plan=SimpleNamespace(layouts=(layout,)), @@ -282,7 +283,8 @@ def test_normalized_geometry_is_rank_generic_but_current_output_provider_refuses Uniform(CartesianGrid(frame=frame, cells=(4, 6))), owner=OwnerPath.case("rank-gate"), ) - layout = replace(plan.layouts[0], geometry=geometry) + layout = replace( + plan.layouts[0], geometry=geometry, native_spatial_layout=None) owner = SimpleNamespace( _layout_plan=SimpleNamespace(layouts=(layout,)), _executor_for_layout=lambda layout_id: _Engine(nx=4, ny=6), diff --git a/tests/python/unit/runtime/test_seam_combinations.py b/tests/python/unit/runtime/test_seam_combinations.py index 9fdb2f215..70bc7bebb 100644 --- a/tests/python/unit/runtime/test_seam_combinations.py +++ b/tests/python/unit/runtime/test_seam_combinations.py @@ -25,6 +25,8 @@ ("exb", None), ("isothermal", "rusanov"), ("isothermal", "hll"), + ("isothermal", "hllc"), + ("isothermal", "roe"), ("compressible", "rusanov"), ("compressible", "hll"), ("compressible", "hllc"), diff --git a/tests/python/unit/runtime/test_spatial_identity.py b/tests/python/unit/runtime/test_spatial_identity.py index 5fe68c927..c22d28789 100644 --- a/tests/python/unit/runtime/test_spatial_identity.py +++ b/tests/python/unit/runtime/test_spatial_identity.py @@ -7,7 +7,7 @@ import pops.runtime._engine_descriptors as engine from pops.numerics.reconstruction import WENO5 from pops.numerics.reconstruction.limiters import Minmod -from pops.numerics.riemann import HLL +from pops.numerics.riemann import HLL, Recovery, Roe, Rusanov from pops.numerics.riemann.waves import ExplicitPair from pops.numerics.variables import Primitive from pops.problem._detached import detached_frozen @@ -67,6 +67,36 @@ def test_spatial_identity_distinguishes_routes_and_exact_numeric_domains(): positivity_floor=Fraction(1, 10)) +def test_spatial_identity_lowers_the_fixed_riemann_recovery_route(): + spatial = engine.Spatial( + limiter=Minmod(), + flux=Recovery(primary=Roe(), fallbacks=(HLL(), Rusanov())), + ) + + assert spatial.flux.token == "roe_hll_rusanov_recovery" + assert spatial.flux.native_entry == ( + "pops::PreparedRiemannRecoveryPolicy" + ) + assert spatial.to_data()["riemann"] == { + "route": "roe_hll_rusanov_recovery", + "external_id": None, + "capability_contract": { + "required_capabilities": [ + "physical_flux", "provider_pack", "roe_dissipation", "stability_bound", + "wave_speeds", + ], + "wave_speed_provider": None, + }, + } + + with pytest.raises(ValueError, match="wave_speed_cache requires flux=riemann.HLL"): + engine.Spatial( + flux=Recovery(primary=Roe(), fallbacks=(HLL(), Rusanov())), + wave_speed_cache=True, + ) + + def test_external_riemann_identity_includes_the_registered_brick_id(): from pops.descriptors import BrickDescriptor diff --git a/tests/python/unit/runtime/test_temporal_restart_state.py b/tests/python/unit/runtime/test_temporal_restart_state.py index 3b34d6b0a..805156311 100644 --- a/tests/python/unit/runtime/test_temporal_restart_state.py +++ b/tests/python/unit/runtime/test_temporal_restart_state.py @@ -25,6 +25,7 @@ from pops.runtime._temporal_restart import TemporalRestartState from pops.runtime._uniform_restart_preflight import preflight_uniform_restart from pops.time import Clock, ErrorControlledDt, FixedDt, TimePoint +from tests.python.support.native_execution_context import artifact_execution_context ROOT = Path(__file__).resolve().parents[4] @@ -372,7 +373,11 @@ def _bind_uniform_artifact(artifact): n = 4 initial = np.ones((1, n, n), dtype=np.float64) - return pops.bind(artifact, initial_state={"blk": initial}) + return pops.bind( + artifact, + initial_state={"blk": initial}, + resources={"execution_context": artifact_execution_context(artifact)}, + ) def _bound_uniform_runtime(native_cxx, *, attempt_policy): diff --git a/tests/python/unit/time/test_program_cadence.py b/tests/python/unit/time/test_program_cadence.py new file mode 100644 index 000000000..c2a9b07f1 --- /dev/null +++ b/tests/python/unit/time/test_program_cadence.py @@ -0,0 +1,110 @@ +"""Public immutable Program cadence and its bind-time transport.""" +from __future__ import annotations + +import pytest + +from pops.identity.semantic import semantic_identity_of +from pops.runtime._program_cadence_install import install_program_cadence +from pops.time import Program +from pops.time._cadence import ProgramCadence +from pops.time._program.detach import detach_compiled_program + + +def test_program_cadence_is_single_declaration_exact_positive_and_identity_bearing(): + baseline = Program("baseline") + configured = Program("configured") + + assert "cadence" not in baseline._serialize(include_provenance=False) + assert "cadence" not in baseline.to_graph().to_data() + assert configured.cadence(substeps=2, stride=3) is configured + assert configured.cadence_contract().to_data() == { + "schema_version": 1, + "substeps": 2, + "stride": 3, + } + assert configured._serialize(include_provenance=False)["cadence"] == { + "schema_version": 1, + "substeps": 2, + "stride": 3, + } + assert configured.to_graph().to_data()["cadence"] == { + "schema_version": 1, + "substeps": 2, + "stride": 3, + } + assert configured._ir_hash() != baseline._ir_hash() + assert configured.to_graph().graph_hash != baseline.to_graph().graph_hash + assert semantic_identity_of(program=configured) != semantic_identity_of(program=baseline) + + with pytest.raises(ValueError, match="only once"): + configured.cadence(stride=4) + + for name, kwargs in ( + ("bool substeps", {"substeps": True, "stride": 1}), + ("bool stride", {"substeps": 1, "stride": False}), + ("float stride", {"substeps": 1, "stride": 2.0}), + ): + candidate = Program(name) + with pytest.raises(TypeError, match="exact int"): + candidate.cadence(**kwargs) + for name, kwargs in ( + ("zero substeps", {"substeps": 0, "stride": 1}), + ("zero stride", {"substeps": 1, "stride": 0}), + ): + candidate = Program(name) + with pytest.raises(ValueError, match=">= 1"): + candidate.cadence(**kwargs) + with pytest.raises(TypeError, match="exact v1 schema"): + ProgramCadence.from_data( + type("CadenceDict", (dict,), {})( + schema_version=1, substeps=1, stride=2 + ) + ) + with pytest.raises(ValueError, match="schema_version"): + ProgramCadence.from_data( + {"schema_version": True, "substeps": 1, "stride": 2} + ) + + +def test_compiled_detachment_preserves_cadence_and_freeze_refuses_mutation(): + authored = Program("detached-cadence").cadence(stride=3) + detached = detach_compiled_program(authored) + + assert detached is not authored + assert detached.cadence_contract() == authored.cadence_contract() + assert detached._ir_hash() == authored._ir_hash() + with pytest.raises(RuntimeError, match="frozen"): + detached.cadence(stride=4) + + +class _CadenceEngine: + def __init__(self, *, lie: bool = False) -> None: + self.calls = [] + self.substeps = 1 + self.stride = 1 + self.lie = lie + + def set_program_cadence(self, substeps, stride): + self.calls.append((substeps, stride)) + self.substeps = substeps + self.stride = stride + + def program_substeps(self): + return self.substeps + + def program_stride(self): + return self.stride + int(self.lie) + + +def test_bind_installs_and_authenticates_only_the_frozen_compiled_cadence(): + authored = Program("install-cadence").cadence(substeps=2, stride=3) + detached = detach_compiled_program(authored) + engine = _CadenceEngine() + + install_program_cadence(engine, detached) + + assert engine.calls == [(2, 3)] + with pytest.raises(TypeError, match="frozen compiled Program"): + install_program_cadence(_CadenceEngine(), authored) + with pytest.raises(RuntimeError, match="differs from the compiled contract"): + install_program_cadence(_CadenceEngine(lie=True), detached) diff --git a/tests/python/unit/time/test_program_solve_final.py b/tests/python/unit/time/test_program_solve_final.py index b1a7f8e2f..caa923b32 100644 --- a/tests/python/unit/time/test_program_solve_final.py +++ b/tests/python/unit/time/test_program_solve_final.py @@ -272,8 +272,8 @@ def test_final_catalogs_do_not_publish_unavailable_placeholders(): assert not hasattr(fields, "Helmholtz") assert not hasattr(fields, "EllipticSolve") assert not hasattr(projections, "bound_preserving") - assert not hasattr(limiters, "MC") - assert not hasattr(limiters, "Superbee") + assert hasattr(limiters, "MC") + assert hasattr(limiters, "Superbee") assert not hasattr(preconditioners, "Jacobi") assert not hasattr(preconditioners, "BlockJacobi") assert not hasattr(solvers, "Schur") diff --git a/tests/python/unit/time/test_time_codegen.py b/tests/python/unit/time/test_time_codegen.py index ad1852d44..a20c99775 100644 --- a/tests/python/unit/time/test_time_codegen.py +++ b/tests/python/unit/time/test_time_codegen.py @@ -6,7 +6,8 @@ pops_program_hash / pops_install_program), the Forward-Euler body, and that a multi-stage scheme (SSPRK2) now lowers (a scratch state + a second rhs + a lincomb commit). Multi-block (ADC-426) now lowers too -- N P.state / N P.commit, each op routed to its block index; the SIMULTANEOUS multi-target -solve_fields_from_blocks lowers to ctx.solve_fields_from_blocks (Spec 3 crit 24, ADC-457). Constructs +solve_fields_from_blocks lowers to ctx.solve_fields_from_blocks_at (Spec 3 crit 24, ADC-457/ADC-759). +Constructs the codegen still cannot lower -- named sources beyond 'default', a commit of an undeclared block -- must be REFUSED with a clear error, never silently mis-lowered. Pure Python (no compile); skips if pops is unavailable. @@ -104,11 +105,14 @@ def test_forward_euler_abi(t): def test_forward_euler_algorithm(t): - # FE: base = ctx.state(0); solve_fields_from_state(0, base); R = rhs_into(0, base); acc += dt*R; - # commit via lincomb. Each solve_fields op lowers to the per-stage solve (ADC-409); for FE the - # stage state is the base U^n, so it matches the historical solve_fields() semantics. + # FE: base = ctx.state(0); solve_fields_from_state_at(point, field, 0, base); + # R = rhs_into(0, base); acc += dt*R; commit via lincomb. Each solve_fields op lowers to the + # exact provider/level/stage solve (ADC-409/ADC-759); for FE the stage state is the base U^n, so + # it matches the historical solve_fields() semantics. src = _emit(_forward_euler(t)) - for frag in ('ctx.solve_fields_from_state("potential", 0, ', + for frag in ("const auto field_boundary_point_", + 'ctx.solve_fields_from_state_at(field_boundary_point_', + '"potential", 0, ', "= ctx.state(0);", "ctx.rhs_scratch(", "ctx.rhs_into(0, ", @@ -118,6 +122,7 @@ def test_forward_euler_algorithm(t): "ctx.commit_many("): assert frag in src, "generated FE body missing %r" % frag assert "ctx.solve_fields();" not in src, "solve_fields must lower to the per-stage solve (ADC-409)" + assert 'ctx.solve_fields_from_state("potential"' not in src assert "ctx.n_blocks()" not in src, "single-block codegen should target ctx.state(0), not a loop" @@ -213,8 +218,9 @@ def test_multiblock_lowers(t): assert "ctx.state(0)" in src, "block a should bind ctx.state(0)" assert "ctx.state(1)" in src, "block b should bind ctx.state(1)" assert "ctx.rhs_group(" in src, "sibling residuals should execute as one native round" - assert 'ctx.solve_fields_from_blocks(' in src, \ - "coupled blocks should publish one simultaneous field solve" + assert "const auto field_boundary_point_" in src + assert "ctx.solve_fields_from_blocks_at(field_boundary_point_" in src, \ + "coupled blocks should publish one point-qualified simultaneous field solve" def test_unknown_block_commit_refused(t): @@ -254,7 +260,7 @@ def test_solve_fields_from_blocks_lowers(t): "b1", Ub + P.dt * P.rhs(state=Ub, terms=[Flux(), DefaultSource()]), at=endpoint_b.point)) src = _emit(P) - assert "ctx.solve_fields_from_blocks(" in src + assert "ctx.solve_fields_from_blocks_at(" in src assert "std::vector" not in src assert "{0, &" in src and "{1, &" in src diff --git a/tests/python/unit/time/test_time_control_flow.py b/tests/python/unit/time/test_time_control_flow.py index 37f9475c5..bd67bcaf8 100644 --- a/tests/python/unit/time/test_time_control_flow.py +++ b/tests/python/unit/time/test_time_control_flow.py @@ -168,8 +168,7 @@ def _run_section_b(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) # passive advection at speed 0 (the Program never runs a rhs) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_control_flow_b.py b/tests/python/unit/time/test_time_control_flow_b.py index b9bd8ad12..91c2f87c0 100644 --- a/tests/python/unit/time/test_time_control_flow_b.py +++ b/tests/python/unit/time/test_time_control_flow_b.py @@ -191,8 +191,7 @@ def _passive_model(name): from pops.physics._facade import Model m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_divergence.py b/tests/python/unit/time/test_time_divergence.py index be4488a82..a9e071871 100644 --- a/tests/python/unit/time/test_time_divergence.py +++ b/tests/python/unit/time/test_time_divergence.py @@ -243,8 +243,7 @@ def _run_section_b(t): def passive_model(name): # 1-variable block, no flux, no Poisson coupling m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_gmres.py b/tests/python/unit/time/test_time_gmres.py index 6f55efa31..b6b6e802c 100644 --- a/tests/python/unit/time/test_time_gmres.py +++ b/tests/python/unit/time/test_time_gmres.py @@ -419,8 +419,7 @@ def _passive_model(name): from pops.physics._facade import Model m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_history.py b/tests/python/unit/time/test_time_history.py index 58871b86e..c971a2d54 100644 --- a/tests/python/unit/time/test_time_history.py +++ b/tests/python/unit/time/test_time_history.py @@ -280,8 +280,7 @@ def _passive_source_model(name): from pops.physics._facade import Model m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_local_newton.py b/tests/python/unit/time/test_time_local_newton.py index f1bdc6401..293f639f9 100644 --- a/tests/python/unit/time/test_time_local_newton.py +++ b/tests/python/unit/time/test_time_local_newton.py @@ -71,8 +71,7 @@ def reaction_model(name, k): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) @@ -114,8 +113,7 @@ def fault_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) @@ -272,7 +270,7 @@ def r(Q, Uit, U0): "ctx.pointwise_active_mask(0,", "pops::reduce_max(ln_status_", "pops::local_nonlinear_status_from_priority(", - "pops::detail::decode_ranked_local_nonlinear_failure(", + "pops::collective_first_local_nonlinear_failure(", "collective status/location precedence mismatch", ): chk(frag in src, "the Newton kernel has %r" % frag) diff --git a/tests/python/unit/time/test_time_local_solve_run.py b/tests/python/unit/time/test_time_local_solve_run.py index 37858139d..9b14ff3dc 100644 --- a/tests/python/unit/time/test_time_local_solve_run.py +++ b/tests/python/unit/time/test_time_local_solve_run.py @@ -82,7 +82,7 @@ def lorentz_model(name="lorentz_local"): u = m.primitive("u", mx / rho) v = m.primitive("v", my / rho) p = m.primitive("p", cs2 * rho) - m.primitive_vars(rho=rho, u=u, v=v, p=p) + m.primitive_vars(rho=rho, u=u, v=v) m.conservative_from([rho, rho * u, rho * v]) m.flux(x=[mx, mx * u + p, my * u], y=[my, mx * v, my * v + p]) cs = sqrt(cs2) diff --git a/tests/python/unit/time/test_time_multiblock.py b/tests/python/unit/time/test_time_multiblock.py index 2d45dbbd4..ab4328cdf 100644 --- a/tests/python/unit/time/test_time_multiblock.py +++ b/tests/python/unit/time/test_time_multiblock.py @@ -9,7 +9,8 @@ (A) Validation + codegen (pure Python, always runs when pops.time imports): a 2-block program lowers with per-block ctx.state / rhs_into indices; a read-only block (declared but never committed) is allowed; a double commit and a commit of an undeclared block are rejected; the SIMULTANEOUS - multi-target solve_fields_from_blocks lowers to ctx.solve_fields_from_blocks (Spec 3 crit 24). + multi-target solve_fields_from_blocks lowers to ctx.solve_fields_from_blocks_at + (Spec 3 crit 24, ADC-759). (B) End-to-end parity (skips unless the full toolchain is present): a 2-block passive-transport model (a scalar with a non-trivial flux + a NAMED source_term, EMPTY default source -- avoids the @@ -85,9 +86,7 @@ def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") a = 0.7 # constant advection speed (x and y) - u = m.primitive("u", a + 0.0 * rho) - v = m.primitive("v", a + 0.0 * rho) - m.primitive_vars(rho=rho, u=u, v=v) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[a * rho], y=[a * rho]) # F = a*rho (linear advection) m.eigenvalues(x=[a + 0.0 * rho], y=[a + 0.0 * rho]) @@ -217,8 +216,8 @@ def section_a(t): ) src_c = _emit(Pc) chk( - "ctx.solve_fields_from_blocks(" in src_c, - "solve_fields_from_blocks lowers to the coupled multi-block solve", + "ctx.solve_fields_from_blocks_at(field_boundary_point_" in src_c, + "solve_fields_from_blocks lowers to the exact coupled multi-block solve", ) chk( "std::vector" not in src_c, diff --git a/tests/python/unit/time/test_time_multielliptic.py b/tests/python/unit/time/test_time_multielliptic.py index f7c6b82af..256bbf47f 100644 --- a/tests/python/unit/time/test_time_multielliptic.py +++ b/tests/python/unit/time/test_time_multielliptic.py @@ -193,7 +193,7 @@ def _block(m): u = m.primitive("u", mx / rho) v = m.primitive("v", my / rho) p = m.primitive("p", cs2 * rho) - m.primitive_vars(rho=rho, u=u, v=v, p=p) + m.primitive_vars(rho=rho, u=u, v=v) m.conservative_from([rho, rho * u, rho * v]) cs = sqrt(cs2) m.eigenvalues(x=[u - cs, u, u + cs], y=[v - cs, v, v + cs]) @@ -250,20 +250,22 @@ def _emit(program, *, model=None): program, model=model, field_plans=codegen_field_plans(program)) -# default solve_fields lowers to the 2-arg ctx call (historical), named to the 3-arg ctx call. +# Every single-state solve lowers through the same point/provider-qualified route. default_codegen_model = default_model() src_default = _emit(_prog("me_def_prog", model=default_codegen_model), model=default_codegen_model) -chk('ctx.solve_fields_from_state("potential", 0, ' in src_default, - "default solve_fields lowers to its qualified potential provider") -chk('ctx.solve_fields_from_state("phi2", 0, ' not in src_default, +chk('ctx.solve_fields_from_state_at(field_boundary_point_' in src_default + and '"potential", 0, ' in src_default, + "default solve_fields lowers to its exact point-qualified potential provider") +chk('"phi2", 0, ' not in src_default, "default solve_fields does NOT use the named phi2 overload") named_codegen_model = named_model() src_named = _emit(_prog("me_nam_prog", field="phi2", model=named_codegen_model), model=named_codegen_model) -chk('ctx.solve_fields_from_state("phi2", 0, ' in src_named, - "named solve_fields lowers to ctx.solve_fields_from_state(\"phi2\", 0, ...)") +chk('ctx.solve_fields_from_state_at(field_boundary_point_' in src_named + and '"phi2", 0, ' in src_named, + "named solve_fields lowers to the exact point-qualified phi2 provider") # The named brick + registration land in the native loader (production backend). loader = named_model("me_nam_loader")._m.emit_cpp_native_loader(target="system") diff --git a/tests/python/unit/time/test_time_ops_polish.py b/tests/python/unit/time/test_time_ops_polish.py index 2f6135e96..788566df8 100644 --- a/tests/python/unit/time/test_time_ops_polish.py +++ b/tests/python/unit/time/test_time_ops_polish.py @@ -33,13 +33,19 @@ from pops.codegen.program_codegen import emit_cpp_program from pops.domain import Rectangle from pops.frames import Cartesian2D +from pops.identity import make_identity from pops.layouts import Uniform from pops.math import ddt, div, sqrt from pops.mesh import CartesianGrid, PeriodicAxes from pops.numerics import DiscretizationPlan, reconstruction, riemann, variables from pops.numerics.spatial import FiniteVolume from pops.numerics.terms import DefaultSource, Flux -from pops.time import FixedDt +from pops.output._balance_due_contract import ( + BalanceDueConsumer, + BalanceDueContract, + BalanceDueRoute, +) +from pops.time import FixedDt, every, every_dt, when from typed_program_support import typed_state @@ -53,6 +59,25 @@ def t(): return time +def _balance_due_contract(route, *schedules, automatic_terms=()): + return BalanceDueContract( + make_identity("consumer-graph", {"test": "balance-due"}), + ( + BalanceDueRoute( + route, + tuple( + BalanceDueConsumer( + make_identity("consumer-manifest", {"index": index}), + schedule, + ) + for index, schedule in enumerate(schedules) + ), + automatic_terms, + ), + ), + ) + + # ---- (A.1) solve_local_nonlinear (op 10): the per-cell Newton builder (ADC-422) ---- def test_solve_local_nonlinear_validates_inputs(t): from pops.solvers.nonlinear import LocalNewton @@ -307,6 +332,274 @@ def test_record_scalar_rejects_non_scalar_and_bad_name(t): raise AssertionError("record_scalar must reject an empty name") +def test_record_balance_emits_exact_five_term_native_attempt_mailbox(t): + from pops._balance_contract import BalanceLedger as CoreBalanceLedger + from pops.diagnostics import BalanceLedger + from pops.diagnostics.balance import BALANCE_TERM_NAMES, balance_record_name + + assert BalanceLedger is CoreBalanceLedger + + P = t.Program("p") + U = typed_state(P, "blk") + total = P.sum(U) + ledger = BalanceLedger("mass") + records = P.record_balance( + ledger, + storage_change=total, + outward_boundary_flux=total * 2.0, + sources=total * 3.0, + reflux=total * 0.0, + projection=total * 0.0, + ) + route = ledger.route_identity(U.block) + assert tuple(record.attrs["diagnostic"] for record in records) == tuple( + balance_record_name(route, term) for term in BALANCE_TERM_NAMES + ) + endpoint = typed_state(P, "blk", state_name="U").next + P.commit(endpoint, P.value("balance_next", U, at=endpoint.point)) + contract = _balance_due_contract(route, every(3, clock=P.clock)) + source = emit_cpp_program(P, balance_due_contract=contract) + assert source.count("ctx.record_balance_term(") == 5 + assert source.count("ctx.balance_consumer_is_due(") == 1 + assert source.count("ctx.note_automatic_balance_capture_due(") == 1 + assert source.index("ctx.note_automatic_balance_capture_due(") < source.index( + "ctx.record_balance_term(" + ) + assert '"%s", 3)' % route.token in source + assert "? (ctx.sum_component(" in source + assert "ctx.record_scalar(" not in source + assert route.token in source + + unreachable_source = emit_cpp_program( + P, + balance_due_contract=_balance_due_contract(route, every(1 << 31, clock=P.clock)), + ) + assert "2147483648" not in unreachable_source + assert "ctx.balance_consumer_is_due(" not in unreachable_source + assert "ctx.note_automatic_balance_capture_due(" not in unreachable_source + + +def test_record_balance_delegates_selected_native_terms_without_placeholders(t): + from pops.diagnostics import BalanceLedger + + P = t.Program("native-balance-terms") + U = typed_state(P, "blk") + total = P.sum(U) + ledger = BalanceLedger( + "mass-native", automatic_terms=("projection", "reflux") + ) + records = P.record_balance( + ledger, + storage_change=total, + outward_boundary_flux=total * 2.0, + sources=total * 3.0, + ) + route = ledger.route_identity(U.block) + assert tuple(record.attrs["term"] for record in records) == ( + "storage_change", + "outward_boundary_flux", + "sources", + ) + endpoint = typed_state(P, "blk", state_name="U").next + P.commit(endpoint, P.value("balance_next", U, at=endpoint.point)) + contract = _balance_due_contract( + route, + every(2, clock=P.clock), + automatic_terms=("projection", "reflux"), + ) + source = emit_cpp_program(P, balance_due_contract=contract) + assert source.count("ctx.record_balance_term(") == 3 + assert source.count("ctx.note_automatic_balance_capture_due(") == 1 + + P_bad = t.Program("duplicate-native-balance-term") + U_bad = typed_state(P_bad, "blk") + total_bad = P_bad.sum(U_bad) + with pytest.raises(ValueError, match="owned by.*native automatic producer"): + P_bad.record_balance( + ledger, + storage_change=total_bad, + outward_boundary_flux=total_bad, + sources=total_bad, + projection=total_bad, + ) + + component_ledger = BalanceLedger( + "component-one", + component=1, + automatic_terms=("projection",), + ) + with pytest.raises(ValueError, match="selects components.*component 1"): + P_bad.record_balance( + component_ledger, + storage_change=total_bad, + outward_boundary_flux=total_bad, + sources=total_bad, + reflux=total_bad, + ) + + +def test_balance_due_contract_unions_consumers_and_ignores_static_false(t): + from pops.diagnostics import BalanceLedger + + P = t.Program("balance-due-contract") + U = typed_state(P, "blk") + route = BalanceLedger("mass").route_identity(U.block) + contract = _balance_due_contract( + route, + every(5, clock=P.clock), + when(False, clock=P.clock), + every(3, clock=P.clock), + ) + + assert contract.route(route.token).accepted_step_periods() == (3, 5) + false_only = _balance_due_contract(route, when(False, clock=P.clock)) + assert false_only.route(route.token).accepted_step_periods() == () + + native_boundary = _balance_due_contract( + route, + every((1 << 31) - 1, clock=P.clock), + every(1 << 31, clock=P.clock), + ) + assert native_boundary.route(route.token).accepted_step_periods() == ((1 << 31) - 1,) + + +def test_record_balance_elides_native_collectives_without_a_consumer(t): + from pops.diagnostics import BalanceLedger + + P = t.Program("balance-without-consumer") + U = typed_state(P, "blk") + total = P.sum(U) + P.record_balance( + BalanceLedger("mass"), + storage_change=total, + outward_boundary_flux=total, + sources=total, + reflux=total, + projection=total, + ) + endpoint = typed_state(P, "blk", state_name="U").next + P.commit(endpoint, P.value("balance_next", U, at=endpoint.point)) + + source = emit_cpp_program(P) + + assert "ctx.balance_consumer_is_due(" not in source + assert "ctx.note_automatic_balance_capture_due(" not in source + assert "ctx.record_balance_term(" not in source + assert "(false) ? (ctx.sum_component(" in source + + +def test_record_balance_keeps_a_shared_reduction_unconditional(t): + from pops.diagnostics import BalanceLedger + + P = t.Program("balance-shared-reduction") + U = typed_state(P, "blk") + total = P.sum(U) + ledger = BalanceLedger("mass") + P.record_balance( + ledger, + storage_change=total, + outward_boundary_flux=total, + sources=total, + reflux=total, + projection=total, + ) + P.record_scalar("mass", total) + endpoint = typed_state(P, "blk", state_name="U").next + P.commit(endpoint, P.value("balance_next", U, at=endpoint.point)) + route = ledger.route_identity(U.block) + + source = emit_cpp_program( + P, + balance_due_contract=_balance_due_contract(route, every(4, clock=P.clock)), + ) + reduction_line = next(line for line in source.splitlines() if "ctx.sum_component(" in line) + + assert "? (ctx.sum_component(" not in reduction_line + assert source.count("ctx.record_balance_term(") == 5 + assert 'ctx.record_scalar("mass"' in source + + +def test_record_balance_physical_time_cadence_stays_conservatively_due(t): + from pops.diagnostics import BalanceLedger + + P = t.Program("balance-physical-cadence") + U = typed_state(P, "blk") + total = P.sum(U) + ledger = BalanceLedger("mass") + P.record_balance( + ledger, + storage_change=total, + outward_boundary_flux=total, + sources=total, + reflux=total, + projection=total, + ) + endpoint = typed_state(P, "blk", state_name="U").next + P.commit(endpoint, P.value("balance_next", U, at=endpoint.point)) + route = ledger.route_identity(U.block) + + source = emit_cpp_program( + P, + balance_due_contract=_balance_due_contract(route, every_dt(0.1, clock=P.clock)), + ) + + assert source.count("ctx.balance_consumer_is_due(") == 1 + assert source.count("ctx.note_automatic_balance_capture_due(") == 1 + assert '"%s", 1)' % route.token in source + assert source.count("ctx.record_balance_term(") == 5 + + +def test_balance_consumer_without_a_program_producer_fails_before_codegen(t): + from pops.codegen.program_balance_due import validate_balance_due_contract + from pops.diagnostics import BalanceLedger + + P = t.Program("balance-missing-producer") + U = typed_state(P, "blk") + endpoint = typed_state(P, "blk", state_name="U").next + P.commit(endpoint, P.value("balance_next", U, at=endpoint.point)) + route = BalanceLedger("mass").route_identity(U.block) + contract = _balance_due_contract(route, every(2, clock=P.clock)) + + with pytest.raises( + ValueError, + match="Balance routes have no Program.record_balance producer", + ): + validate_balance_due_contract(P, contract) + + +def test_record_balance_rejects_non_reduced_or_incomplete_evidence(t): + from pops.diagnostics import BalanceLedger + + P = t.Program("p") + U = typed_state(P, "blk") + total = P.sum(U) + for forged in ( + "pops.balance-term", + "pops.balance-term.v1", + "pops.balance-term.v1:forged", + ): + with pytest.raises(ValueError, match="reserved for Program.record_balance"): + P.record_scalar(forged, total) + with pytest.raises(ValueError, match="global reduction"): + P.record_balance( + BalanceLedger("mass"), + storage_change=P.max_wave_speed(U), + outward_boundary_flux=total, + sources=total, + reflux=total, + projection=total, + ) + with pytest.raises(ValueError, match="additive sum/dot reductions"): + P.record_balance( + BalanceLedger("mass"), + storage_change=P.max(U), + outward_boundary_flux=total, + sources=total, + reflux=total, + projection=total, + ) + + # ---- (A.5) IR hash sensitivity ---- def test_ir_hash_distinguishes_new_ops(t): def _h(build): diff --git a/tests/python/unit/time/test_time_rhs_jacvec_contract.py b/tests/python/unit/time/test_time_rhs_jacvec_contract.py index a8b7a6522..374c0fe67 100644 --- a/tests/python/unit/time/test_time_rhs_jacvec_contract.py +++ b/tests/python/unit/time/test_time_rhs_jacvec_contract.py @@ -129,7 +129,6 @@ def test_recursive_ir_exposes_field_coupled_jacvec_to_the_amr_capability_gate(): field_routes_validated=True, ) assert amr_program_op_support(program, context=context) == { - "fine_level_field_perturbation": "pending", "named_field_solve": "green", } diff --git a/tests/python/unit/time/test_time_solve_fields_from_state.py b/tests/python/unit/time/test_time_solve_fields_from_state.py index e129a6cb0..c83c94ed7 100644 --- a/tests/python/unit/time/test_time_solve_fields_from_state.py +++ b/tests/python/unit/time/test_time_solve_fields_from_state.py @@ -2,12 +2,13 @@ """Per-stage elliptic field solve in the final public runtime (ADC-409). Each consumed callable ``FieldHandle(U_stage)`` now lowers to -``ctx.solve_fields_from_state(0, )``: +``ctx.solve_fields_from_state_at(point, provider, 0, )``: the elliptic fields are re-solved -- and the shared aux re-filled -- from THAT stage's state, not the -block's current state. So a field-COUPLED multi-stage scheme (Poisson feedback into the RHS) is exact: -stage k's RHS reads phi solved from stage k's own state. The compiled Program runs the stages -sequentially, so stage k's solve overwrites the shared aux before stage k's RHS reads it -- no distinct -per-stage FieldContext buffer is needed. +block's current state. The point carries the exact active AMR level and logical stage time, while the +provider slot is owner-qualified. So a field-COUPLED multi-stage scheme (Poisson feedback into the +RHS) is exact: stage k's RHS reads phi solved from stage k's own state. The compiled Program runs the +stages sequentially, so stage k's solve overwrites the shared aux before stage k's RHS reads it -- no +distinct per-stage FieldContext buffer is needed. (A) Public IR/provenance: the detached compiled Program records two field solves with distinct state inputs. The second solve consumes ``U1`` and the second RHS consumes both ``U1`` and the diff --git a/tests/python/unit/time/test_time_solve_linear.py b/tests/python/unit/time/test_time_solve_linear.py index c111bd68d..df131a08a 100644 --- a/tests/python/unit/time/test_time_solve_linear.py +++ b/tests/python/unit/time/test_time_solve_linear.py @@ -415,8 +415,7 @@ def test_native_compiled_cg_matches_offline_periodic_helmholtz(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) @@ -506,8 +505,7 @@ def test_native_gmres_geometric_mg_matches_offline_periodic_helmholtz(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_std_rk.py b/tests/python/unit/time/test_time_std_rk.py index e34ecb616..d2a3b9f05 100644 --- a/tests/python/unit/time/test_time_std_rk.py +++ b/tests/python/unit/time/test_time_std_rk.py @@ -127,8 +127,7 @@ def _passive_model(name): from pops.physics._facade import Model model = Model(name) (rho,) = model.conservative_vars("rho") - velocity = model.primitive("u", 0.0 * rho) - model.primitive_vars(rho=rho, u=velocity) + model.primitive_vars(rho) model.conservative_from([rho]) model.flux(x=[0.0 * rho], y=[0.0 * rho]) model.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/python/unit/time/test_time_where.py b/tests/python/unit/time/test_time_where.py index 09dce1b43..512d1d9da 100644 --- a/tests/python/unit/time/test_time_where.py +++ b/tests/python/unit/time/test_time_where.py @@ -198,8 +198,7 @@ def _run_section_b(t): def passive_model(name): m = Model(name) (rho,) = m.conservative_vars("rho") - u = m.primitive("u", 0.0 * rho) # passive advection at speed 0 (the Program never runs a rhs) - m.primitive_vars(rho=rho, u=u) + m.primitive_vars(rho) m.conservative_from([rho]) m.flux(x=[0.0 * rho], y=[0.0 * rho]) m.eigenvalues(x=[0.0 * rho], y=[0.0 * rho]) diff --git a/tests/test_manifest.toml b/tests/test_manifest.toml index 3b55de01e..194f7c506 100644 --- a/tests/test_manifest.toml +++ b/tests/test_manifest.toml @@ -165,12 +165,30 @@ sources = ["tests/cpp/integration/mpi/test_mpi_amr_dynamic_active_depth.cpp"] labels = ["backend", "mpi", "medium"] mpi_nproc = [1, 2, 4] +[[cpp.suite]] +name = "test_mpi_cell_temporal_program_refusal" +sources = ["tests/cpp/integration/mpi/test_mpi_cell_temporal_program_refusal.cpp"] +labels = ["backend", "mpi", "medium"] +mpi_nproc = [2] + +[[cpp.suite]] +name = "test_mpi_amr_prepared_boundary_cf" +sources = ["tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp"] +labels = ["backend", "mpi", "medium"] +mpi_nproc = [1, 2, 4] + [[cpp.suite]] name = "test_mpi_amr_program_reflux" sources = ["tests/cpp/integration/mpi/test_mpi_amr_program_reflux.cpp"] labels = ["backend", "mpi", "medium"] mpi_nproc = [2, 4] +[[cpp.suite]] +name = "test_mpi_amr_rebalance_migration" +sources = ["tests/cpp/integration/mpi/test_mpi_amr_rebalance_migration.cpp"] +labels = ["backend", "mpi", "amr", "medium"] +mpi_nproc = [2, 4] + [[cpp.suite]] name = "test_mpi_amr_twoblock_parity" sources = ["tests/cpp/integration/mpi/test_mpi_amr_twoblock_parity.cpp"] @@ -807,6 +825,11 @@ name = "test_geometry" sources = ["tests/cpp/unit/mesh/test_geometry.cpp"] labels = ["unit", "mesh", "fast"] +[[cpp.suite]] +name = "test_nd_metric_provider" +sources = ["tests/cpp/unit/mesh/test_nd_metric_provider.cpp"] +labels = ["unit", "mesh", "geometry", "fast"] + [[cpp.suite]] name = "test_load_balance" sources = ["tests/cpp/unit/mesh/test_load_balance.cpp"] @@ -817,6 +840,78 @@ name = "test_multifab" sources = ["tests/cpp/unit/mesh/test_multifab.cpp"] labels = ["unit", "mesh", "fast"] +[[cpp.suite]] +name = "test_nd_boundary_schedule" +sources = ["tests/cpp/unit/mesh/test_nd_boundary_schedule.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_cluster" +sources = ["tests/cpp/unit/mesh/test_nd_cluster.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_distribution" +sources = ["tests/cpp/unit/mesh/test_nd_distribution.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_execution" +sources = ["tests/cpp/unit/mesh/test_nd_execution.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_finite_volume" +sources = ["tests/cpp/unit/numerics/test_nd_finite_volume.cpp"] +labels = ["unit", "numerics", "spatial", "fast"] + +[[cpp.suite]] +name = "test_nd_flux_ledger" +sources = ["tests/cpp/unit/amr/test_nd_flux_ledger.cpp"] +labels = ["unit", "amr", "numerics", "fast"] + +[[cpp.suite]] +name = "test_nd_hierarchy_plan" +sources = ["tests/cpp/unit/mesh/test_nd_hierarchy_plan.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_layout" +sources = ["tests/cpp/unit/mesh/test_nd_layout.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_tag_mask" +sources = ["tests/cpp/unit/mesh/test_nd_tag_mask.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_topology" +sources = ["tests/cpp/unit/mesh/test_nd_topology.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_transfer" +sources = ["tests/cpp/unit/amr/test_nd_transfer.cpp"] +labels = ["unit", "amr", "mesh", "fast"] + +[[cpp.suite]] +name = "test_nd_translation_schedule" +sources = ["tests/cpp/unit/mesh/test_nd_translation_schedule.cpp"] +labels = ["unit", "mesh", "fast"] + +[[cpp.suite]] +name = "test_mpi_nd_translation_completion_failstop" +sources = ["tests/cpp/integration/mpi/test_mpi_nd_translation_completion_failstop.cpp"] +labels = ["backend", "mpi", "medium"] +mpi_nproc = [1] + +[[cpp.suite]] +name = "test_mpi_nd_translation_exchange" +sources = ["tests/cpp/integration/mpi/test_mpi_nd_translation_exchange.cpp"] +labels = ["backend", "mpi", "medium"] +mpi_nproc = [1, 2, 4] + [[cpp.suite]] name = "test_patch_range" sources = ["tests/cpp/unit/mesh/test_patch_range.cpp"] @@ -882,6 +977,16 @@ name = "test_positivity_floor" sources = ["tests/cpp/unit/numerics/test_positivity_floor.cpp"] labels = ["unit", "numerics", "fast"] +[[cpp.suite]] +name = "test_prepared_cartesian_nd" +sources = ["tests/cpp/unit/numerics/test_prepared_cartesian_nd.cpp"] +labels = ["unit", "numerics", "fast"] + +[[cpp.suite]] +name = "test_prepared_numerics_gate" +sources = ["tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp"] +labels = ["unit", "numerics", "fast"] + [[cpp.suite]] name = "test_primitive_recon" sources = ["tests/cpp/unit/numerics/test_primitive_recon.cpp"] @@ -897,11 +1002,21 @@ name = "test_roe_flux" sources = ["tests/cpp/unit/numerics/test_roe_flux.cpp"] labels = ["unit", "numerics", "fast"] +[[cpp.suite]] +name = "test_spatial_provider_matrix" +sources = ["tests/cpp/unit/numerics/test_spatial_provider_matrix.cpp"] +labels = ["unit", "numerics", "fast"] + [[cpp.suite]] name = "test_splitting" sources = ["tests/cpp/unit/numerics/test_splitting.cpp"] labels = ["unit", "numerics", "fast"] +[[cpp.suite]] +name = "test_variable_recovery_chain" +sources = ["tests/cpp/unit/numerics/test_variable_recovery_chain.cpp"] +labels = ["unit", "numerics", "fast"] + [[cpp.suite]] name = "test_weno5_ssprk3" sources = ["tests/cpp/unit/numerics/test_weno5_ssprk3.cpp"] @@ -1129,11 +1244,31 @@ name = "test_prepared_boundary_plan" sources = ["tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp"] labels = ["unit", "mesh", "fast"] +[[cpp.suite]] +name = "test_prepared_stream_executor" +sources = ["tests/cpp/unit/runtime/test_prepared_stream_executor.cpp"] +labels = ["unit", "runtime", "accelerator", "fast"] + [[cpp.suite]] name = "test_program_reflux_ledger" sources = ["tests/cpp/integration/amr/test_program_reflux_ledger.cpp"] labels = ["integration", "amr", "medium"] +[[cpp.suite]] +name = "test_temporal_partition_restart" +sources = ["tests/cpp/integration/amr/test_temporal_partition_restart.cpp"] +labels = ["integration", "runtime", "amr", "medium"] + +[[cpp.suite]] +name = "test_cell_temporal_partition_executor" +sources = ["tests/cpp/integration/amr/test_cell_temporal_partition_executor.cpp"] +labels = ["integration", "runtime", "amr", "medium"] + +[[cpp.suite]] +name = "test_cell_temporal_program_route" +sources = ["tests/cpp/integration/amr/test_cell_temporal_program_route.cpp"] +labels = ["integration", "runtime", "amr", "medium"] + [[cpp.suite]] name = "test_residual_operator" sources = ["tests/cpp/unit/runtime/test_residual_operator.cpp"] @@ -1181,6 +1316,8 @@ mpi_entrypoints = [ { path = "tests/python/integration/mpi/test_amr_history_mpi.py", nproc = 2 }, { path = "tests/python/integration/mpi/test_amr_nonlinear_collective_mpi.py", nproc = 2 }, { path = "tests/python/integration/mpi/test_amr_regrid_on_restart_mpi.py", nproc = 2 }, + { path = "tests/python/integration/mpi/test_async_balance_cadence_mpi.py", nproc = 2 }, + { path = "tests/python/integration/mpi/test_external_amr_field_solver_mpi.py", nproc = 2 }, { path = "tests/python/integration/mpi/test_scientific_output_mpi.py", nproc = 2 }, { path = "tests/python/integration/mpi/test_uniform_history_checkpoint_mpi.py", nproc = 2 }, ]