From 9564f201987d890e142d1a867cc04a8005b233a4 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 03:08:30 +0200 Subject: [PATCH 01/93] refactor(boundary): prepare model-aware hyperbolic faces --- .../mesh/boundary/prepared_boundary_plan.hpp | 115 +--- .../boundary/prepared_hyperbolic_boundary.hpp | 560 ++++++++++++++++++ include/pops/runtime/amr_system.hpp | 4 +- include/pops/runtime/system.hpp | 8 +- include/pops_headers.manifest | 1 + python/bindings/core/init/init_amr.cpp | 10 +- python/bindings/core/init/init_system.cpp | 10 +- python/pops/boundary/__init__.py | 2 + python/pops/boundary/transport.py | 102 +++- python/pops/codegen/module_lowering.py | 36 +- python/pops/physics/__init__.py | 5 +- python/pops/physics/roles.py | 23 +- python/pops/runtime/_runtime_authorities.py | 16 +- src/runtime/amr/amr_system.cpp | 42 +- src/runtime/system/system_install.cpp | 201 +++---- .../amr/test_amr_transfer_properties.cpp | 9 +- .../native_loader/test_amr_native_loader.cpp | 13 +- .../test_multiblock_interface_scheduler.cpp | 8 +- .../unit/mesh/test_prepared_boundary_plan.cpp | 157 ++--- .../runtime/test_program_context_contract.cpp | 11 +- .../unit/boundary/test_transport_authoring.py | 67 ++- .../unit/codegen/test_module_lowering.py | 13 +- ...est_boundary_component_prepare_contract.py | 10 +- 23 files changed, 1047 insertions(+), 376 deletions(-) create mode 100644 include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index f17d2194f..13746f6da 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -17,7 +17,7 @@ #include #include -#include +#include #include #include @@ -85,9 +85,8 @@ 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. +/// 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 { public: /// Move-only, lane-bound executable state for this immutable plan. @@ -179,12 +178,13 @@ class PreparedBoundaryPlan { 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 = {}) : 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)) { for (const int face : omitted_face_ordinals) { @@ -199,7 +199,8 @@ class PreparedBoundaryPlan { 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_; } /// 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); } @@ -213,22 +214,6 @@ class PreparedBoundaryPlan { 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)); @@ -254,9 +239,9 @@ class PreparedBoundaryPlan { return !ghost_components_.empty() || !residual_components_.empty() || !jvp_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 @@ -387,18 +372,7 @@ class PreparedBoundaryPlan { return result; } - Periodicity periodicity() const { - validate_topology(); - const BCRec& bc = component_bc_.front(); - return Periodicity{bc.xlo == BCType::Periodic, bc.ylo == BCType::Periodic}; - } - - 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; - }); - } + Periodicity periodicity() const { return hyperbolic_boundary_.periodicity(); } /// Same-level/MPI and axis-aligned periodic production are performed by the memoized native halo /// schedule. Physical data is then applied per component. AmrRuntime executes the resolved @@ -407,13 +381,9 @@ 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_boundary(state, domain, periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, component_bc(comp), comp); + hyperbolic_boundary_.fill_physical(state, domain); } void fill_same_level_and_physical(MultiFab& state, const Box2D& domain, @@ -421,13 +391,9 @@ 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_boundary(state, domain, lane, periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, component_bc(comp), comp); + hyperbolic_boundary_.fill_physical(state, domain); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry) const { @@ -436,8 +402,7 @@ class PreparedBoundaryPlan { "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); fill_boundary(state, geometry.domain, periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, component_bc(comp, geometry), comp); + hyperbolic_boundary_.fill_physical(state, geometry.domain); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry, @@ -447,8 +412,7 @@ class PreparedBoundaryPlan { "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); fill_boundary(state, geometry.domain, lane, periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, component_bc(comp, geometry), comp); + hyperbolic_boundary_.fill_physical(state, geometry.domain); } /// One-shot control/diagnostic adapter. It materializes a fresh component session and workspace; @@ -563,7 +527,7 @@ 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_; @@ -654,20 +618,16 @@ 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}; - } - 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(); } static void validate_read_dependencies_(const std::vector& identities, @@ -685,26 +645,6 @@ 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"); - } - } - if ((expected[0] == BCType::Periodic) != (expected[1] == BCType::Periodic) || - (expected[2] == BCType::Periodic) != (expected[3] == BCType::Periodic)) - throw std::runtime_error( - "axis-aligned PreparedBoundaryPlan requires periodic faces in complete axis pairs"); - } - void validate_for(const MultiFab& state) const { if (state.ncomp() != ncomp()) throw std::runtime_error("PreparedBoundaryPlan component count does not match block state"); @@ -742,13 +682,9 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical(MultiFab 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); fill_boundary(state, domain, *lane_, plan_->periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, domain, plan_->component_bc(comp), comp); + plan_->hyperbolic_boundary_.fill_physical(state, domain); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( @@ -759,8 +695,7 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); plan_->validate_for(state); fill_boundary(state, geometry.domain, *lane_, plan_->periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( @@ -769,8 +704,7 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( validate_current_(); plan_->validate_for(state); fill_boundary(state, geometry.domain, *lane_, plan_->periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); detail::BoundaryFieldRegistry fields; fields.configure_states(plan_->required_state_identities()); fields.configure_fields(plan_->required_field_identities()); @@ -801,8 +735,7 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( validate_current_(); plan_->validate_for(state); fill_boundary(state, geometry.domain, *lane_, plan_->periodicity()); - for (int comp = 0; comp < state.ncomp(); ++comp) - fill_physical_bc(state, geometry.domain, plan_->component_bc(comp, geometry), comp); + plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); if (ghost_workspaces_.size() != ghost_components_.size()) throw std::logic_error( "PreparedBoundaryPlan ghost executor was not materialized before numerical execution"); 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..04c50b019 --- /dev/null +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -0,0 +1,560 @@ +/// @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 + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { + +enum class HyperbolicBoundaryLaw { Periodic, Extrapolate, FixedState, ReflectiveSlip, External }; + +enum class HyperbolicComponentParity { Scalar, PolarVector, AxialVector }; + +/// 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 declaration metadata, never inferred from the component index. +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 >= Dim) + throw std::invalid_argument("polar boundary component axis is outside the model dimension"); + return {HyperbolicComponentParity::PolarVector, component_axis}; + } + static HyperbolicComponentTransform axial_vector(int component_axis) { + if (component_axis < 0 || component_axis >= Dim) + throw std::invalid_argument("axial boundary component axis is outside the model dimension"); + 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; +}; + +/// 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) { + 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::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::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) + 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; + } + } +}; + +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") { + if constexpr (Dim < 2) + throw std::invalid_argument("model role references the absent y axis"); + return HyperbolicComponentTransform::polar_vector(1); + } + if (role == "MomentumZ" || role == "VelocityZ") { + if constexpr (Dim < 3) + throw std::invalid_argument("model role references the absent z axis"); + return HyperbolicComponentTransform::polar_vector(2); + } + if (role == "AxialX") + return HyperbolicComponentTransform::axial_vector(0); + if (role == "AxialY") { + if constexpr (Dim < 2) + throw std::invalid_argument("axial model role references the absent y axis"); + return HyperbolicComponentTransform::axial_vector(1); + } + if (role == "AxialZ") { + if constexpr (Dim < 3) + throw std::invalid_argument("axial model role references the absent z axis"); + 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 == "slip_wall") + return HyperbolicBoundaryLaw::ReflectiveSlip; + if (token == "external") + return HyperbolicBoundaryLaw::External; + throw std::invalid_argument("unsupported prepared hyperbolic face law '" + std::string(token) + + "'"); +} + +} // namespace detail + +template +class PreparedHyperbolicBoundary { + public: + static_assert(Dim >= 1 && Dim <= 3); + using Transform = HyperbolicComponentTransform; + + PreparedHyperbolicBoundary() = default; + + PreparedHyperbolicBoundary( + std::array faces, + std::vector component_transforms, + HyperbolicCornerPolicy corner_policy = HyperbolicCornerPolicy::NotRequired) + : faces_(std::move(faces)), + component_transforms_(std::move(component_transforms)), + corner_policy_(corner_policy) { + validate(); + 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_; } + + Periodicity periodicity() const { + static_assert(Dim == 2, "the current MultiFab topology is two-dimensional"); + return Periodicity{ + faces_[0].law == HyperbolicBoundaryLaw::Periodic, + faces_[2].law == HyperbolicBoundaryLaw::Periodic, + }; + } + + /// 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 { + static_assert(Dim == 2, "the current MultiFab storage is two-dimensional"); + 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; + 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); + } + } + + 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]) + for_each_cell( + Box2D{{domain.lo[0] - depth, tangential_lo}, {domain.lo[0] - 1, tangential_hi}}, + detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], + faces_[0].law, faces_[1].law}); + if (detail::is_physical_hyperbolic_law(faces_[1].law) && valid.hi[0] == domain.hi[0]) + for_each_cell( + Box2D{{domain.hi[0] + 1, tangential_lo}, {domain.hi[0] + depth, tangential_hi}}, + detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], + faces_[0].law, faces_[1].law}); + + 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]) + for_each_cell( + Box2D{{tangential_lo, domain.lo[1] - depth}, {tangential_hi, domain.lo[1] - 1}}, + detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], + faces_[2].law, faces_[3].law}); + if (detail::is_physical_hyperbolic_law(faces_[3].law) && valid.hi[1] == domain.hi[1]) + for_each_cell( + Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}, + detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], + faces_[2].law, faces_[3].law}); + } + } + + private: + std::array faces_{}; + std::vector component_transforms_; + HyperbolicCornerPolicy corner_policy_ = HyperbolicCornerPolicy::NotRequired; +#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 + + void validate() 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 ((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.law == HyperbolicBoundaryLaw::FixedState) { + 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"); + } else if (!prepared_face.fixed_state.empty()) { + throw std::invalid_argument( + "only a fixed-state hyperbolic boundary may carry component values"); + } + 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) { + 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"); + + std::vector> transforms; + transforms.reserve(component_roles.size()); + for (const auto& role : component_roles) + transforms.push_back(detail::transform_from_role(role)); + + 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); + if (destination.law == HyperbolicBoundaryLaw::FixedState) { + 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)])); + } + } + return PreparedHyperbolicBoundary(std::move(faces), std::move(transforms), + HyperbolicCornerPolicy::NotRequired); +} + +} // namespace pops diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 229a951e0..8f8e9464a 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -356,7 +356,9 @@ 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 = {}); diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index d3352a648..3a17a98bb 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -320,12 +320,14 @@ 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 = {}); diff --git a/include/pops_headers.manifest b/include/pops_headers.manifest index 64d4ad1d6..4935f38ec 100644 --- a/include/pops_headers.manifest +++ b/include/pops_headers.manifest @@ -52,6 +52,7 @@ api pops/mesh/boundary/halo_schedule.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/geometry.hpp api pops/mesh/index/box2d.hpp diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index aa46f3123..0256312cc 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -236,14 +236,16 @@ 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) { system.install_boundary_plan(name, identity, required_depth, face_types, face_values, - ncomp, omitted_interface_faces, state_identity, - PreparedBoundaryReadDependencies{}); + face_identities, component_roles, omitted_interface_faces, + state_identity, PreparedBoundaryReadDependencies{}); }, 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{}, "Install one resolved per-block ghost-production plan before lazy AMR construction.") diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index 32f7b6f04..86e2c7b1c 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -171,14 +171,16 @@ 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) { system.install_boundary_plan(name, identity, required_depth, face_types, face_values, - ncomp, omitted_interface_faces, state_identity, - PreparedBoundaryReadDependencies{}); + face_identities, component_roles, omitted_interface_faces, + state_identity, PreparedBoundaryReadDependencies{}); }, 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{}, "Install one resolved per-block ghost-production plan before block construction.") diff --git a/python/pops/boundary/__init__.py b/python/pops/boundary/__init__.py index cadd68d55..867f08406 100644 --- a/python/pops/boundary/__init__.py +++ b/python/pops/boundary/__init__.py @@ -7,6 +7,7 @@ from .transport import ( BoundaryStencilRequirement, + SlipWall, TransportBoundarySet, ) from .embedded import EmbeddedBoundaryFlux, ZeroFlux @@ -14,6 +15,7 @@ __all__ = [ "BoundaryStencilRequirement", "EmbeddedBoundaryFlux", + "SlipWall", "TransportBoundarySet", "ZeroFlux", ] diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index f466f720f..186d30363 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -211,7 +211,7 @@ def __post_init__(self) -> None: 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", "slip_wall"}: raise ValueError("unsupported built-in transport condition type") _state(self.state, where="ResolvedTransportCondition.state") if not self.state.is_resolved: @@ -248,6 +248,7 @@ def _resolved_condition( ) -> ResolvedTransportCondition: from pops.mesh.boundaries import ( BoundaryDependencies, + GhostFormula, GhostState, Inflow as LowLevelInflow, Outflow as LowLevelOutflow, @@ -275,7 +276,11 @@ def _resolved_condition( characteristic=_closure(), ) output = GhostState(boundary=boundary, subject=state, representation=target) - factory = LowLevelInflow if condition_type == "inflow" else LowLevelOutflow + factory = { + "inflow": LowLevelInflow, + "outflow": LowLevelOutflow, + "slip_wall": GhostFormula, + }[condition_type] provider = factory( handle=_provider_handle(state, geometry, condition_type), outputs=(output,), @@ -424,6 +429,86 @@ def resolve_condition( ) +@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") + roles = getattr(self.state.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 len(normal) != 1: + raise ValueError( + "SlipWall on %s requires exactly one 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 @@ -543,8 +628,11 @@ 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": { + "outflow": "foextrap", + "inflow": "dirichlet", + "slip_wall": "slip_wall", + }[row.condition_type], "values": ( [] if row.condition_type == "outflow" else [_expression_data(expression, qualified=True)["value"] @@ -578,9 +666,10 @@ 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 {"outflow", "slip_wall"}: values = [0.0] * ncomp - face_type = "foextrap" + face_type = ( + "foextrap" if condition.condition_type == "outflow" else "slip_wall") else: values = [] for index, expression in enumerate(condition.values): @@ -830,5 +919,6 @@ def labels(rows: Any) -> list[str]: "Outflow", "ResolvedTransportBoundarySet", "ResolvedTransportCondition", + "SlipWall", "TransportBoundarySet", ] diff --git a/python/pops/codegen/module_lowering.py b/python/pops/codegen/module_lowering.py index 56cce3234..a954ba5f6 100644 --- a/python/pops/codegen/module_lowering.py +++ b/python/pops/codegen/module_lowering.py @@ -30,6 +30,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` @@ -112,13 +142,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) 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/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/runtime/_runtime_authorities.py b/python/pops/runtime/_runtime_authorities.py index 85c077d11..1afaa2c3c 100644 --- a/python/pops/runtime/_runtime_authorities.py +++ b/python/pops/runtime/_runtime_authorities.py @@ -71,9 +71,20 @@ 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", "slip_wall", "external"} for value in types): raise NotImplementedError("prepared boundary plan selected an unavailable face producer") + 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 = [] for comp in range(ncomp): for row in faces: @@ -94,7 +105,8 @@ 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, ) diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index 93ebe68f0..55822fa19 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1396,7 +1396,9 @@ 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) { Impl* P = p_.get(); @@ -1410,42 +1412,10 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( 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 hyperbolic = + prepare_hyperbolic_boundary<2>(face_types, face_values, face_identities, component_roles); auto plan = std::make_shared(identity, required_depth, - std::move(components), omitted_interface_faces, + std::move(hyperbolic), omitted_interface_faces, state_identity, std::move(read_dependencies)); for (const auto& [_, installed] : P->boundary_plans_) if (installed->state_identity() == state_identity) diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index 1449e08f2..6b2737be1 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -289,44 +289,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,7 +308,9 @@ 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) { @@ -361,20 +325,10 @@ POPS_EXPORT void System::install_boundary_plan(const std::string& name, const st "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 hyperbolic = + prepare_hyperbolic_boundary<2>(face_types, face_values, face_identities, component_roles); auto plan = std::make_shared(identity, required_depth, - std::move(components), omitted_interface_faces, + std::move(hyperbolic), omitted_interface_faces, state_identity, std::move(read_dependencies)); for (const auto& [_, installed] : P->boundary_plans_) if (installed->state_identity() == state_identity) @@ -504,8 +458,7 @@ 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_)) + 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"); @@ -513,9 +466,8 @@ POPS_EXPORT void System::install_block(const std::string& name, int ncomp, 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"); + 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)}); @@ -681,12 +633,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); @@ -1109,10 +1063,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; @@ -1137,8 +1089,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); } }; @@ -1155,8 +1106,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; } @@ -1164,9 +1114,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; @@ -1191,8 +1140,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_) @@ -1207,9 +1155,9 @@ void System::set_analytic_level_set(const std::vector& opcodes, "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 + "'"); + 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()) @@ -1226,8 +1174,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; @@ -1241,28 +1188,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( @@ -1274,11 +1218,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( @@ -1297,8 +1240,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}}, @@ -1344,9 +1287,9 @@ void System::set_geometry_mode(const std::string& mode) { "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 + "'"); + 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()) @@ -1706,36 +1649,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/tests/cpp/integration/amr/test_amr_transfer_properties.cpp b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp index 1c3bbda1f..e6dd57720 100644 --- a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp +++ b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp @@ -102,8 +102,13 @@ AmrRuntime bootstrap_runtime(int cells = 8, bool install_prepared_boundary = fal 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 = 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 5aac6bdab..59502223d 100644 --- a/tests/cpp/integration/native_loader/test_amr_native_loader.cpp +++ b/tests/cpp/integration/native_loader/test_amr_native_loader.cpp @@ -1089,12 +1089,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 = diff --git a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp index fe9f39f99..1fb7ba310 100644 --- a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp +++ b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp @@ -532,8 +532,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/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index 8bb6942b6..f0934ad4b 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -18,14 +18,15 @@ 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); } PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, std::string output) { @@ -60,18 +61,18 @@ PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, s 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); } @@ -82,11 +83,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"); @@ -130,10 +131,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); @@ -144,6 +143,63 @@ 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, model_aware_slip_wall_reverses_only_normal_polar_component) { + 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) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(3); + values(i, j, 3) = Real(4); + }); + } + auto boundary = prepare_hyperbolic_boundary<2>( + {"slip_wall", "slip_wall", "foextrap", "foextrap"}, std::vector(16, 0.0), + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY", "Energy"}); + PreparedBoundaryPlan plan("case::fluid::slip-plan", 1, 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(3)); + EXPECT_EQ(field(-1, 2, 3), Real(4)); + EXPECT_EQ(field(2, -1, 1), Real(2)); + EXPECT_EQ(field(2, -1, 2), 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 axial_normal_2d = HyperbolicComponentTransform<2>::axial_vector(0); + const auto axial_tangent_2d = HyperbolicComponentTransform<2>::axial_vector(1); + EXPECT_EQ(polar_normal_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(polar_tangent_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_normal_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_tangent_2d.reflection_sign(0), 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); @@ -155,7 +211,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); @@ -166,65 +222,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); } @@ -234,8 +247,8 @@ TEST(test_prepared_boundary_plan, grid_context_routes_exact_nary_storage_registr 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/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index 6106d41e7..eba6ca1f9 100644 --- a/tests/cpp/unit/runtime/test_program_context_contract.cpp +++ b/tests/cpp/unit/runtime/test_program_context_contract.cpp @@ -173,9 +173,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); diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 6ec105491..5a1f8dfbe 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -5,7 +5,7 @@ import pops from pops.boundary import TransportBoundarySet from pops.boundary.transport import ResolvedTransportBoundarySet -from pops.boundary.transport import Inflow, Outflow +from pops.boundary.transport import Inflow, Outflow, SlipWall from pops.domain import Rectangle from pops.frames import Cartesian2D from pops.math import ddt, div @@ -13,6 +13,7 @@ from pops.numerics.reconstruction import limiters from pops.numerics.spatial import FiniteVolume from pops.params import RuntimeParam +from pops.physics import Axial, Density, Momentum from pops.representations import Conservative from pops.spaces import CellState @@ -135,3 +136,67 @@ 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_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=y_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, 0.0, 0.0, 0.0] for row in runtime["faces"]) diff --git a/tests/python/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 82ff7e347..20a554734 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -33,7 +33,18 @@ 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 # noqa: E402 +from pops.physics import Axial, Density, Momentum, Scalar # 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(Scalar()) == "Scalar" + assert _lower_native_role("momentum_y") == "MomentumY" + assert _lower_native_role("Custom") is None def _facade_model(name="ep"): 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 1b425d464..7a280b1ce 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -50,7 +50,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": [], @@ -109,7 +114,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),)), ) From 4dcc6e2fd8d43de7a32b92b9bade411d8b99df87 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 09:08:46 +0200 Subject: [PATCH 02/93] fix(boundary): preserve 2.5D slip-wall parity --- .../boundary/prepared_hyperbolic_boundary.hpp | 34 ++++----- python/pops/boundary/transport.py | 4 +- python/pops/frames/__init__.py | 3 +- python/pops/frames/cartesian.py | 23 +++++- .../amr/test_amr_transfer_properties.cpp | 75 +++++++++++++++++++ .../unit/mesh/test_prepared_boundary_plan.cpp | 33 +++++--- .../runtime/test_program_context_contract.cpp | 56 +++++++++++++- .../unit/boundary/test_transport_authoring.py | 11 ++- .../unit/codegen/test_module_lowering.py | 3 +- .../unit/domain/test_cartesian_domain_grid.py | 5 +- 10 files changed, 200 insertions(+), 47 deletions(-) diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp index 04c50b019..509d53242 100644 --- a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -40,7 +40,9 @@ enum class HyperbolicComponentParity { Scalar, PolarVector, AxialVector }; /// /// 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 declaration metadata, never inferred from the component index. +/// 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); @@ -50,13 +52,15 @@ struct HyperbolicComponentTransform { static HyperbolicComponentTransform scalar() { return {}; } static HyperbolicComponentTransform polar_vector(int component_axis) { - if (component_axis < 0 || component_axis >= Dim) - throw std::invalid_argument("polar boundary component axis is outside the model dimension"); + 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 >= Dim) - throw std::invalid_argument("axial boundary component axis is outside the model dimension"); + 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}; } @@ -268,28 +272,16 @@ 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") { - if constexpr (Dim < 2) - throw std::invalid_argument("model role references the absent y axis"); + if (role == "MomentumY" || role == "VelocityY") return HyperbolicComponentTransform::polar_vector(1); - } - if (role == "MomentumZ" || role == "VelocityZ") { - if constexpr (Dim < 3) - throw std::invalid_argument("model role references the absent z axis"); + if (role == "MomentumZ" || role == "VelocityZ") return HyperbolicComponentTransform::polar_vector(2); - } if (role == "AxialX") return HyperbolicComponentTransform::axial_vector(0); - if (role == "AxialY") { - if constexpr (Dim < 2) - throw std::invalid_argument("axial model role references the absent y axis"); + if (role == "AxialY") return HyperbolicComponentTransform::axial_vector(1); - } - if (role == "AxialZ") { - if constexpr (Dim < 3) - throw std::invalid_argument("axial model role references the absent z axis"); + if (role == "AxialZ") return HyperbolicComponentTransform::axial_vector(2); - } if (role == "Density" || role == "Energy" || role == "Pressure" || role == "Temperature" || role == "Scalar" || role == "Custom") return HyperbolicComponentTransform::scalar(); diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index 186d30363..4ae0dcf58 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -494,9 +494,9 @@ def resolve_condition( for component, token in tokens.items() if token in {normal_token, normal_velocity} ] - if len(normal) != 1: + if not normal: raise ValueError( - "SlipWall on %s requires exactly one declared normal polar-vector component" + "SlipWall on %s requires a declared normal polar-vector component" % geometry.name ) return _resolved_condition( 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/tests/cpp/integration/amr/test_amr_transfer_properties.cpp b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp index e6dd57720..55c326a60 100644 --- a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp +++ b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp @@ -587,3 +587,78 @@ 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, 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/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index f0934ad4b..2c036f140 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -143,33 +143,40 @@ 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, model_aware_slip_wall_reverses_only_normal_polar_component) { +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, 4, 1); + 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(3); - values(i, j, 3) = Real(4); + 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", "foextrap", "foextrap"}, std::vector(16, 0.0), + {"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", "MomentumY", "Energy"}); - PreparedBoundaryPlan plan("case::fluid::slip-plan", 1, std::move(boundary)); + {"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(3)); - EXPECT_EQ(field(-1, 2, 3), Real(4)); + 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(3)); + 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) { @@ -178,12 +185,18 @@ TEST(test_prepared_boundary_plan, polar_and_axial_reflections_are_distinct_in_1d 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); diff --git a/tests/cpp/unit/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index eba6ca1f9..ecd7813a5 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 @@ -241,6 +243,55 @@ 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, GeneratedScratchIsPersistentExactAndNonAliasing) { ensure_kokkos(); SystemConfig cfg; @@ -352,9 +403,8 @@ 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}}), - std::invalid_argument) + EXPECT_THROW((void)ctx.solve_fields_from_blocks(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}}); diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 5a1f8dfbe..9021784f1 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -7,7 +7,7 @@ from pops.boundary.transport import ResolvedTransportBoundarySet from pops.boundary.transport import Inflow, 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 @@ -163,7 +163,7 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): "rho": Density(), "mx": Momentum(axis=x_axis), "my": Momentum(axis=y_axis), - "bz": Axial(axis=y_axis), + "bz": Axial(axis=Z_AXIS), }, ) rho, mx, my, bz = state @@ -175,7 +175,10 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): 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)}, + 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( @@ -199,4 +202,4 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): 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, 0.0, 0.0, 0.0] for row in runtime["faces"]) + assert all(row["values"] == [0.0] * 4 for row in runtime["faces"]) diff --git a/tests/python/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 20a554734..30b420ba2 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -34,7 +34,7 @@ from pops.physics._facade import Model # noqa: E402 from pops.codegen.module_lowering import ( # noqa: E402 _lower_native_role, _module_to_model, lower_and_validate, remap_lowering_error) -from pops.frames import X_AXIS # noqa: E402 +from pops.frames import X_AXIS, Z_AXIS # noqa: E402 from pops.physics import Axial, Density, Momentum, Scalar # noqa: E402 @@ -42,6 +42,7 @@ 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 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): From 586c4738d511f2c4964e98210b87afe80a91e80f Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 21:52:33 +0200 Subject: [PATCH 03/93] fix(boundary): retain slip walls after compile --- python/pops/mesh/boundaries/compiled_plan.py | 4 ++-- .../unit/boundary/test_transport_authoring.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/python/pops/mesh/boundaries/compiled_plan.py b/python/pops/mesh/boundaries/compiled_plan.py index 075650898..e1fb2401b 100644 --- a/python/pops/mesh/boundaries/compiled_plan.py +++ b/python/pops/mesh/boundaries/compiled_plan.py @@ -90,9 +90,9 @@ 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", "slip_wall", "external"}: raise ValueError("compiled boundary face has no executable producer type") - if face["type"] in {"periodic", "foextrap", "external"}: + if face["type"] in {"periodic", "foextrap", "slip_wall", "external"}: values = [0.0] * ncomp else: expressions = face.get("values") diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 9021784f1..284086d5c 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -203,3 +203,17 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): 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"] From 8fa1db471faa6b8514422a73b51683a4b4440f70 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 22:18:51 +0200 Subject: [PATCH 04/93] fix(physics): complete axial variable role ABI --- include/pops/core/state/variables.hpp | 18 ++- python/pops/physics/_coupled_abi.py | 3 + python/pops/runtime/_bricks_time.py | 3 + tests/cpp/unit/runtime/test_variable_role.cpp | 26 +++++ .../runtime/test_axial_slip_wall_pipeline.py | 103 ++++++++++++++++++ .../unit/codegen/test_module_lowering.py | 3 + 6 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/python/integration/runtime/test_axial_slip_wall_pipeline.py 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/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/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/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/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..406d0cf07 --- /dev/null +++ b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py @@ -0,0 +1,103 @@ +"""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 + + +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}) + + 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/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 30b420ba2..b0ce47c11 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -36,6 +36,8 @@ _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(): @@ -46,6 +48,7 @@ def test_module_role_lowering_preserves_typed_boundary_semantics(): 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"): From fa2827463b39ea7b9c0bb6f51ac7a91c156e64a1 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 22:40:12 +0200 Subject: [PATCH 05/93] test: register axial boundary integration proof --- tests/python/architecture/test_final_public_api.py | 4 ++-- tests/python/test_durations.json | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/python/architecture/test_final_public_api.py b/tests/python/architecture/test_final_public_api.py index d8eac5273..71ba1a7f5 100644 --- a/tests/python/architecture/test_final_public_api.py +++ b/tests/python/architecture/test_final_public_api.py @@ -325,8 +325,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"): diff --git a/tests/python/test_durations.json b/tests/python/test_durations.json index a4560cbab..9121d879d 100644 --- a/tests/python/test_durations.json +++ b/tests/python/test_durations.json @@ -56,6 +56,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, @@ -399,7 +400,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": 216, + "estimated_count": 217, "estimated_files": [ "tests/python/examples/final/test_hyqmom15_final_example.py", "tests/python/examples/final/test_scalar_advection_final_example.py", @@ -434,6 +435,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", @@ -618,6 +620,6 @@ "tests/python/unit/time/test_typed_provenance_guards.py", "tests/python/unit/time/test_typed_schedule.py" ], - "total_files": 395 + "total_files": 396 } } From bf36faa698463b94f76044b9d5ea7a60372a7742 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 23:23:37 +0200 Subject: [PATCH 06/93] fix(boundary): make slip-wall role lookup type-safe --- python/pops/boundary/transport.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index 4ae0dcf58..1b0ac21c7 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -470,7 +470,8 @@ def resolve_condition( from pops.physics.roles import ComponentRole, native_role_token components = _state_components(self.state, where="SlipWall") - roles = getattr(self.state.space, "roles", None) + 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") From 8125b0cc6a58ca9d8b6e7daf613a2707f06447d1 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 03:08:30 +0200 Subject: [PATCH 07/93] refactor(boundary): prepare model-aware hyperbolic faces --- .../mesh/boundary/prepared_boundary_plan.hpp | 129 ++-- .../boundary/prepared_hyperbolic_boundary.hpp | 571 ++++++++++++++++++ include/pops/runtime/amr_system.hpp | 10 +- include/pops/runtime/system.hpp | 15 +- include/pops_headers.manifest | 1 + python/bindings/core/init/init_amr.cpp | 15 +- python/bindings/core/init/init_system.cpp | 15 +- python/pops/boundary/__init__.py | 2 + python/pops/boundary/transport.py | 102 +++- python/pops/codegen/module_lowering.py | 36 +- python/pops/physics/__init__.py | 5 +- python/pops/physics/roles.py | 23 +- python/pops/runtime/_runtime_authorities.py | 16 +- src/runtime/amr/amr_system.cpp | 70 +-- src/runtime/system/system_install.cpp | 234 +++---- .../amr/test_amr_transfer_properties.cpp | 9 +- .../native_loader/test_amr_native_loader.cpp | 13 +- .../test_multiblock_interface_scheduler.cpp | 8 +- .../unit/mesh/test_prepared_boundary_plan.cpp | 157 ++--- .../runtime/test_program_context_contract.cpp | 11 +- .../unit/boundary/test_transport_authoring.py | 67 +- .../unit/codegen/test_module_lowering.py | 13 +- ...est_boundary_component_prepare_contract.py | 10 +- 23 files changed, 1129 insertions(+), 403 deletions(-) create mode 100644 include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index b6711c619..f094d03b5 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -17,7 +17,7 @@ #include #include -#include +#include #include #include @@ -85,9 +85,8 @@ 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. +/// 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 { public: /// Move-only, lane-bound executable state for this immutable plan. @@ -179,13 +178,14 @@ class PreparedBoundaryPlan { 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)) { @@ -201,7 +201,10 @@ class PreparedBoundaryPlan { 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_; + } const std::vector& periodic_identifications() const noexcept { return periodic_identifications_; } @@ -218,22 +221,6 @@ class PreparedBoundaryPlan { 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)); @@ -259,9 +246,9 @@ class PreparedBoundaryPlan { return !ghost_components_.empty() || !residual_components_.empty() || !jvp_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 @@ -394,22 +381,7 @@ 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) - 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 hyperbolic_boundary_.periodicity(); } /// Same-level/MPI and prepared periodic production are performed by the memoized native halo @@ -419,13 +391,9 @@ 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); + hyperbolic_boundary_.fill_physical(state, domain); } void fill_same_level_and_physical(MultiFab& state, const Box2D& domain, @@ -433,13 +401,9 @@ 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); + hyperbolic_boundary_.fill_physical(state, domain); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry) const { @@ -448,8 +412,7 @@ class PreparedBoundaryPlan { "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); + hyperbolic_boundary_.fill_physical(state, geometry.domain); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry, @@ -459,8 +422,7 @@ class PreparedBoundaryPlan { "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); + hyperbolic_boundary_.fill_physical(state, geometry.domain); } /// One-shot control/diagnostic adapter. It materializes a fresh component session and workspace; @@ -575,7 +537,7 @@ 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_; @@ -667,8 +629,13 @@ 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}; + std::array face_laws_() const { + return { + hyperbolic_boundary_.face(0, -1).law, + hyperbolic_boundary_.face(0, 1).law, + hyperbolic_boundary_.face(1, -1).law, + hyperbolic_boundary_.face(1, 1).law, + }; } bool has_mapped_periodicity_() const noexcept { @@ -697,8 +664,9 @@ class PreparedBoundaryPlan { 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 +688,12 @@ 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"); - } - } + const auto expected = face_laws_(); if (periodic_identifications_.empty()) { - if ((expected[0] == BCType::Periodic) != (expected[1] == BCType::Periodic) || - (expected[2] == BCType::Periodic) != (expected[3] == BCType::Periodic)) + if ((expected[0] == HyperbolicBoundaryLaw::Periodic) != + (expected[1] == HyperbolicBoundaryLaw::Periodic) || + (expected[2] == HyperbolicBoundaryLaw::Periodic) != + (expected[3] == HyperbolicBoundaryLaw::Periodic)) throw std::runtime_error( "axis-aligned PreparedBoundaryPlan requires periodic faces in complete axis pairs"); return; @@ -749,13 +707,13 @@ 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 (expected[static_cast(face)] != HyperbolicBoundaryLaw::Periodic) 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]) + if ((expected[face] == HyperbolicBoundaryLaw::Periodic) != claimed[face]) throw std::runtime_error( "PreparedBoundaryPlan periodic face table differs from explicit identifications"); if (has_mapped_periodicity_() && periodic_identifications_.size() != 1) @@ -801,13 +759,9 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical(MultiFab 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); + plan_->hyperbolic_boundary_.fill_physical(state, domain); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( @@ -818,8 +772,7 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( "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); + plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( @@ -828,8 +781,7 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( 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); + plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); detail::BoundaryFieldRegistry fields; fields.configure_states(plan_->required_state_identities()); fields.configure_fields(plan_->required_field_identities()); @@ -860,8 +812,7 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( 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); + plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); if (ghost_workspaces_.size() != ghost_components_.size()) throw std::logic_error( "PreparedBoundaryPlan ghost executor was not materialized before numerical execution"); 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..e5d4de337 --- /dev/null +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -0,0 +1,571 @@ +/// @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 + +#if defined(POPS_HAS_KOKKOS) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pops { + +enum class HyperbolicBoundaryLaw { Periodic, Extrapolate, FixedState, ReflectiveSlip, External }; + +enum class HyperbolicComponentParity { Scalar, PolarVector, AxialVector }; + +/// 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 declaration metadata, never inferred from the component index. +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 >= Dim) + throw std::invalid_argument("polar boundary component axis is outside the model dimension"); + return {HyperbolicComponentParity::PolarVector, component_axis}; + } + static HyperbolicComponentTransform axial_vector(int component_axis) { + if (component_axis < 0 || component_axis >= Dim) + throw std::invalid_argument("axial boundary component axis is outside the model dimension"); + 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; +}; + +/// 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) { + 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::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::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) + 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; + } + } +}; + +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") { + if constexpr (Dim < 2) + throw std::invalid_argument("model role references the absent y axis"); + return HyperbolicComponentTransform::polar_vector(1); + } + if (role == "MomentumZ" || role == "VelocityZ") { + if constexpr (Dim < 3) + throw std::invalid_argument("model role references the absent z axis"); + return HyperbolicComponentTransform::polar_vector(2); + } + if (role == "AxialX") + return HyperbolicComponentTransform::axial_vector(0); + if (role == "AxialY") { + if constexpr (Dim < 2) + throw std::invalid_argument("axial model role references the absent y axis"); + return HyperbolicComponentTransform::axial_vector(1); + } + if (role == "AxialZ") { + if constexpr (Dim < 3) + throw std::invalid_argument("axial model role references the absent z axis"); + 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 == "slip_wall") + return HyperbolicBoundaryLaw::ReflectiveSlip; + if (token == "external") + return HyperbolicBoundaryLaw::External; + throw std::invalid_argument("unsupported prepared hyperbolic face law '" + std::string(token) + + "'"); +} + +} // namespace detail + +template +class PreparedHyperbolicBoundary { + public: + static_assert(Dim >= 1 && Dim <= 3); + using Transform = HyperbolicComponentTransform; + + PreparedHyperbolicBoundary() = default; + + PreparedHyperbolicBoundary( + std::array faces, + std::vector component_transforms, + HyperbolicCornerPolicy corner_policy = HyperbolicCornerPolicy::NotRequired, + bool allow_mapped_periodicity = false) + : faces_(std::move(faces)), + component_transforms_(std::move(component_transforms)), + corner_policy_(corner_policy), + allow_mapped_periodicity_(allow_mapped_periodicity) { + validate(); + 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_; } + + Periodicity periodicity() const { + static_assert(Dim == 2, "the current MultiFab topology is two-dimensional"); + if ((faces_[0].law == HyperbolicBoundaryLaw::Periodic) != + (faces_[1].law == HyperbolicBoundaryLaw::Periodic) || + (faces_[2].law == HyperbolicBoundaryLaw::Periodic) != + (faces_[3].law == HyperbolicBoundaryLaw::Periodic)) + throw std::logic_error( + "axis-permuted periodic topology has no per-axis runtime Periodicity projection"); + return Periodicity{ + faces_[0].law == HyperbolicBoundaryLaw::Periodic, + faces_[2].law == HyperbolicBoundaryLaw::Periodic, + }; + } + + /// 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 { + static_assert(Dim == 2, "the current MultiFab storage is two-dimensional"); + 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; + 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); + } + } + + 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]) + for_each_cell( + Box2D{{domain.lo[0] - depth, tangential_lo}, {domain.lo[0] - 1, tangential_hi}}, + detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], + faces_[0].law, faces_[1].law}); + if (detail::is_physical_hyperbolic_law(faces_[1].law) && valid.hi[0] == domain.hi[0]) + for_each_cell( + Box2D{{domain.hi[0] + 1, tangential_lo}, {domain.hi[0] + depth, tangential_hi}}, + detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], + faces_[0].law, faces_[1].law}); + + 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]) + for_each_cell( + Box2D{{tangential_lo, domain.lo[1] - depth}, {tangential_hi, domain.lo[1] - 1}}, + detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], + faces_[2].law, faces_[3].law}); + if (detail::is_physical_hyperbolic_law(faces_[3].law) && valid.hi[1] == domain.hi[1]) + for_each_cell( + Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}, + detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], + faces_[2].law, faces_[3].law}); + } + } + + private: + std::array faces_{}; + std::vector component_transforms_; + HyperbolicCornerPolicy corner_policy_ = HyperbolicCornerPolicy::NotRequired; + bool allow_mapped_periodicity_ = 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 + + void validate() 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 (!allow_mapped_periodicity_ && + (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.law == HyperbolicBoundaryLaw::FixedState) { + 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"); + } else if (!prepared_face.fixed_state.empty()) { + throw std::invalid_argument( + "only a fixed-state hyperbolic boundary may carry component values"); + } + 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 allow_mapped_periodicity = false) { + 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"); + + std::vector> transforms; + transforms.reserve(component_roles.size()); + for (const auto& role : component_roles) + transforms.push_back(detail::transform_from_role(role)); + + 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); + if (destination.law == HyperbolicBoundaryLaw::FixedState) { + 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)])); + } + } + return PreparedHyperbolicBoundary(std::move(faces), std::move(transforms), + HyperbolicCornerPolicy::NotRequired, + allow_mapped_periodicity); +} + +} // namespace pops diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 63bebee4d..8d5ed0c21 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -349,11 +349,15 @@ 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. + PreparedBoundaryReadDependencies read_dependencies = {}, + std::vector + periodic_identifications = {}); + /// Compatibility adapter for the historical component-count ABI. 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, diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 4909e3911..772947a9d 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -319,17 +319,20 @@ 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. + PreparedBoundaryReadDependencies read_dependencies = {}, + std::vector + periodic_identifications = {}); + /// Compatibility adapter for the historical component-count ABI. 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, diff --git a/include/pops_headers.manifest b/include/pops_headers.manifest index e860963dd..3c218e281 100644 --- a/include/pops_headers.manifest +++ b/include/pops_headers.manifest @@ -53,6 +53,7 @@ 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/geometry.hpp api pops/mesh/index/box2d.hpp diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index 54703525f..d5aa21ef7 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -235,16 +235,19 @@ 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) { - 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)); + system.install_boundary_plan(name, identity, required_depth, face_types, face_values, + face_identities, component_roles, omitted_interface_faces, + state_identity, PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows( + periodic_identifications)); }, 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>{}, diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index b77a53d46..5b36ccaba 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -169,16 +169,19 @@ 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) { - 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)); + system.install_boundary_plan(name, identity, required_depth, face_types, face_values, + face_identities, component_roles, omitted_interface_faces, + state_identity, PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows( + periodic_identifications)); }, 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>{}, diff --git a/python/pops/boundary/__init__.py b/python/pops/boundary/__init__.py index cadd68d55..867f08406 100644 --- a/python/pops/boundary/__init__.py +++ b/python/pops/boundary/__init__.py @@ -7,6 +7,7 @@ from .transport import ( BoundaryStencilRequirement, + SlipWall, TransportBoundarySet, ) from .embedded import EmbeddedBoundaryFlux, ZeroFlux @@ -14,6 +15,7 @@ __all__ = [ "BoundaryStencilRequirement", "EmbeddedBoundaryFlux", + "SlipWall", "TransportBoundarySet", "ZeroFlux", ] diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index f466f720f..186d30363 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -211,7 +211,7 @@ def __post_init__(self) -> None: 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", "slip_wall"}: raise ValueError("unsupported built-in transport condition type") _state(self.state, where="ResolvedTransportCondition.state") if not self.state.is_resolved: @@ -248,6 +248,7 @@ def _resolved_condition( ) -> ResolvedTransportCondition: from pops.mesh.boundaries import ( BoundaryDependencies, + GhostFormula, GhostState, Inflow as LowLevelInflow, Outflow as LowLevelOutflow, @@ -275,7 +276,11 @@ def _resolved_condition( characteristic=_closure(), ) output = GhostState(boundary=boundary, subject=state, representation=target) - factory = LowLevelInflow if condition_type == "inflow" else LowLevelOutflow + factory = { + "inflow": LowLevelInflow, + "outflow": LowLevelOutflow, + "slip_wall": GhostFormula, + }[condition_type] provider = factory( handle=_provider_handle(state, geometry, condition_type), outputs=(output,), @@ -424,6 +429,86 @@ def resolve_condition( ) +@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") + roles = getattr(self.state.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 len(normal) != 1: + raise ValueError( + "SlipWall on %s requires exactly one 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 @@ -543,8 +628,11 @@ 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": { + "outflow": "foextrap", + "inflow": "dirichlet", + "slip_wall": "slip_wall", + }[row.condition_type], "values": ( [] if row.condition_type == "outflow" else [_expression_data(expression, qualified=True)["value"] @@ -578,9 +666,10 @@ 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 {"outflow", "slip_wall"}: values = [0.0] * ncomp - face_type = "foextrap" + face_type = ( + "foextrap" if condition.condition_type == "outflow" else "slip_wall") else: values = [] for index, expression in enumerate(condition.values): @@ -830,5 +919,6 @@ def labels(rows: Any) -> list[str]: "Outflow", "ResolvedTransportBoundarySet", "ResolvedTransportCondition", + "SlipWall", "TransportBoundarySet", ] diff --git a/python/pops/codegen/module_lowering.py b/python/pops/codegen/module_lowering.py index 56cce3234..a954ba5f6 100644 --- a/python/pops/codegen/module_lowering.py +++ b/python/pops/codegen/module_lowering.py @@ -30,6 +30,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` @@ -112,13 +142,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) 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/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/runtime/_runtime_authorities.py b/python/pops/runtime/_runtime_authorities.py index aff8a712d..12d99278f 100644 --- a/python/pops/runtime/_runtime_authorities.py +++ b/python/pops/runtime/_runtime_authorities.py @@ -158,9 +158,20 @@ 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", "slip_wall", "external"} for value in types): raise NotImplementedError("prepared boundary plan selected an unavailable face producer") + 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 = [] for comp in range(ncomp): for row in faces: @@ -182,7 +193,8 @@ 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, diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index b78a3e476..d9e9ae099 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1330,16 +1330,9 @@ 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& 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), {}); -} - -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) { @@ -1354,42 +1347,11 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( 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 hyperbolic = prepare_hyperbolic_boundary<2>( + face_types, face_values, face_identities, component_roles, + !periodic_identifications.empty()); auto plan = std::make_shared( - identity, required_depth, std::move(components), omitted_interface_faces, state_identity, + identity, required_depth, std::move(hyperbolic), 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) @@ -1398,6 +1360,24 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( P->boundary_plans_.emplace(name, std::move(plan)); } +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& omitted_interface_faces, const std::string& state_identity, + PreparedBoundaryReadDependencies read_dependencies, + std::vector periodic_identifications) { + if (ncomp < 1) + throw std::runtime_error("AmrSystem::install_boundary_plan requires at least one component"); + std::vector face_identities; + face_identities.reserve(4); + for (int face = 0; face < 4; ++face) + face_identities.push_back(identity + ".face." + std::to_string(face)); + install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, + std::vector(static_cast(ncomp), "Scalar"), + omitted_interface_faces, state_identity, std::move(read_dependencies), + std::move(periodic_identifications)); +} + POPS_EXPORT void AmrSystem::install_field_storage_route(const std::string& field_identity, const std::string& provider_slot) { Impl* P = p_.get(); diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index 4ed735086..6c848034c 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -289,44 +289,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,20 +308,14 @@ 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), {}); -} - -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& omitted_interface_faces, const std::string& state_identity, - PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications) { + PreparedBoundaryReadDependencies read_dependencies, + std::vector + periodic_identifications) { Impl* P = p_.get(); require_assembling(P->lifecycle_, "install_boundary_plan"); if (name.empty() || state_identity.empty()) @@ -371,20 +327,11 @@ POPS_EXPORT void System::install_boundary_plan( "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 hyperbolic = prepare_hyperbolic_boundary<2>( + face_types, face_values, face_identities, component_roles, + !periodic_identifications.empty()); auto plan = std::make_shared( - identity, required_depth, std::move(components), omitted_interface_faces, state_identity, + identity, required_depth, std::move(hyperbolic), 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) @@ -392,6 +339,24 @@ POPS_EXPORT void System::install_boundary_plan( P->boundary_plans_.emplace(name, std::move(plan)); } +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& omitted_interface_faces, const std::string& state_identity, + PreparedBoundaryReadDependencies read_dependencies, + std::vector periodic_identifications) { + if (ncomp < 1) + throw std::runtime_error("System::install_boundary_plan requires at least one component"); + std::vector face_identities; + face_identities.reserve(4); + for (int face = 0; face < 4; ++face) + face_identities.push_back(identity + ".face." + std::to_string(face)); + install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, + std::vector(static_cast(ncomp), "Scalar"), + omitted_interface_faces, state_identity, std::move(read_dependencies), + std::move(periodic_identifications)); +} + POPS_EXPORT void System::install_field_storage_route(const std::string& field_identity, const std::string& provider_slot) { Impl* P = p_.get(); @@ -514,8 +479,7 @@ 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_)) + 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"); @@ -523,9 +487,8 @@ POPS_EXPORT void System::install_block(const std::string& name, int ncomp, 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"); + 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)}); @@ -690,12 +653,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); @@ -1125,10 +1090,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 +1116,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 +1133,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 +1141,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 +1167,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_) @@ -1223,9 +1182,9 @@ void System::set_analytic_level_set(const std::vector& opcodes, "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 + "'"); + 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()) @@ -1242,8 +1201,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 +1215,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 +1245,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 +1267,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}}, @@ -1360,9 +1314,9 @@ void System::set_geometry_mode(const std::string& mode) { "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 + "'"); + 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()) @@ -1722,36 +1676,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/tests/cpp/integration/amr/test_amr_transfer_properties.cpp b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp index 1c3bbda1f..e6dd57720 100644 --- a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp +++ b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp @@ -102,8 +102,13 @@ AmrRuntime bootstrap_runtime(int cells = 8, bool install_prepared_boundary = fal 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 = 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..ea6f77c06 100644 --- a/tests/cpp/integration/native_loader/test_amr_native_loader.cpp +++ b/tests/cpp/integration/native_loader/test_amr_native_loader.cpp @@ -1092,12 +1092,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 = diff --git a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp index 3e0fffcc1..a1ba4b1c7 100644 --- a/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp +++ b/tests/cpp/integration/runtime/test_multiblock_interface_scheduler.cpp @@ -903,8 +903,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/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index d262516c5..d4312cc54 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -18,14 +18,15 @@ 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() { @@ -69,18 +70,18 @@ PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, s 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 +92,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 +140,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 +152,63 @@ 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, model_aware_slip_wall_reverses_only_normal_polar_component) { + 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) { + values(i, j, 0) = Real(1); + values(i, j, 1) = Real(2); + values(i, j, 2) = Real(3); + values(i, j, 3) = Real(4); + }); + } + auto boundary = prepare_hyperbolic_boundary<2>( + {"slip_wall", "slip_wall", "foextrap", "foextrap"}, std::vector(16, 0.0), + {"case::fluid::xlo", "case::fluid::xhi", "case::fluid::ylo", "case::fluid::yhi"}, + {"Density", "MomentumX", "MomentumY", "Energy"}); + PreparedBoundaryPlan plan("case::fluid::slip-plan", 1, 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(3)); + EXPECT_EQ(field(-1, 2, 3), Real(4)); + EXPECT_EQ(field(2, -1, 1), Real(2)); + EXPECT_EQ(field(2, -1, 2), 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 axial_normal_2d = HyperbolicComponentTransform<2>::axial_vector(0); + const auto axial_tangent_2d = HyperbolicComponentTransform<2>::axial_vector(1); + EXPECT_EQ(polar_normal_2d.reflection_sign(0), Real(-1)); + EXPECT_EQ(polar_tangent_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_normal_2d.reflection_sign(0), Real(1)); + EXPECT_EQ(axial_tangent_2d.reflection_sign(0), 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 +220,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 +231,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); } @@ -327,8 +340,8 @@ TEST(test_prepared_boundary_plan, grid_context_routes_exact_nary_storage_registr 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/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index b992666a7..22ad04198 100644 --- a/tests/cpp/unit/runtime/test_program_context_contract.cpp +++ b/tests/cpp/unit/runtime/test_program_context_contract.cpp @@ -288,9 +288,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); diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 6ec105491..5a1f8dfbe 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -5,7 +5,7 @@ import pops from pops.boundary import TransportBoundarySet from pops.boundary.transport import ResolvedTransportBoundarySet -from pops.boundary.transport import Inflow, Outflow +from pops.boundary.transport import Inflow, Outflow, SlipWall from pops.domain import Rectangle from pops.frames import Cartesian2D from pops.math import ddt, div @@ -13,6 +13,7 @@ from pops.numerics.reconstruction import limiters from pops.numerics.spatial import FiniteVolume from pops.params import RuntimeParam +from pops.physics import Axial, Density, Momentum from pops.representations import Conservative from pops.spaces import CellState @@ -135,3 +136,67 @@ 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_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=y_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, 0.0, 0.0, 0.0] for row in runtime["faces"]) diff --git a/tests/python/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 82ff7e347..20a554734 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -33,7 +33,18 @@ 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 # noqa: E402 +from pops.physics import Axial, Density, Momentum, Scalar # 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(Scalar()) == "Scalar" + assert _lower_native_role("momentum_y") == "MomentumY" + assert _lower_native_role("Custom") is None def _facade_model(name="ep"): 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..5cf3989ad 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -50,7 +50,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": [], @@ -109,7 +114,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),)), ) From f5a7ed6fb5bcc86a3014d11103398a62c3d5b54a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 09:08:46 +0200 Subject: [PATCH 08/93] fix(boundary): preserve 2.5D slip-wall parity --- .../boundary/prepared_hyperbolic_boundary.hpp | 34 ++++----- python/pops/boundary/transport.py | 4 +- python/pops/frames/__init__.py | 3 +- python/pops/frames/cartesian.py | 23 +++++- .../amr/test_amr_transfer_properties.cpp | 75 +++++++++++++++++++ .../unit/mesh/test_prepared_boundary_plan.cpp | 33 +++++--- .../runtime/test_program_context_contract.cpp | 51 +++++++++++++ .../unit/boundary/test_transport_authoring.py | 11 ++- .../unit/codegen/test_module_lowering.py | 3 +- .../unit/domain/test_cartesian_domain_grid.py | 5 +- 10 files changed, 198 insertions(+), 44 deletions(-) diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp index e5d4de337..227c2aa6d 100644 --- a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -40,7 +40,9 @@ enum class HyperbolicComponentParity { Scalar, PolarVector, AxialVector }; /// /// 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 declaration metadata, never inferred from the component index. +/// 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); @@ -50,13 +52,15 @@ struct HyperbolicComponentTransform { static HyperbolicComponentTransform scalar() { return {}; } static HyperbolicComponentTransform polar_vector(int component_axis) { - if (component_axis < 0 || component_axis >= Dim) - throw std::invalid_argument("polar boundary component axis is outside the model dimension"); + 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 >= Dim) - throw std::invalid_argument("axial boundary component axis is outside the model dimension"); + 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}; } @@ -268,28 +272,16 @@ 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") { - if constexpr (Dim < 2) - throw std::invalid_argument("model role references the absent y axis"); + if (role == "MomentumY" || role == "VelocityY") return HyperbolicComponentTransform::polar_vector(1); - } - if (role == "MomentumZ" || role == "VelocityZ") { - if constexpr (Dim < 3) - throw std::invalid_argument("model role references the absent z axis"); + if (role == "MomentumZ" || role == "VelocityZ") return HyperbolicComponentTransform::polar_vector(2); - } if (role == "AxialX") return HyperbolicComponentTransform::axial_vector(0); - if (role == "AxialY") { - if constexpr (Dim < 2) - throw std::invalid_argument("axial model role references the absent y axis"); + if (role == "AxialY") return HyperbolicComponentTransform::axial_vector(1); - } - if (role == "AxialZ") { - if constexpr (Dim < 3) - throw std::invalid_argument("axial model role references the absent z axis"); + if (role == "AxialZ") return HyperbolicComponentTransform::axial_vector(2); - } if (role == "Density" || role == "Energy" || role == "Pressure" || role == "Temperature" || role == "Scalar" || role == "Custom") return HyperbolicComponentTransform::scalar(); diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index 186d30363..4ae0dcf58 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -494,9 +494,9 @@ def resolve_condition( for component, token in tokens.items() if token in {normal_token, normal_velocity} ] - if len(normal) != 1: + if not normal: raise ValueError( - "SlipWall on %s requires exactly one declared normal polar-vector component" + "SlipWall on %s requires a declared normal polar-vector component" % geometry.name ) return _resolved_condition( 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/tests/cpp/integration/amr/test_amr_transfer_properties.cpp b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp index e6dd57720..55c326a60 100644 --- a/tests/cpp/integration/amr/test_amr_transfer_properties.cpp +++ b/tests/cpp/integration/amr/test_amr_transfer_properties.cpp @@ -587,3 +587,78 @@ 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, 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/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index d4312cc54..5ac2a0a83 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -152,33 +152,40 @@ 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, model_aware_slip_wall_reverses_only_normal_polar_component) { +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, 4, 1); + 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(3); - values(i, j, 3) = Real(4); + 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", "foextrap", "foextrap"}, std::vector(16, 0.0), + {"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", "MomentumY", "Energy"}); - PreparedBoundaryPlan plan("case::fluid::slip-plan", 1, std::move(boundary)); + {"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(3)); - EXPECT_EQ(field(-1, 2, 3), Real(4)); + 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(3)); + 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) { @@ -187,12 +194,18 @@ TEST(test_prepared_boundary_plan, polar_and_axial_reflections_are_distinct_in_1d 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); diff --git a/tests/cpp/unit/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index 22ad04198..20d47a6e9 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 @@ -392,6 +394,55 @@ TEST(ProgramContextContract, CommitManySnapshotsSourcesThatAreAlsoTargets) { EXPECT_EQ(second.fab(0).const_array()(second.box(0).lo[0], second.box(0).lo[1], 0), Real(3)); } +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, GeneratedScratchIsPersistentExactAndNonAliasing) { ensure_kokkos(); SystemConfig cfg; diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 5a1f8dfbe..9021784f1 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -7,7 +7,7 @@ from pops.boundary.transport import ResolvedTransportBoundarySet from pops.boundary.transport import Inflow, 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 @@ -163,7 +163,7 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): "rho": Density(), "mx": Momentum(axis=x_axis), "my": Momentum(axis=y_axis), - "bz": Axial(axis=y_axis), + "bz": Axial(axis=Z_AXIS), }, ) rho, mx, my, bz = state @@ -175,7 +175,10 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): 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)}, + 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( @@ -199,4 +202,4 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): 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, 0.0, 0.0, 0.0] for row in runtime["faces"]) + assert all(row["values"] == [0.0] * 4 for row in runtime["faces"]) diff --git a/tests/python/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 20a554734..30b420ba2 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -34,7 +34,7 @@ from pops.physics._facade import Model # noqa: E402 from pops.codegen.module_lowering import ( # noqa: E402 _lower_native_role, _module_to_model, lower_and_validate, remap_lowering_error) -from pops.frames import X_AXIS # noqa: E402 +from pops.frames import X_AXIS, Z_AXIS # noqa: E402 from pops.physics import Axial, Density, Momentum, Scalar # noqa: E402 @@ -42,6 +42,7 @@ 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 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): From 50933207d32fac72322ddebaa20e1b1202646348 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 21:52:33 +0200 Subject: [PATCH 09/93] fix(boundary): retain slip walls after compile --- python/pops/mesh/boundaries/compiled_plan.py | 4 ++-- .../unit/boundary/test_transport_authoring.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/python/pops/mesh/boundaries/compiled_plan.py b/python/pops/mesh/boundaries/compiled_plan.py index 8acd7b69c..76753aafb 100644 --- a/python/pops/mesh/boundaries/compiled_plan.py +++ b/python/pops/mesh/boundaries/compiled_plan.py @@ -192,9 +192,9 @@ 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", "slip_wall", "external"}: raise ValueError("compiled boundary face has no executable producer type") - if face["type"] in {"periodic", "foextrap", "external"}: + if face["type"] in {"periodic", "foextrap", "slip_wall", "external"}: values = [0.0] * ncomp else: expressions = face.get("values") diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 9021784f1..284086d5c 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -203,3 +203,17 @@ def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): 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"] From 7cf733b8bf080550703c21c9f333bae47cda31d5 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 22:18:51 +0200 Subject: [PATCH 10/93] fix(physics): complete axial variable role ABI --- include/pops/core/state/variables.hpp | 18 ++- python/pops/physics/_coupled_abi.py | 3 + python/pops/runtime/_bricks_time.py | 3 + tests/cpp/unit/runtime/test_variable_role.cpp | 26 +++++ .../runtime/test_axial_slip_wall_pipeline.py | 103 ++++++++++++++++++ .../unit/codegen/test_module_lowering.py | 3 + 6 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/python/integration/runtime/test_axial_slip_wall_pipeline.py 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/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/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/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/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..406d0cf07 --- /dev/null +++ b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py @@ -0,0 +1,103 @@ +"""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 + + +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}) + + 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/unit/codegen/test_module_lowering.py b/tests/python/unit/codegen/test_module_lowering.py index 30b420ba2..b0ce47c11 100644 --- a/tests/python/unit/codegen/test_module_lowering.py +++ b/tests/python/unit/codegen/test_module_lowering.py @@ -36,6 +36,8 @@ _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(): @@ -46,6 +48,7 @@ def test_module_role_lowering_preserves_typed_boundary_semantics(): 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"): From 4c2900888c393e400e0bd120d8516a96eb90086b Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 22:40:12 +0200 Subject: [PATCH 11/93] test: register axial boundary integration proof --- tests/python/architecture/test_final_public_api.py | 4 ++-- tests/python/test_durations.json | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/python/architecture/test_final_public_api.py b/tests/python/architecture/test_final_public_api.py index 1d3e8257d..58d4f7014 100644 --- a/tests/python/architecture/test_final_public_api.py +++ b/tests/python/architecture/test_final_public_api.py @@ -325,8 +325,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"): diff --git a/tests/python/test_durations.json b/tests/python/test_durations.json index 1ab489c79..44b8af007 100644 --- a/tests/python/test_durations.json +++ b/tests/python/test_durations.json @@ -57,6 +57,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, @@ -403,7 +404,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": 220, + "estimated_count": 221, "estimated_files": [ "tests/python/examples/final/test_hyqmom15_final_example.py", "tests/python/examples/final/test_scalar_advection_final_example.py", @@ -440,6 +441,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", @@ -626,6 +628,6 @@ "tests/python/unit/time/test_typed_provenance_guards.py", "tests/python/unit/time/test_typed_schedule.py" ], - "total_files": 399 + "total_files": 400 } } From b399676d8da690f07a095238daced95f7241b7e0 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Tue, 28 Jul 2026 23:23:37 +0200 Subject: [PATCH 12/93] fix(boundary): make slip-wall role lookup type-safe --- python/pops/boundary/transport.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index 4ae0dcf58..1b0ac21c7 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -470,7 +470,8 @@ def resolve_condition( from pops.physics.roles import ComponentRole, native_role_token components = _state_components(self.state, where="SlipWall") - roles = getattr(self.state.space, "roles", None) + 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") From fc9bbe9b37d884c10d3f40f596cb31939d0279d6 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 15:27:17 +0200 Subject: [PATCH 13/93] fix(boundary): preserve mapped periodic topology on master --- .../mesh/boundary/prepared_boundary_plan.hpp | 4 +-- .../boundary/prepared_hyperbolic_boundary.hpp | 5 ++- include/pops/runtime/amr_system.hpp | 19 +++++----- include/pops/runtime/system.hpp | 19 +++++----- python/bindings/core/init/init_amr.cpp | 10 +++--- python/bindings/core/init/init_system.cpp | 10 +++--- src/runtime/amr/amr_system.cpp | 3 +- src/runtime/system/system_install.cpp | 22 +++++------- .../unit/mesh/test_prepared_boundary_plan.cpp | 35 ++++++++++--------- ...est_boundary_component_prepare_contract.py | 13 +++++-- 10 files changed, 68 insertions(+), 72 deletions(-) diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index f094d03b5..184f27a20 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -202,9 +202,7 @@ class PreparedBoundaryPlan { const std::string& state_identity() const { return state_identity_; } int required_depth() const { return required_depth_; } int ncomp() const { return hyperbolic_boundary_.ncomp(); } - const PreparedHyperbolicBoundary<2>& hyperbolic_boundary() const { - return hyperbolic_boundary_; - } + const PreparedHyperbolicBoundary<2>& hyperbolic_boundary() const { return hyperbolic_boundary_; } const std::vector& periodic_identifications() const noexcept { return periodic_identifications_; } diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp index 227c2aa6d..8bf7e5cf7 100644 --- a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -447,9 +447,8 @@ class PreparedHyperbolicBoundary { 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 (!allow_mapped_periodicity_ && - (low.law == HyperbolicBoundaryLaw::Periodic) != - (high.law == HyperbolicBoundaryLaw::Periodic)) + if (!allow_mapped_periodicity_ && (low.law == HyperbolicBoundaryLaw::Periodic) != + (high.law == HyperbolicBoundaryLaw::Periodic)) throw std::invalid_argument( "prepared hyperbolic periodic topology requires complete axis pairs"); } diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 8d5ed0c21..4e7b0221c 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -346,17 +346,14 @@ class AmrSystem { /// Install the same executable per-block ghost authority as System. Presence of a resolved plan /// selects the N-level AmrRuntime route, whose Program RHS composes same-level MPI, the authored /// coarse/fine transfer authority, and these physical faces. - 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, - 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 = {}); + 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, + 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 = {}); /// Compatibility adapter for the historical component-count ABI. POPS_EXPORT void install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 772947a9d..ca773c457 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -321,17 +321,14 @@ class System { POPS_EXPORT GridContext grid_context(int block); /// 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, - 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 = {}); + 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, + 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 = {}); /// Compatibility adapter for the historical component-count ABI. POPS_EXPORT void install_boundary_plan( const std::string& name, const std::string& identity, int required_depth, diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index d5aa21ef7..a322162d2 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -240,11 +240,11 @@ void bind_amr_assembly(py::class_& cls) { const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, const std::vector>& periodic_identifications) { - system.install_boundary_plan(name, identity, required_depth, face_types, face_values, - face_identities, component_roles, omitted_interface_faces, - state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows( - periodic_identifications)); + system.install_boundary_plan( + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows(periodic_identifications)); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), py::arg("face_values"), py::arg("face_identities"), py::arg("component_roles"), diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index 5b36ccaba..ffd74f220 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -174,11 +174,11 @@ void bind_system_assembly(py::class_& cls) { const std::vector& component_roles, const std::vector& omitted_interface_faces, const std::string& state_identity, const std::vector>& periodic_identifications) { - system.install_boundary_plan(name, identity, required_depth, face_types, face_values, - face_identities, component_roles, omitted_interface_faces, - state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows( - periodic_identifications)); + system.install_boundary_plan( + name, identity, required_depth, face_types, face_values, face_identities, + component_roles, omitted_interface_faces, state_identity, + PreparedBoundaryReadDependencies{}, + decode_periodic_identification_rows(periodic_identifications)); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), py::arg("face_values"), py::arg("face_identities"), py::arg("component_roles"), diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index d9e9ae099..168ac7f3f 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1348,8 +1348,7 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( throw std::runtime_error( "AmrSystem::install_boundary_plan state differs from the exact block state route"); auto hyperbolic = prepare_hyperbolic_boundary<2>( - face_types, face_values, face_identities, component_roles, - !periodic_identifications.empty()); + face_types, face_values, face_identities, component_roles, !periodic_identifications.empty()); auto plan = std::make_shared( identity, required_depth, std::move(hyperbolic), omitted_interface_faces, state_identity, std::move(read_dependencies), std::move(periodic_identifications)); diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index 6c848034c..f5f79ef85 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -305,17 +305,14 @@ POPS_EXPORT void System::install_block_state_route(const std::string& name, P->block_state_identities_.emplace(name, state_identity); } -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, - 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) { +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, + 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) { Impl* P = p_.get(); require_assembling(P->lifecycle_, "install_boundary_plan"); if (name.empty() || state_identity.empty()) @@ -328,8 +325,7 @@ POPS_EXPORT void System::install_boundary_plan(const std::string& name, const st if (P->boundary_plans_.count(name) != 0) throw std::runtime_error("System::install_boundary_plan duplicate block '" + name + "'"); auto hyperbolic = prepare_hyperbolic_boundary<2>( - face_types, face_values, face_identities, component_roles, - !periodic_identifications.empty()); + face_types, face_values, face_identities, component_roles, !periodic_identifications.empty()); auto plan = std::make_shared( identity, required_depth, std::move(hyperbolic), omitted_interface_faces, state_identity, std::move(read_dependencies), std::move(periodic_identifications)); diff --git a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index 5ac2a0a83..4d1d2c3b1 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -29,13 +29,19 @@ PreparedHyperbolicBoundary<2> physical_boundary(std::vector xhi_values = 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_x_boundary() { + return prepare_hyperbolic_boundary<2>({"periodic", "periodic", "foextrap", "foextrap"}, + std::vector(4, 0.0), + {"case::periodic-x::xlo", "case::periodic-x::xhi", + "case::periodic-x::ylo", "case::periodic-x::yhi"}, + {"Scalar"}); +} + +PreparedHyperbolicBoundary<2> rotated_periodic_boundary() { + return prepare_hyperbolic_boundary<2>( + {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(4, 0.0), + {"case::rotated::xlo", "case::rotated::xhi", "case::rotated::ylo", "case::rotated::yhi"}, + {"Scalar"}, true); } PreparedBoundaryComponentSpec linearization_spec(bool jvp, std::string target, std::string output) { @@ -273,7 +279,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_x_boundary(), {}, "", {}, {reflected_x}); plan.fill_same_level_and_physical(state, domain); @@ -314,9 +320,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_x_boundary()); PreparedBoundaryPlan explicit_plan("case::block::explicit-identity-periodic", 1, - {reflected_x_periodic_bc()}, {}, "", {}, {identity}); + periodic_x_boundary(), {}, "", {}, {identity}); legacy_plan.fill_same_level_and_physical(legacy, domain); explicit_plan.fill_same_level_and_physical(explicit_identity, domain); @@ -332,15 +338,10 @@ 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, rotated_periodic_boundary(), {}, "", + {}, {xlo_to_yhi}); const Box2D rectangular_domain = Box2D::from_extents(8, 6); MultiFab state = scalar_field(rectangular_domain, 1, 1); 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 5cf3989ad..526939c18 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -159,6 +159,7 @@ def boundary_identity(name, axis, side): "faces": [ { "ordinal": ordinal, + "producer": "case::block::reflected-periodic::face::%d" % ordinal, "type": "periodic" if ordinal < 2 else "foextrap", "values": [0.0], } @@ -204,7 +205,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),)), ) @@ -219,4 +221,11 @@ class BoundaryBlock: 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[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, 1, 0, 1, 1, -1]] From 99966df9a54509eaa83de3273a5cc0311038c45c Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 15:46:40 +0200 Subject: [PATCH 14/93] test(boundary): migrate MPI plan to typed faces --- .../integration/mpi/test_mpi_fillboundary.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp b/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp index 43ada0850..4f935f185 100644 --- a/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp +++ b/tests/cpp/integration/mpi/test_mpi_fillboundary.cpp @@ -24,6 +24,8 @@ #include #include #include +#include +#include using namespace pops; @@ -116,15 +118,15 @@ 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; + auto boundary = prepare_hyperbolic_boundary<2>( + {"periodic", "periodic", "foextrap", "foextrap"}, std::vector(4, 0.0), + {"test::mpi::reflected-periodic::xlo", "test::mpi::reflected-periodic::xhi", + "test::mpi::reflected-periodic::ylo", "test::mpi::reflected-periodic::yhi"}, + {"Scalar"}, true); 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}); + PreparedBoundaryPlan plan("test::mpi::reflected-periodic", mapped_ng, std::move(boundary), {}, + "", {}, {reflected_x}); plan.fill_same_level_and_physical(mapped, mapped_domain); From c99b48fe3493e265b2efc93d2f4486b1f7490ee7 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 15:34:09 +0200 Subject: [PATCH 15/93] fix(boundary): reject unprepared characteristic fallback --- python/pops/boundary/transport.py | 8 +++ .../unit/boundary/test_transport_authoring.py | 63 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index 1b0ac21c7..0dffe6097 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -560,6 +560,8 @@ def compose_ghost_plan(self, context: Any) -> Any: def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportCondition, ...], int]: """Validate the complete compile-time shape of the built-in native provider.""" + from pops.mesh.boundaries import ClosureMode + states = {row.state for row in self.conditions} if len(states) != 1: raise NotImplementedError( @@ -584,6 +586,12 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio face_rows[face] = condition depth = max(depth, condition.requirement.ghost_depth) dependencies = condition.provider.dependencies + if dependencies.characteristic.mode is not ClosureMode.NONE: + raise NotImplementedError( + "native transport boundary lowering requires prepared model eigenstructure " + "for characteristic closure; directional modes cannot fall back to " + "component-wise ghost filling" + ) flow = dependencies.representation if flow.converter is not None or flow.source != flow.target: raise NotImplementedError( diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 284086d5c..739c4e2bd 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import replace + import pytest import pops @@ -138,6 +140,67 @@ def test_transport_conditions_require_instance_handles_and_exact_component_cover 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) + + authority = case._resolved_numerics_for("tracer").boundaries[0] + with pytest.raises( + NotImplementedError, + match="prepared model eigenstructure.*cannot fall back", + ): + authority.compile_boundary_data() + + def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): frame, _, _, _, numerics, case, block, block_state = _authoring() numerics.boundaries.add(TransportBoundarySet({ From f1fa2ff2b78f32094afc3f6ddc269add1855b1b4 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 15:39:14 +0200 Subject: [PATCH 16/93] refactor(boundary): remove scalar component-count adapter --- include/pops/runtime/amr_system.hpp | 7 ------- include/pops/runtime/system.hpp | 7 ------- src/runtime/amr/amr_system.cpp | 18 ------------------ src/runtime/system/system_install.cpp | 18 ------------------ ...test_boundary_component_prepare_contract.py | 18 ++++++++++++++++++ 5 files changed, 18 insertions(+), 50 deletions(-) diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 4e7b0221c..35b2bf1ec 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -354,13 +354,6 @@ class AmrSystem { const std::vector& omitted_interface_faces = {}, const std::string& state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}, std::vector periodic_identifications = {}); - /// Compatibility adapter for the historical component-count ABI. - 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& omitted_interface_faces, const std::string& state_identity, - PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications); /// 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); diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index ca773c457..980d19f68 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -329,13 +329,6 @@ class System { const std::vector& omitted_interface_faces = {}, const std::string& state_identity = {}, PreparedBoundaryReadDependencies read_dependencies = {}, std::vector periodic_identifications = {}); - /// Compatibility adapter for the historical component-count ABI. - 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& omitted_interface_faces, const std::string& state_identity, - PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications); /// 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. diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index 168ac7f3f..74fae345a 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1359,24 +1359,6 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( P->boundary_plans_.emplace(name, std::move(plan)); } -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& omitted_interface_faces, const std::string& state_identity, - PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications) { - if (ncomp < 1) - throw std::runtime_error("AmrSystem::install_boundary_plan requires at least one component"); - std::vector face_identities; - face_identities.reserve(4); - for (int face = 0; face < 4; ++face) - face_identities.push_back(identity + ".face." + std::to_string(face)); - install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, - std::vector(static_cast(ncomp), "Scalar"), - omitted_interface_faces, state_identity, std::move(read_dependencies), - std::move(periodic_identifications)); -} - POPS_EXPORT void AmrSystem::install_field_storage_route(const std::string& field_identity, const std::string& provider_slot) { Impl* P = p_.get(); diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index f5f79ef85..a18780425 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -335,24 +335,6 @@ POPS_EXPORT void System::install_boundary_plan( P->boundary_plans_.emplace(name, std::move(plan)); } -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& omitted_interface_faces, const std::string& state_identity, - PreparedBoundaryReadDependencies read_dependencies, - std::vector periodic_identifications) { - if (ncomp < 1) - throw std::runtime_error("System::install_boundary_plan requires at least one component"); - std::vector face_identities; - face_identities.reserve(4); - for (int face = 0; face < 4; ++face) - face_identities.push_back(identity + ".face." + std::to_string(face)); - install_boundary_plan(name, identity, required_depth, face_types, face_values, face_identities, - std::vector(static_cast(ncomp), "Scalar"), - omitted_interface_faces, state_identity, std::move(read_dependencies), - std::move(periodic_identifications)); -} - POPS_EXPORT void System::install_field_storage_route(const std::string& field_identity, const std::string& provider_slot) { Impl* P = p_.get(); 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 526939c18..43a533e70 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) From 1519e61300a889071d56eabd581b7ee3a3e57fed Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 17:30:38 +0200 Subject: [PATCH 17/93] feat(boundary): report prepared transport capabilities --- docs/design/native-capability-matrix.md | 7 ++ python/pops/_capabilities_report.py | 86 +++++++++++++++++++ .../unit/codegen/test_fail_closed_reports.py | 44 ++++++++++ 3 files changed, 137 insertions(+) diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index a78cd978a..7b2532f61 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -98,6 +98,13 @@ 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. +- 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, and typed-role slip wall. + Separate `unavailable` rows expose the missing characteristic no-inflow kernel, non-identity + representation conversion, device-side analytic `(x,t,params)` data, and post-Riemann flux + transformation. These requests fail during resolution or lowering; none silently degrades to + component-wise ghost filling. - Native Riemann routes: Rusanov, HLL, HLLC, Roe, subject to model capability requirements. - Native reconstruction routes: first-order, MUSCL, WENO5/WENO5-Z. - Elliptic GeometricMG on Uniform/AMR and FFT on uniform periodic constant-coefficient grids. diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 8bd34a507..96233abd2 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -374,6 +374,92 @@ 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, and typed-role slip wall, with " + "double-physical corners explicitly not required by dimension-split FV stencils" + ), + source=source, + ), + _row( + "boundary:characteristic_no_inflow", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "the prepared transport plan rejects characteristic closure until executable " + "model eigenstructure, incoming-mode data, and sonic/sign policies are installed" + ), + requested="characteristic no-inflow/outflow transport boundary", + available_route="explicit fixed-state inflow or extrapolated outflow", + alternative=( + "use the explicit built-in route or install a prepared characteristic kernel" + ), + source=source, + ), + _row( + "boundary:representation_conversion", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "primitive/conservative boundary conversion has no built-in executable provider; " + "a non-identity RepresentationFlow is rejected before native installation" + ), + requested="transport boundary data in a non-state representation", + available_route="boundary data in the evolved state's representation", + alternative="install an authored compiled representation-conversion provider", + source=source, + ), + _row( + "boundary:analytic_xtp", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "the built-in inflow evaluator accepts constants and RuntimeParams only; " + "coordinate-, state-, field-, or time-dependent expressions are rejected" + ), + requested="device-side analytic boundary data depending on (x,t,params)", + available_route="constant or RuntimeParam fixed-state inflow", + alternative="install a compiled ghost-boundary component", + source=source, + ), + _row( + "boundary:post_riemann_flux", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "the prepared boundary component ABI has ghost, residual, and JVP operations " + "but no post-Riemann numerical-flux transformation port" + ), + requested="post-Riemann transport-boundary flux provider", + available_route="prepared ghost-state/exterior-state transport boundary", + alternative="add the typed NumericalFlux boundary component interface", + source=source, + ), _row( "amr:field_coupled_rhs_jacvec", layout="amr", diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 1b3a966fc..4ef58b33e 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -74,6 +74,50 @@ def test_mpi_world_route_reports_only_proved_native_availability(supports_mpi, e assert amr_implicit.layout == "amr" +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 "corners explicitly not required" in prepared.limitation + + expected_unavailable = { + "boundary:characteristic_no_inflow": ( + "executable model eigenstructure", + "prepared characteristic kernel", + ), + "boundary:representation_conversion": ( + "non-identity RepresentationFlow", + "representation-conversion provider", + ), + "boundary:analytic_xtp": ( + "constants and RuntimeParams only", + "compiled ghost-boundary component", + ), + "boundary:post_riemann_flux": ( + "no post-Riemann numerical-flux transformation port", + "NumericalFlux boundary component interface", + ), + } + for feature, (limitation, alternative) in expected_unavailable.items(): + route = routes[feature] + assert route.status == "unavailable" + assert route.layout == "uniform|amr" + assert limitation in route.limitation + assert alternative in route.alternative + assert route.error_message + + def test_defaults_source_only_is_not_used_for_a_loaded_broken_extension(monkeypatch): monkeypatch.setattr(defaults, "_native_extension", lambda: None) assert defaults.numerical_defaults_report()["source"] == "source-only" From 033c4b3ad4b871d2dc8cbae31a535c7d69e559bf Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 18:07:50 +0200 Subject: [PATCH 18/93] feat(boundary): prepare primitive fixed inflow with model conversion --- docs/design/native-capability-matrix.md | 17 ++- .../mesh/boundary/prepared_boundary_plan.hpp | 23 +++- .../boundary/prepared_hyperbolic_boundary.hpp | 108 +++++++++++++++++- include/pops/runtime/amr_system.hpp | 4 +- .../builders/compiled/amr_dsl_block.hpp | 9 +- include/pops/runtime/system.hpp | 4 +- python/bindings/core/init/init_amr.cpp | 9 +- python/bindings/core/init/init_system.cpp | 9 +- python/pops/_capabilities_report.py | 16 +-- python/pops/boundary/__init__.py | 2 + python/pops/boundary/transport.py | 80 +++++++++++-- python/pops/mesh/boundaries/compiled_plan.py | 14 +++ python/pops/runtime/_runtime_authorities.py | 18 +++ src/runtime/amr/amr_system.cpp | 7 +- src/runtime/system/system_fields.cpp | 8 ++ src/runtime/system/system_install.cpp | 7 +- .../amr/test_amr_system_contract.cpp | 57 +++++++++ .../unit/mesh/test_prepared_boundary_plan.cpp | 76 ++++++++++++ .../runtime/test_program_context_contract.cpp | 50 ++++++++ .../unit/boundary/test_transport_authoring.py | 81 ++++++++++++- .../unit/codegen/test_fail_closed_reports.py | 12 +- ...est_boundary_component_prepare_contract.py | 2 + 22 files changed, 570 insertions(+), 43 deletions(-) diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index 7b2532f61..22b708db3 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -100,11 +100,18 @@ Supported native routes include: - Finite-volume spatial discretisation on the 2D core. - 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, and typed-role slip wall. - Separate `unavailable` rows expose the missing characteristic no-inflow kernel, non-identity - representation conversion, device-side analytic `(x,t,params)` data, and post-Riemann flux - transformation. These requests fail during resolution or lowering; none silently degrades to - component-wise ghost filling. + periodicity, extrapolation, constant or `RuntimeParam` fixed state, fixed-state primitive inflow + converted once through the exact compiled block-model `to_conservative` provider, and typed-role + slip wall. 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. Separate `unavailable` rows expose the missing characteristic + no-inflow kernel, device-side analytic `(x,t,params)` data, and post-Riemann flux transformation. + These requests fail during resolution or lowering; none silently degrades to component-wise + ghost filling. 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 to model capability requirements. - Native reconstruction routes: first-order, MUSCL, WENO5/WENO5-Z. - Elliptic GeometricMG on Uniform/AMR and FFT on uniform periodic constant-coefficient grids. diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index 184f27a20..063107fa1 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. /// @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -203,6 +205,21 @@ class PreparedBoundaryPlan { int required_depth() const { return required_depth_; } 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_; + } const std::vector& periodic_identifications() const noexcept { return periodic_identifications_; } diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp index 8bf7e5cf7..6beb40019 100644 --- a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,12 @@ enum class HyperbolicBoundaryLaw { Periodic, Extrapolate, FixedState, Reflective 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 @@ -102,6 +109,10 @@ struct PreparedHyperbolicFace { 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; }; /// Dimension-split FV stencils do not read double-physical corners. Such corners are therefore @@ -304,6 +315,15 @@ inline HyperbolicBoundaryLaw hyperbolic_law_from_token(std::string_view 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 @@ -340,6 +360,56 @@ class PreparedHyperbolicBoundary { } HyperbolicCornerPolicy corner_policy() const { return corner_policy_; } + 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_, allow_mapped_periodicity_); + } + Periodicity periodicity() const { static_assert(Dim == 2, "the current MultiFab topology is two-dimensional"); if ((faces_[0].law == HyperbolicBoundaryLaw::Periodic) != @@ -360,6 +430,9 @@ class PreparedHyperbolicBoundary { /// ghosts are included because they were already produced by fill_boundary and are valid inputs. void fill_physical(MultiFab& state, const Box2D& domain) 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"); @@ -462,9 +535,24 @@ class PreparedHyperbolicBoundary { [](Real value) { return !std::isfinite(value); })) throw std::invalid_argument( "fixed-state hyperbolic boundary must provide one finite value per component"); + 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 a fixed-state hyperbolic boundary 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; @@ -525,7 +613,9 @@ 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 allow_mapped_periodicity = false) { + const std::vector& component_roles, bool allow_mapped_periodicity = false, + const std::vector& face_representations = {}, + const std::vector& face_converter_identities = {}) { if (face_types.size() != static_cast(2 * Dim) || face_identities.size() != static_cast(2 * Dim)) throw std::invalid_argument( @@ -534,6 +624,12 @@ PreparedHyperbolicBoundary prepare_hyperbolic_boundary( 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"); std::vector> transforms; transforms.reserve(component_roles.size()); @@ -546,12 +642,22 @@ PreparedHyperbolicBoundary prepare_hyperbolic_boundary( 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::FixedState) { 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; } } return PreparedHyperbolicBoundary(std::move(faces), std::move(transforms), diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 35b2bf1ec..d9dcdbc4c 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -353,7 +353,9 @@ class AmrSystem { 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 = {}); /// 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); diff --git a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp index ed83da32f..01ff570e3 100644 --- a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp +++ b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp @@ -207,12 +207,17 @@ 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; } + if (prepared_boundary_plan && prepared_boundary_plan->requires_fixed_state_conversion()) { + auto conversion = make_cell_convert(model); + prepared_boundary_plan->prepare_fixed_state_conversion(conversion.first); + } + std::shared_ptr boundary_plan = prepared_boundary_plan; BCRec transport_bc; if (!S.base_per.x) transport_bc.xlo = transport_bc.xhi = BCType::Foextrap; diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 980d19f68..49681fad4 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -328,7 +328,9 @@ class System { 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 = {}); /// 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. diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index a322162d2..d55d73587 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -239,18 +239,23 @@ void bind_amr_assembly(py::class_& cls) { 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) { system.install_boundary_plan( name, identity, required_depth, face_types, face_values, face_identities, component_roles, omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows(periodic_identifications)); + decode_periodic_identification_rows(periodic_identifications), face_representations, + face_converter_identities); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), 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{}, "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"), diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index ffd74f220..822c48200 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -173,18 +173,23 @@ void bind_system_assembly(py::class_& cls) { 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) { system.install_boundary_plan( name, identity, required_depth, face_types, face_values, face_identities, component_roles, omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, - decode_periodic_identification_rows(periodic_identifications)); + decode_periodic_identification_rows(periodic_identifications), face_representations, + face_converter_identities); }, py::arg("name"), py::arg("identity"), py::arg("required_depth"), py::arg("face_types"), 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{}, "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"), diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 96233abd2..c7b1601de 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -385,7 +385,8 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: 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, and typed-role slip wall, with " + "constant/RuntimeParam fixed state, model primitive-to-conservative fixed-state " + "conversion, and typed-role slip wall, with " "double-physical corners explicitly not required by dimension-split FV stencils" ), source=source, @@ -412,18 +413,17 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: _row( "boundary:representation_conversion", layout="uniform|amr", - backend="none", + backend="production", platform="host", mpi=mpi, gpu=gpu, - status="unavailable", + status="partial", limitation=( - "primitive/conservative boundary conversion has no built-in executable provider; " - "a non-identity RepresentationFlow is rejected before native installation" + "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" ), - requested="transport boundary data in a non-state representation", - available_route="boundary data in the evolved state's representation", - alternative="install an authored compiled representation-conversion provider", source=source, ), _row( diff --git a/python/pops/boundary/__init__.py b/python/pops/boundary/__init__.py index 867f08406..8e0849939 100644 --- a/python/pops/boundary/__init__.py +++ b/python/pops/boundary/__init__.py @@ -7,6 +7,7 @@ from .transport import ( BoundaryStencilRequirement, + model_primitive_to_conservative, SlipWall, TransportBoundarySet, ) @@ -15,6 +16,7 @@ __all__ = [ "BoundaryStencilRequirement", "EmbeddedBoundaryFlux", + "model_primitive_to_conservative", "SlipWall", "TransportBoundarySet", "ZeroFlux", diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index 0dffe6097..725175f05 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -75,6 +75,27 @@ 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 _condition_protocol(value: Any, *, where: str) -> Any: _state(getattr(value, "state", None), where="%s.state" % where) for method in ("inspect", "resolve_references", "resolve_condition"): @@ -339,9 +360,17 @@ def declaration_references(self) -> tuple[Handle, ...]: 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) 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, @@ -592,12 +621,7 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio "for characteristic closure; directional modes cannot fall back to " "component-wise ghost filling" ) - 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" - ) + self._native_representation_contract(condition, state) if condition.condition_type == "inflow": if dependencies.states or dependencies.fields or dependencies.time: raise NotImplementedError( @@ -616,6 +640,37 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio raise ValueError("native transport boundary has incomplete physical-face coverage") 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. @@ -642,6 +697,10 @@ def compile_boundary_data(self) -> dict[str, Any]: "inflow": "dirichlet", "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"] @@ -698,6 +757,10 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: "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, } rows = tuple(row for row in face_rows if row is not None) @@ -925,6 +988,7 @@ def labels(rows: Any) -> list[str]: __all__ = [ "BoundaryStencilRequirement", "Inflow", + "model_primitive_to_conservative", "Outflow", "ResolvedTransportBoundarySet", "ResolvedTransportCondition", diff --git a/python/pops/mesh/boundaries/compiled_plan.py b/python/pops/mesh/boundaries/compiled_plan.py index 76753aafb..bfd917cc7 100644 --- a/python/pops/mesh/boundaries/compiled_plan.py +++ b/python/pops/mesh/boundaries/compiled_plan.py @@ -194,6 +194,18 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: if not isinstance(face, dict) or face.get("type") not in { "periodic", "foextrap", "dirichlet", "slip_wall", "external"}: raise ValueError("compiled boundary face has no executable producer type") + 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", "slip_wall", "external"}: values = [0.0] * ncomp else: @@ -218,6 +230,8 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: "geometry": face.get("geometry"), "producer": face.get("producer"), "type": face["type"], + "representation": representation, + "converter": converter, "values": values, }) faces.sort(key=lambda row: row["ordinal"]) diff --git a/python/pops/runtime/_runtime_authorities.py b/python/pops/runtime/_runtime_authorities.py index 12d99278f..27fe7f605 100644 --- a/python/pops/runtime/_runtime_authorities.py +++ b/python/pops/runtime/_runtime_authorities.py @@ -162,6 +162,22 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: "periodic", "foextrap", "dirichlet", "slip_wall", "external"} for value in types): raise NotImplementedError("prepared boundary plan selected an unavailable face producer") + 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) 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( @@ -198,6 +214,8 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: list(first.get("omitted_interface_faces", [])), state_identity, periodic_identifications, + representations, + ["" if value is None else value for value in converter_identities], ) component_rows = first.get("component_regions", []) if not isinstance(component_rows, list): diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index 74fae345a..29b5db04b 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1335,7 +1335,9 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( 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) { Impl* P = p_.get(); require_assembling_amr(P->bound_, "install_boundary_plan"); if (P->built) @@ -1348,7 +1350,8 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( throw std::runtime_error( "AmrSystem::install_boundary_plan state differs from the exact block state route"); auto hyperbolic = prepare_hyperbolic_boundary<2>( - face_types, face_values, face_identities, component_roles, !periodic_identifications.empty()); + face_types, face_values, face_identities, component_roles, !periodic_identifications.empty(), + face_representations, face_converter_identities); auto plan = std::make_shared( identity, required_depth, std::move(hyperbolic), omitted_interface_faces, state_identity, std::move(read_dependencies), std::move(periodic_identifications)); diff --git a/src/runtime/system/system_fields.cpp b/src/runtime/system/system_fields.cpp index 460c04b42..230590730 100644 --- a/src/runtime/system/system_fields.cpp +++ b/src/runtime/system/system_fields.cpp @@ -62,6 +62,14 @@ 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) { Impl::Species& s = p_->find(name); + const auto boundary = p_->boundary_plans_.find(name); + if (boundary != p_->boundary_plans_.end() && + boundary->second->requires_fixed_state_conversion()) { + if (!prim_to_cons) + throw std::runtime_error( + "System primitive fixed-state boundary requires the block-model conversion"); + boundary->second->prepare_fixed_state_conversion(prim_to_cons); + } s.prim_to_cons = std::move(prim_to_cons); s.cons_to_prim = std::move(cons_to_prim); } diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index a18780425..546ea6c74 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -312,7 +312,9 @@ POPS_EXPORT void System::install_boundary_plan( 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) { Impl* P = p_.get(); require_assembling(P->lifecycle_, "install_boundary_plan"); if (name.empty() || state_identity.empty()) @@ -325,7 +327,8 @@ POPS_EXPORT void System::install_boundary_plan( if (P->boundary_plans_.count(name) != 0) throw std::runtime_error("System::install_boundary_plan duplicate block '" + name + "'"); auto hyperbolic = prepare_hyperbolic_boundary<2>( - face_types, face_values, face_identities, component_roles, !periodic_identifications.empty()); + face_types, face_values, face_identities, component_roles, !periodic_identifications.empty(), + face_representations, face_converter_identities); auto plan = std::make_shared( identity, required_depth, std::move(hyperbolic), omitted_interface_faces, state_identity, std::move(read_dependencies), std::move(periodic_identifications)); diff --git a/tests/cpp/integration/amr/test_amr_system_contract.cpp b/tests/cpp/integration/amr/test_amr_system_contract.cpp index a90f6eda1..3e3fe55f0 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 @@ -391,6 +392,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; diff --git a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index 4d1d2c3b1..d0ae8244f 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -158,6 +159,81 @@ 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, + 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, model_aware_slip_wall_handles_multiple_normal_and_out_of_plane_components) { const Box2D domain = Box2D::from_extents(4, 4); diff --git a/tests/cpp/unit/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index 20d47a6e9..72e96b454 100644 --- a/tests/cpp/unit/runtime/test_program_context_contract.cpp +++ b/tests/cpp/unit/runtime/test_program_context_contract.cpp @@ -443,6 +443,56 @@ TEST(ProgramContextContract, SystemPreparedSlipWallFillsDeepPhysicalGhosts) { } } +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, GeneratedScratchIsPersistentExactAndNonAliasing) { ensure_kokkos(); SystemConfig cfg; diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 739c4e2bd..3f6323ce1 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -5,7 +5,7 @@ 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, SlipWall from pops.domain import Rectangle @@ -16,7 +16,7 @@ from pops.numerics.spatial import FiniteVolume from pops.params import RuntimeParam from pops.physics import Axial, Density, Momentum -from pops.representations import Conservative +from pops.representations import Conservative, Primitive from pops.spaces import CellState @@ -113,6 +113,83 @@ def test_transport_set_resolves_exact_ports_values_and_derived_stencil_requireme } +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), + ) + forged_authority = replace( + authority, + conditions=tuple( + forged_condition if row is converted_condition else row + for row in authority.conditions + ), + ) + with pytest.raises(NotImplementedError, match="exact model_primitive_to_conservative"): + forged_authority.compile_boundary_data() + + def test_transport_set_rejects_incomplete_geometry_at_resolution(): frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() numerics.boundaries.add(TransportBoundarySet({ diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 4ef58b33e..48c596564 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -89,17 +89,21 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k assert prepared.gpu is False assert "one prepared 2D model-aware plan" in prepared.limitation assert "typed-role slip wall" in prepared.limitation + assert "model primitive-to-conservative" 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 + expected_unavailable = { "boundary:characteristic_no_inflow": ( "executable model eigenstructure", "prepared characteristic kernel", ), - "boundary:representation_conversion": ( - "non-identity RepresentationFlow", - "representation-conversion provider", - ), "boundary:analytic_xtp": ( "constants and RuntimeParams only", "compiled ghost-boundary component", 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 43a533e70..d5ae8008b 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -247,3 +247,5 @@ class BoundaryBlock: ] assert native.installed[6] == ["Scalar"] assert native.installed[9] == [[0, 1, 0, 1, 1, -1]] + assert native.installed[10] == ["conservative"] * 4 + assert native.installed[11] == [""] * 4 From 8d80201092b0d5c4eff803e2bc7f1f3d2e7816d3 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 18:38:33 +0200 Subject: [PATCH 19/93] fix(model): reject unequal representation arities --- .../runtime/builders/block/block_builder.hpp | 7 ++++++ python/pops/codegen/module_emit_brick.py | 6 +++++ .../unit/codegen/test_representation_arity.py | 23 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 tests/python/unit/codegen/test_representation_arity.py diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index 6aeb0af88..454c7abd9 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -907,6 +907,13 @@ std::pair, std::function) { + 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) diff --git a/python/pops/codegen/module_emit_brick.py b/python/pops/codegen/module_emit_brick.py index 1bdc4c745..de7c6c9b7 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) 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() From 0190677a2452225711f654771c02e17633f2e7c6 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 18:42:28 +0200 Subject: [PATCH 20/93] docs(model): state the flat conversion arity contract --- include/pops/runtime/builders/block/block_builder.hpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index 454c7abd9..73c8b62a4 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -899,8 +899,9 @@ std::function make_poisson_rhs(const Model& m) /// 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): +/// 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> From 6e26f986dce27f50ce97d7db1560facbce99eb61 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 18:46:01 +0200 Subject: [PATCH 21/93] test(boundary): qualify AMR physical and coarse-fine ownership --- docs/design/native-capability-matrix.md | 5 +- python/pops/_capabilities_report.py | 3 +- tests/CMakeLists.txt | 2 + .../mpi/test_mpi_amr_prepared_boundary_cf.cpp | 271 ++++++++++++++++++ tests/cpp/test_sources.cmake | 1 + .../test_ci_impacted_selection.py | 2 +- .../unit/codegen/test_fail_closed_reports.py | 1 + tests/test_manifest.toml | 6 + 8 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index 22b708db3..5491ef52d 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -107,7 +107,10 @@ Supported native routes include: boundary admissibility projection. Separate `unavailable` rows expose the missing characteristic no-inflow kernel, device-side analytic `(x,t,params)` data, and post-Riemann flux transformation. These requests fail during resolution or lowering; none silently degrades to component-wise - ghost filling. The explicit public route is + 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. diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index c7b1601de..8c7e19239 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -386,7 +386,8 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: "one prepared 2D model-aware plan serves Uniform/AMR native and compiled " "transport boundaries; executable built-ins are periodic, extrapolation, " "constant/RuntimeParam fixed state, model primitive-to-conservative fixed-state " - "conversion, and typed-role slip wall, with " + "conversion, and typed-role slip wall; 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" ), source=source, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5553f9a61..b62bf9a49 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -781,6 +781,7 @@ if(POPS_HAS_MPI) 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_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) @@ -823,6 +824,7 @@ if(POPS_HAS_MPI) test_mpi_composite_fac test_amr_regrid_mpi_parity test_mpi_amr_dynamic_active_depth + test_mpi_amr_prepared_boundary_cf test_mpi_system_solve_fields test_mpi_system_fft test_mpi_system_analytic_level_set 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..5dce88dc0 --- /dev/null +++ b/tests/cpp/integration/mpi/test_mpi_amr_prepared_boundary_cf.cpp @@ -0,0 +1,271 @@ +// 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 { + +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.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/test_sources.cmake b/tests/cpp/test_sources.cmake index 8dee480e5..7841a6ec3 100644 --- a/tests/cpp/test_sources.cmake +++ b/tests/cpp/test_sources.cmake @@ -123,6 +123,7 @@ 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_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_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") diff --git a/tests/python/architecture/test_ci_impacted_selection.py b/tests/python/architecture/test_ci_impacted_selection.py index 3e616d3a1..466404768 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 == 83 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 diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 48c596564..84aca249d 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -90,6 +90,7 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k assert "one prepared 2D model-aware plan" in prepared.limitation assert "typed-role slip wall" 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"] diff --git a/tests/test_manifest.toml b/tests/test_manifest.toml index 4d99ca2ad..441907ba2 100644 --- a/tests/test_manifest.toml +++ b/tests/test_manifest.toml @@ -165,6 +165,12 @@ 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_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"] From 3ee7b77f7b70fe0b03ac6da90a7e8399e64178e2 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 20:58:09 +0200 Subject: [PATCH 22/93] feat(boundary): execute analytic inflow on native devices --- docs/design/native-capability-matrix.md | 17 +- .../mesh/boundary/prepared_boundary_plan.hpp | 28 +- .../boundary/prepared_hyperbolic_boundary.hpp | 357 +++++++++++++++++- include/pops/runtime/amr_system.hpp | 5 +- include/pops/runtime/context/grid_context.hpp | 2 +- include/pops/runtime/system.hpp | 5 +- python/bindings/core/init/init_amr.cpp | 11 +- python/bindings/core/init/init_system.cpp | 11 +- python/pops/_capabilities_report.py | 18 +- python/pops/analytic/__init__.py | 2 + python/pops/analytic/_functions.py | 8 + python/pops/analytic/_model.py | 95 ++++- python/pops/boundary/transport.py | 226 +++++++++-- python/pops/mesh/boundaries/compiled_plan.py | 61 ++- .../runtime/_analytic_expression_lowering.py | 29 +- python/pops/runtime/_runtime_authorities.py | 51 +++ python/pops/time/points.py | 15 + src/runtime/amr/amr_system.cpp | 8 +- src/runtime/system/system_install.cpp | 8 +- .../unit/mesh/test_prepared_boundary_plan.cpp | 102 +++++ .../analytic/test_analytic_expressions.py | 26 ++ .../unit/boundary/test_transport_authoring.py | 134 +++++++ .../unit/codegen/test_fail_closed_reports.py | 12 +- .../test_analytic_expression_lowering.py | 26 +- ...est_boundary_component_prepare_contract.py | 14 +- 25 files changed, 1154 insertions(+), 117 deletions(-) diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index 5491ef52d..90ba6d34e 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -100,12 +100,17 @@ Supported native routes include: - Finite-volume spatial discretisation on the 2D core. - 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, fixed-state primitive inflow - converted once through the exact compiled block-model `to_conservative` provider, and typed-role - slip wall. 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. Separate `unavailable` rows expose the missing characteristic - no-inflow kernel, device-side analytic `(x,t,params)` data, and post-Riemann flux transformation. + 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, and typed-role slip wall. + 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 + 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. 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. Separate `unavailable` rows expose the missing characteristic no-inflow kernel and + post-Riemann flux transformation. 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 diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index 063107fa1..61928964a 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -130,6 +130,9 @@ 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; @@ -427,7 +430,7 @@ class PreparedBoundaryPlan { "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); fill_native_halos_(state, geometry.domain); - hyperbolic_boundary_.fill_physical(state, geometry.domain); + hyperbolic_boundary_.fill_physical(state, geometry); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry, @@ -437,7 +440,7 @@ class PreparedBoundaryPlan { "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); fill_native_halos_(state, geometry.domain, lane); - hyperbolic_boundary_.fill_physical(state, geometry.domain); + hyperbolic_boundary_.fill_physical(state, geometry, lane.communicator()); } /// One-shot control/diagnostic adapter. It materializes a fresh component session and workspace; @@ -787,7 +790,20 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); plan_->validate_for(state); plan_->fill_native_halos_(state, geometry.domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); + plan_->hyperbolic_boundary_.fill_physical(state, geometry, lane_->communicator()); +} + +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); + plan_->fill_native_halos_(state, geometry.domain, *lane_); + plan_->hyperbolic_boundary_.fill_physical(state, geometry, static_cast(point.physical_time), + point.clock, lane_->communicator()); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( @@ -796,7 +812,8 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( validate_current_(); plan_->validate_for(state); plan_->fill_native_halos_(state, geometry.domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); + plan_->hyperbolic_boundary_.fill_physical(state, geometry, static_cast(point.physical_time), + point.clock, lane_->communicator()); detail::BoundaryFieldRegistry fields; fields.configure_states(plan_->required_state_identities()); fields.configure_fields(plan_->required_field_identities()); @@ -827,7 +844,8 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( validate_current_(); plan_->validate_for(state); plan_->fill_native_halos_(state, geometry.domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, geometry.domain); + plan_->hyperbolic_boundary_.fill_physical(state, geometry, static_cast(point.physical_time), + point.clock, lane_->communicator()); if (ghost_workspaces_.size() != ghost_components_.size()) throw std::logic_error( "PreparedBoundaryPlan ghost executor was not materialized before numerical execution"); diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp index 6beb40019..8274b8a17 100644 --- a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -11,8 +11,11 @@ #include #include #include +#include #include #include +#include +#include #if defined(POPS_HAS_KOKKOS) #include @@ -113,6 +116,8 @@ struct PreparedHyperbolicFace { 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 @@ -279,6 +284,80 @@ struct HyperbolicFaceYKernel { } }; +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") @@ -359,6 +438,11 @@ class PreparedHyperbolicBoundary { 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 requires_fixed_state_conversion() const { return std::any_of(faces_.begin(), faces_.end(), [](const PreparedHyperbolicFace& prepared) { @@ -429,6 +513,36 @@ class PreparedHyperbolicBoundary { /// 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 { + if (has_analytic_state()) + throw std::logic_error( + "analytic hyperbolic boundary requires physical Geometry at execution"); + fill_physical_impl_(state, domain, nullptr, Real(0), {}, false, world_communicator_view()); + } + + 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 { + fill_physical_impl_(state, geometry.domain, &geometry, Real(0), {}, false, communicator); + } + + 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 { + fill_physical_impl_(state, geometry.domain, &geometry, physical_time, clock, true, + communicator); + } + + private: + void fill_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( @@ -439,6 +553,26 @@ class PreparedHyperbolicBoundary { const int depth = state.n_grow(); if (depth == 0) return; + 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) { @@ -469,15 +603,13 @@ class PreparedHyperbolicBoundary { 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]) - for_each_cell( - Box2D{{domain.lo[0] - depth, tangential_lo}, {domain.lo[0] - 1, tangential_hi}}, - detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], - faces_[0].law, faces_[1].law}); + 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]) - for_each_cell( - Box2D{{domain.hi[0] + 1, tangential_lo}, {domain.hi[0] + depth, tangential_hi}}, - detail::HyperbolicFaceXKernel{values, table, domain.lo[0], domain.hi[0], - faces_[0].law, faces_[1].law}); + 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; @@ -486,19 +618,16 @@ class PreparedHyperbolicBoundary { 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]) - for_each_cell( - Box2D{{tangential_lo, domain.lo[1] - depth}, {tangential_hi, domain.lo[1] - 1}}, - detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], - faces_[2].law, faces_[3].law}); + 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]) - for_each_cell( - Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}, - detail::HyperbolicFaceYKernel{values, table, domain.lo[1], domain.hi[1], - faces_[2].law, faces_[3].law}); + fill_y_face_( + values, Box2D{{tangential_lo, domain.hi[1] + 1}, {tangential_hi, domain.hi[1] + depth}}, + 3, domain, geometry, physical_time, table); } } - private: std::array faces_{}; std::vector component_transforms_; HyperbolicCornerPolicy corner_policy_ = HyperbolicCornerPolicy::NotRequired; @@ -511,6 +640,113 @@ class PreparedHyperbolicBoundary { 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() const { if (component_transforms_.empty()) throw std::invalid_argument( @@ -529,6 +765,25 @@ class PreparedHyperbolicBoundary { 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) { if (prepared_face.fixed_state.size() != component_transforms_.size() || std::any_of(prepared_face.fixed_state.begin(), prepared_face.fixed_state.end(), @@ -615,7 +870,10 @@ PreparedHyperbolicBoundary prepare_hyperbolic_boundary( const std::vector& face_identities, const std::vector& component_roles, bool allow_mapped_periodicity = false, const std::vector& face_representations = {}, - const std::vector& face_converter_identities = {}) { + 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( @@ -630,12 +888,22 @@ PreparedHyperbolicBoundary prepare_hyperbolic_boundary( 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)]; @@ -659,6 +927,59 @@ PreparedHyperbolicBoundary prepare_hyperbolic_boundary( 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, diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index d9dcdbc4c..456fac05a 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -355,7 +355,10 @@ class AmrSystem { PreparedBoundaryReadDependencies read_dependencies = {}, std::vector periodic_identifications = {}, const std::vector& face_representations = {}, - const std::vector& face_converter_identities = {}); + 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); diff --git a/include/pops/runtime/context/grid_context.hpp b/include/pops/runtime/context/grid_context.hpp index 5d3b985d8..5822d9854 100644 --- a/include/pops/runtime/context/grid_context.hpp +++ b/include/pops/runtime/context/grid_context.hpp @@ -251,7 +251,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); diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 49681fad4..ee698777e 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -330,7 +330,10 @@ class System { PreparedBoundaryReadDependencies read_dependencies = {}, std::vector periodic_identifications = {}, const std::vector& face_representations = {}, - const std::vector& face_converter_identities = {}); + 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. diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index d55d73587..45c98c09a 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -241,13 +241,17 @@ void bind_amr_assembly(py::class_& cls) { const std::vector& omitted_interface_faces, const std::string& state_identity, const std::vector>& periodic_identifications, const std::vector& face_representations, - const std::vector& face_converter_identities) { + 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, face_identities, component_roles, omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, decode_periodic_identification_rows(periodic_identifications), face_representations, - face_converter_identities); + 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("face_identities"), py::arg("component_roles"), @@ -256,6 +260,9 @@ void bind_amr_assembly(py::class_& cls) { 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"), diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index 822c48200..cd2a609a9 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -175,13 +175,17 @@ void bind_system_assembly(py::class_& cls) { const std::vector& omitted_interface_faces, const std::string& state_identity, const std::vector>& periodic_identifications, const std::vector& face_representations, - const std::vector& face_converter_identities) { + 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, face_identities, component_roles, omitted_interface_faces, state_identity, PreparedBoundaryReadDependencies{}, decode_periodic_identification_rows(periodic_identifications), face_representations, - face_converter_identities); + 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("face_identities"), py::arg("component_roles"), @@ -190,6 +194,9 @@ void bind_system_assembly(py::class_& cls) { 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"), diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 8c7e19239..23fce9cc3 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -385,8 +385,9 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: 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, model primitive-to-conservative fixed-state " - "conversion, and typed-role slip wall; dynamic AMR regrid keeps internal " + "constant/RuntimeParam fixed state, conservative device-side analytic " + "(x,y,t,params) fixed state, model primitive-to-conservative fixed-state conversion, " + "and typed-role slip wall; 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" ), @@ -430,18 +431,17 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: _row( "boundary:analytic_xtp", layout="uniform|amr", - backend="none", + backend="production", platform="host", mpi=mpi, gpu=gpu, - status="unavailable", + status="partial", limitation=( - "the built-in inflow evaluator accepts constants and RuntimeParams only; " - "coordinate-, state-, field-, or time-dependent expressions are rejected" + "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, and analytic ghost depth may not exceed the normal domain extent" ), - requested="device-side analytic boundary data depending on (x,t,params)", - available_route="constant or RuntimeParam fixed-state inflow", - alternative="install a compiled ghost-boundary component", source=source, ), _row( 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/transport.py b/python/pops/boundary/transport.py index 725175f05..b16a92bb4 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 @@ -140,13 +146,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" @@ -223,7 +253,7 @@ class ResolvedTransportCondition: geometry: DomainBoundary condition_type: str state: Handle - values: tuple[Expr, ...] + values: tuple[Expr | ScalarExpr, ...] requirement: BoundaryStencilRequirement provider: Any @@ -237,8 +267,9 @@ def __post_init__(self) -> None: _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): @@ -323,7 +354,7 @@ class Inflow: condition_type: ClassVar[str] = "inflow" state: Handle - values: tuple[Expr, ...] + values: tuple[Expr | ScalarExpr, ...] representation: Representation | None converter: Handle | None @@ -341,11 +372,34 @@ 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)) @@ -353,7 +407,12 @@ def declaration_references(self) -> tuple[Handle, ...]: converter = () if self.converter is None else (self.converter,) 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, ) @@ -542,6 +601,7 @@ def resolve_condition( @dataclass(frozen=True, slots=True, eq=False) class ResolvedTransportBoundarySet: domain_geometry_id: str + frame_id: str conditions: tuple[ResolvedTransportCondition, ...] plan: Any @@ -550,6 +610,8 @@ 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): @@ -562,6 +624,7 @@ def canonical_identity(self) -> dict[str, Any]: "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(), } @@ -602,6 +665,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 @@ -621,23 +685,63 @@ def _native_contract(self) -> tuple[Handle, int, tuple[ResolvedTransportConditio "for characteristic closure; directional modes cannot fall back to " "component-wise ghost filling" ) - self._native_representation_contract(condition, state) + representation, _ = self._native_representation_contract(condition, state) if condition.condition_type == "inflow": - if dependencies.states or dependencies.fields or dependencies.time: - raise NotImplementedError( - "state/field/time-dependent inflow requires a compiled boundary kernel; " - "the built-in native provider accepts only constants and RuntimeParams" - ) 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.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 @@ -682,6 +786,7 @@ def compile_boundary_data(self) -> dict[str, Any]: "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, @@ -702,9 +807,16 @@ def compile_boundary_data(self) -> dict[str, Any]: "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 == "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 @@ -715,11 +827,13 @@ 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.runtime._analytic_expression_lowering import lower_analytic_components if not isinstance(params, Mapping): raise TypeError("runtime boundary lowering requires resolved BindSchema values") @@ -739,19 +853,50 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: face_type = ( "foextrap" if condition.condition_type == "outflow" else "slip_wall") 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)) + 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 = "dirichlet" + if condition.condition_type in {"outflow", "slip_wall"}: + analytic_programs = [] + clock_id = None face_rows[face] = { "ordinal": face, "geometry": geometry.canonical_identity(), @@ -762,6 +907,8 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: "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 = { @@ -980,6 +1127,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, ) diff --git a/python/pops/mesh/boundaries/compiled_plan.py b/python/pops/mesh/boundaries/compiled_plan.py index bfd917cc7..a5b2f0f69 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") @@ -208,23 +209,61 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: "compiled primitive boundary face requires one exact fixed-state converter") if face["type"] in {"periodic", "foextrap", "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, ) - 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)) + 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" + ) + 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"), @@ -233,6 +272,8 @@ def runtime_boundary_data(self, params: Any) -> dict[str, Any]: "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/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/_runtime_authorities.py b/python/pops/runtime/_runtime_authorities.py index 27fe7f605..c13e4de2d 100644 --- a/python/pops/runtime/_runtime_authorities.py +++ b/python/pops/runtime/_runtime_authorities.py @@ -189,12 +189,60 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: 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: @@ -216,6 +264,9 @@ def _install_boundary_authorities(engine: Any, install_plan: Any) -> None: 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): 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/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index 29b5db04b..72e932717 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1337,7 +1337,10 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( PreparedBoundaryReadDependencies read_dependencies, std::vector periodic_identifications, const std::vector& face_representations, - const std::vector& face_converter_identities) { + 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) @@ -1351,7 +1354,8 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( "AmrSystem::install_boundary_plan state differs from the exact block state route"); auto hyperbolic = prepare_hyperbolic_boundary<2>( face_types, face_values, face_identities, component_roles, !periodic_identifications.empty(), - face_representations, face_converter_identities); + 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, std::move(read_dependencies), std::move(periodic_identifications)); diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index 546ea6c74..ffcbc0f26 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -314,7 +314,10 @@ POPS_EXPORT void System::install_boundary_plan( PreparedBoundaryReadDependencies read_dependencies, std::vector periodic_identifications, const std::vector& face_representations, - const std::vector& face_converter_identities) { + 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()) @@ -328,7 +331,8 @@ POPS_EXPORT void System::install_boundary_plan( throw std::runtime_error("System::install_boundary_plan duplicate block '" + name + "'"); auto hyperbolic = prepare_hyperbolic_boundary<2>( face_types, face_values, face_identities, component_roles, !periodic_identifications.empty(), - face_representations, face_converter_identities); + 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, std::move(read_dependencies), std::move(periodic_identifications)); diff --git a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index d0ae8244f..f962df4b4 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -38,6 +39,20 @@ PreparedHyperbolicBoundary<2> periodic_x_boundary() { {"Scalar"}); } +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}) { + const bool reads_time = std::find(opcodes.begin(), opcodes.end(), "input") != opcodes.end(); + return prepare_hyperbolic_boundary<2>( + {"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" : "", "", "", ""}); +} + PreparedHyperbolicBoundary<2> rotated_periodic_boundary() { return prepare_hyperbolic_boundary<2>( {"periodic", "foextrap", "foextrap", "periodic"}, std::vector(4, 0.0), @@ -159,6 +174,93 @@ 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_mutation) { + const Box2D domain = Box2D::from_extents(4, 3); + const Geometry geometry(domain, Real(0), Real(4), 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::invalid-plan", 1, + analytic_xlo_boundary({"constant", "log"}, {-1.0, 0.0})); + 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); + if (state.local_size() > 0) + EXPECT_EQ(state.fab(0)(-1, 1, 0), Real(-99)); +} + +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, converts_primitive_fixed_state_once_before_conservative_face_execution) { const Box2D domain = Box2D::from_extents(4, 4); 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 3f6323ce1..ff54cd04b 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -190,6 +190,140 @@ def test_primitive_fixed_state_lowers_only_through_the_exact_block_model_convert forged_authority.compile_boundary_data() +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) + + authority = case._resolved_numerics_for("tracer").boundaries[0] + with pytest.raises(NotImplementedError, match="analytic primitive inflow"): + authority.compile_boundary_data() + + +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) + + authority = case._resolved_numerics_for("tracer").boundaries[0] + with pytest.raises(NotImplementedError, match="setup-program discrete inputs"): + authority.compile_boundary_data() + + +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) + + authority = case._resolved_numerics_for("tracer").boundaries[0] + with pytest.raises(ValueError, match="plan cannot mix several logical Clocks"): + authority.compile_boundary_data() + + def test_transport_set_rejects_incomplete_geometry_at_resolution(): frame, _, _, inlet_value, numerics, case, block, block_state = _authoring() numerics.boundaries.add(TransportBoundarySet({ diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 84aca249d..08373ec09 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -100,15 +100,19 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k 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 + expected_unavailable = { "boundary:characteristic_no_inflow": ( "executable model eigenstructure", "prepared characteristic kernel", ), - "boundary:analytic_xtp": ( - "constants and RuntimeParams only", - "compiled ghost-boundary component", - ), "boundary:post_riemann_flux": ( "no post-Riemann numerical-flux transformation port", "NumericalFlux boundary component interface", 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_boundary_component_prepare_contract.py b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py index d5ae8008b..2f62b5f3a 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -168,6 +168,7 @@ def boundary_identity(name, axis, side): source = boundary_identity("xlo", 0, "lower") target = boundary_identity("xhi", 0, "upper") + face_types = ["periodic", "periodic", "dirichlet", "foextrap"] runtime_data = { "schema_version": 1, "authority_type": "prepared_boundary_plan", @@ -178,8 +179,14 @@ def boundary_identity(name, axis, side): { "ordinal": ordinal, "producer": "case::block::reflected-periodic::face::%d" % ordinal, - "type": "periodic" if ordinal < 2 else "foextrap", + "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) ], @@ -238,7 +245,7 @@ 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[3] == face_types assert native.installed[5] == [ "case::block::reflected-periodic::face::0", "case::block::reflected-periodic::face::1", @@ -249,3 +256,6 @@ class BoundaryBlock: assert native.installed[9] == [[0, 1, 0, 1, 1, -1]] 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", ""] From 1f845020a42ee97833d11fbceefa6d51da3dfdc0 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 21:14:12 +0200 Subject: [PATCH 23/93] feat(recovery): add transactional prepared recovery chain --- .../nonlinear/prepared_variable_recovery.hpp | 445 ++++++++++++++++++ include/pops_headers.manifest | 1 + tests/CMakeLists.txt | 1 + tests/cpp/test_sources.cmake | 1 + .../numerics/test_variable_recovery_chain.cpp | 216 +++++++++ tests/test_manifest.toml | 5 + 6 files changed, 669 insertions(+) create mode 100644 include/pops/numerics/nonlinear/prepared_variable_recovery.hpp create mode 100644 tests/cpp/unit/numerics/test_variable_recovery_chain.cpp 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..df97c4b44 --- /dev/null +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -0,0 +1,445 @@ +#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 + +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; + 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(); } +}; + +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.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.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; +} + +/// 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_); + } + + 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_headers.manifest b/include/pops_headers.manifest index 276ccbc10..b9a661393 100644 --- a/include/pops_headers.manifest +++ b/include/pops_headers.manifest @@ -107,6 +107,7 @@ 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/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/operators/cartesian_operator.hpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5553f9a61..380ac7514 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -556,6 +556,7 @@ set(POPS_CPP_STANDARD_TESTS test_roe_flux test_riemann_capabilities test_newton_robustness + test_variable_recovery_chain test_elliptic_interface test_field_nullspace test_field_context diff --git a/tests/cpp/test_sources.cmake b/tests/cpp/test_sources.cmake index 8dee480e5..428f70f9a 100644 --- a/tests/cpp/test_sources.cmake +++ b/tests/cpp/test_sources.cmake @@ -212,6 +212,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/numerics/test_variable_recovery_chain.cpp b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp new file mode 100644 index 000000000..02760797b --- /dev/null +++ b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp @@ -0,0 +1,216 @@ +#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 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); + } +}; + +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_GT(outcome.total_iterations, 0); + EXPECT_NEAR(outcome.value[0], Real(2), Real(1e-10)); +} + +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_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, 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, 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, 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()); +} + +} // namespace diff --git a/tests/test_manifest.toml b/tests/test_manifest.toml index 4d99ca2ad..cd843f8d1 100644 --- a/tests/test_manifest.toml +++ b/tests/test_manifest.toml @@ -902,6 +902,11 @@ 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"] From 5d17efa1dd04b6f95312e98cb6208979cd1a434d Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 21:27:59 +0200 Subject: [PATCH 24/93] test(numerics): add fail-closed prepared provider gate --- scripts/run_adc757_prepared_numerics_gate.py | 206 +++++++++++++ tests/CMakeLists.txt | 1 + tests/cpp/test_sources.cmake | 1 + .../numerics/test_prepared_numerics_gate.cpp | 276 ++++++++++++++++++ tests/gates/adc757_prepared_numerics.toml | 65 +++++ .../test_adc757_prepared_numerics_gate.py | 87 ++++++ tests/test_manifest.toml | 5 + 7 files changed, 641 insertions(+) create mode 100755 scripts/run_adc757_prepared_numerics_gate.py create mode 100644 tests/cpp/unit/numerics/test_prepared_numerics_gate.cpp create mode 100644 tests/gates/adc757_prepared_numerics.toml create mode 100644 tests/python/architecture/test_adc757_prepared_numerics_gate.py diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py new file mode 100755 index 000000000..d17f6c47e --- /dev/null +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Validate and run the bounded ADC-757 prepared-numerics evidence gate.""" + +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +from pathlib import Path +import re +import subprocess +import sys +import tomllib + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_MANIFEST = ROOT / "tests/gates/adc757_prepared_numerics.toml" +TEST_MANIFEST = ROOT / "tests/test_manifest.toml" +EXPECTED_REQUIREMENTS = { + "prepared_local_nonlinear", + "typed_fallible_evaluation", + "transactional_recovery_publication", + "allocation_aware_cell_hot_path", +} +EXPECTED_DEFERRED = ( + "boundary_geometry_riemann_and_spatial_provider_families", + "python_ir_generated_abi_and_restart_parity", + "runtime_consumer_cutover_and_legacy_deletion", + "amr_regrid_migration_and_restart_coherence", + "mpi_collective_execution", + "gpu_backend_execution", + "workspace_reentrancy_and_stream_partitioning", + "performance_baselines_and_end_to_end_benchmarks", + "local_time_and_load_balance_provider_families", +) +GTEST_PATTERN = re.compile(r"\bTEST(?:_F)?\(\s*([A-Za-z_]\w*)\s*,\s*([A-Za-z_]\w*)\s*\)") + + +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 _declared_gtests(suite: dict) -> tuple[set[str], list[str]]: + names: set[str] = set() + 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") + if "GTEST_SKIP" in text or "DISABLED_" in text: + errors.append("%s contains a skip/disabled marker" % relative) + names.update("%s.%s" % match.groups() for match in GTEST_PATTERN.finditer(text)) + return names, errors + + +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", + "check", + } + if set(data) != expected_fields: + errors.append("manifest fields must be exactly %s" % sorted(expected_fields)) + if data.get("schema_version") != 1: + errors.append("schema_version must be exactly 1") + 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") + if data.get("evidence_from") != ["ADC-750", "ADC-753"]: + errors.append("evidence_from must be exactly ADC-750 then ADC-753") + if data.get("deferred") != list(EXPECTED_DEFERRED): + errors.append("deferred must enumerate every deliberately unproved family exactly") + + checks = data.get("check") + if not isinstance(checks, list) or not checks: + errors.append("manifest must contain [[check]] rows") + checks = [] + suites = _cpp_suites() + coverage: dict[str, set[str]] = defaultdict(set) + identities = Counter() + for index, row in enumerate(checks, 1): + where = "check[%d]" % index + if set(row) != {"requirement", "polarity", "target", "test_regex"}: + 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)) + identity = (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 + if "mpi" in str(target).lower() or "gpu" in str(target).lower(): + errors.append("%s claims a deferred MPI/GPU target %r" % (where, target)) + names, source_errors = _declared_gtests(suites[target]) + errors.extend("%s: %s" % (where, error) for error in source_errors) + try: + matches = sorted(name for name in names 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) + ) + + duplicates = sorted(identity for identity, count in identities.items() if count > 1) + if duplicates: + errors.append("duplicate executable checks: %s" % duplicates) + 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 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 full ADC-757 closure (intentionally refused while deferred remains)", + ) + 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 explicitly deferred families)" + % (len(data["check"]), len(data["deferred"])) + ) + if args.closure: + print( + "ADC-757 closure refused: %d required families remain deferred" % len(data["deferred"]), + file=sys.stderr, + ) + return 3 + if args.check_only: + return 0 + for row in sorted(data["check"], key=lambda value: (value["target"], value["test_regex"])): + _run_ctest(args.build_dir, row["target"], row["test_regex"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 380ac7514..cbca1ac36 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -557,6 +557,7 @@ set(POPS_CPP_STANDARD_TESTS test_riemann_capabilities test_newton_robustness test_variable_recovery_chain + test_prepared_numerics_gate test_elliptic_interface test_field_nullspace test_field_context diff --git a/tests/cpp/test_sources.cmake b/tests/cpp/test_sources.cmake index 428f70f9a..65841f148 100644 --- a/tests/cpp/test_sources.cmake +++ b/tests/cpp/test_sources.cmake @@ -174,6 +174,7 @@ 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_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") 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/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml new file mode 100644 index 000000000..fe563c85a --- /dev/null +++ b/tests/gates/adc757_prepared_numerics.toml @@ -0,0 +1,65 @@ +schema_version = 1 +gate = "adc757-prepared-numerics-slice" +issue = "ADC-757" +evidence_from = ["ADC-750", "ADC-753"] +deferred = [ + "boundary_geometry_riemann_and_spatial_provider_families", + "python_ir_generated_abi_and_restart_parity", + "runtime_consumer_cutover_and_legacy_deletion", + "amr_regrid_migration_and_restart_coherence", + "mpi_collective_execution", + "gpu_backend_execution", + "workspace_reentrancy_and_stream_partitioning", + "performance_baselines_and_end_to_end_benchmarks", + "local_time_and_load_balance_provider_families", +] + +# This is an executable partial gate, not ADC-757 closure. Every claimed +# requirement has one success proof and one refusal/detector proof. +[[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 = "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$" 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..598b972ce --- /dev/null +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -0,0 +1,87 @@ +"""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"]) == 8 + assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS + assert runner.main(["--check-only"]) == 0 + + +def test_adc757_slice_does_not_claim_full_mpi_gpu_or_runtime_closure(): + runner = _load_runner() + data, errors = runner.validate_manifest(MANIFEST) + assert not errors + assert data["deferred"] == list(runner.EXPECTED_DEFERRED) + assert "mpi_collective_execution" in data["deferred"] + assert "gpu_backend_execution" in data["deferred"] + assert "runtime_consumer_cutover_and_legacy_deletion" in data["deferred"] + assert all( + "mpi" not in row["target"].lower() and "gpu" not in row["target"].lower() + for row in data["check"] + ) + assert runner.main(["--check-only", "--closure"]) == 3 + + +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) + + +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$", + ) diff --git a/tests/test_manifest.toml b/tests/test_manifest.toml index cd843f8d1..4650b95b8 100644 --- a/tests/test_manifest.toml +++ b/tests/test_manifest.toml @@ -882,6 +882,11 @@ name = "test_positivity_floor" sources = ["tests/cpp/unit/numerics/test_positivity_floor.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"] From 6fe34e7c01929a78269ba31294675c217ade3cd9 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 21:16:00 +0200 Subject: [PATCH 25/93] fix(riemann): reject non-finite Roe candidates explicitly --- docs/ALGORITHMS.md | 13 ++++-- include/pops/numerics/fv/flux_interfaces.hpp | 23 +++++++++++ include/pops/numerics/fv/numerical_flux.hpp | 32 ++++++++++++--- .../test_flux_failure_loader_transaction.cpp | 14 +++++++ .../unit/numerics/test_flux_interfaces.cpp | 40 +++++++++++++++++++ 5 files changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index dd674cd8d..ea7f076bc 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -220,10 +220,15 @@ 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 instead of publishing a successful NaN result; the +runtime then rolls the owning step transaction back without selecting another solver. +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 diff --git a/include/pops/numerics/fv/flux_interfaces.hpp b/include/pops/numerics/fv/flux_interfaces.hpp index 0265fad9f..0d1855b17 100644 --- a/include/pops/numerics/fv/flux_interfaces.hpp +++ b/include/pops/numerics/fv/flux_interfaces.hpp @@ -102,6 +102,26 @@ struct QualifiedProviderRequirement { enum class EvaluationStatus : std::uint8_t { kOk, kRetry, kReject, kFailed }; enum class TransactionFailureAction : std::uint8_t { kNone, kRetryStep, kRejectStep, kAbortRun }; +/// 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), + 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: @@ -229,6 +249,9 @@ 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()); } diff --git a/include/pops/numerics/fv/numerical_flux.hpp b/include/pops/numerics/fv/numerical_flux.hpp index 657b6390b..08a200cb5 100644 --- a/include/pops/numerics/fv/numerical_flux.hpp +++ b/include/pops/numerics/fv/numerical_flux.hpp @@ -53,6 +53,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. @@ -81,7 +89,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 +122,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)) @@ -186,11 +197,13 @@ 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)) @@ -243,16 +256,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, 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..7b401d43b 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 @@ -66,6 +67,14 @@ std::string package_source() { 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 roe_dissipation(const State& left, const Aux&, const State& right, const Aux&, + 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/unit/numerics/test_flux_interfaces.cpp b/tests/cpp/unit/numerics/test_flux_interfaces.cpp index 7c3d7a211..daf2606c3 100644 --- a/tests/cpp/unit/numerics/test_flux_interfaces.cpp +++ b/tests/cpp/unit/numerics/test_flux_interfaces.cpp @@ -25,6 +25,21 @@ struct Advect { struct OtherAdvect : Advect {}; +struct NonFiniteRoeAdvect : Advect { + POPS_HD State roe_dissipation(const State&, const Aux&, const State&, const Aux&, int) const { + return State{std::numeric_limits::quiet_NaN()}; + } +}; + +struct NonFiniteRoeFluxAdvect : Advect { + POPS_HD State flux(const State&, const Aux&, int) const { + return State{std::numeric_limits::quiet_NaN()}; + } + POPS_HD State roe_dissipation(const State&, const Aux&, const State&, const Aux&, int) const { + return State{}; + } +}; + struct SelectiveInvalidAdvect { using State = pops::StateVec<1>; using Aux = pops::Aux; @@ -267,6 +282,31 @@ TEST(test_flux_interfaces, failed_evaluation_never_publishes_a_density) { 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, device_failure_reduction_orders_status_then_reason_deterministically) { static_assert(std::is_trivially_copyable_v); static_assert(sizeof(pops::FluxEvaluationTracker) == sizeof(std::uint64_t)); From c85cc18d90cc13c0142d5119010459f75ccd0cc5 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 21:52:30 +0200 Subject: [PATCH 26/93] feat(recovery): cut over primitive materialization consumer --- .../nonlinear/prepared_variable_recovery.hpp | 149 ++++++++++++++++++ .../runtime/builders/block/block_builder.hpp | 55 +++++-- .../runtime/builders/block/block_seam.hpp | 5 +- include/pops/runtime/system.hpp | 5 +- .../runtime/system/system_block_store.hpp | 7 +- src/runtime/system/system_fields.cpp | 11 +- src/runtime/system/system_install.cpp | 3 +- .../runtime/test_facade_routing.cpp | 38 +++++ tests/cpp/unit/codegen/test_block_builder.cpp | 34 ++++ ...test_variable_recovery_consumer_cutover.py | 52 ++++++ 10 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 tests/python/architecture/test_variable_recovery_consumer_cutover.py diff --git a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp index df97c4b44..c02c8a9c3 100644 --- a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -10,6 +10,7 @@ /// explicit publication transaction below. #include +#include #include #include @@ -143,6 +144,96 @@ struct RecoveryOutcome { 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; + 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.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_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; @@ -302,6 +393,64 @@ POPS_HD inline RecoveryOutcome recover_prepared_variable( 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; + } +}; + +/// 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) { + return prepare_variable_recovery( + FiniteModelRecoveryAdmissibility{}, + recovery_methods(ClosedFormModelRecoveryMethod{model})); + } 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. diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index 6aeb0af88..f3602f973 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include // assemble_rhs_eb (cut-cell EB) + detail::DiscLevelSet (T5-PR2) #include @@ -896,16 +897,20 @@ std::function make_poisson_rhs(const Model& m) /// 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). +/// 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. /// 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): /// 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) { auto p2c = [m](const double* in, double* out) { typename Model::Prim p{}; @@ -915,23 +920,43 @@ 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)}; + 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); + }; + return {std::function(p2c), + std::function(c2p)}; } } diff --git a/include/pops/runtime/builders/block/block_seam.hpp b/include/pops/runtime/builders/block/block_seam.hpp index 68edc60f5..ac46cd171 100644 --- a/include/pops/runtime/builders/block/block_seam.hpp +++ b/include/pops/runtime/builders/block/block_seam.hpp @@ -38,8 +38,9 @@ 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 int aux_width = 0; // aux_comps() (Cartesian); unused on the polar path (no ensure_aux_width) }; diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 3967d5e3b..e1c6061b0 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) @@ -635,11 +636,13 @@ class System { /// 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. using CellConvert = std::function; + /// Fallible conservative -> primitive conversion. A failed report forbids writing @p out. + using CellRecovery = std::function; /// 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); /// 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 diff --git a/include/pops/runtime/system/system_block_store.hpp b/include/pops/runtime/system/system_block_store.hpp index ef8475925..b35f57f3f 100644 --- a/include/pops/runtime/system/system_block_store.hpp +++ b/include/pops/runtime/system/system_block_store.hpp @@ -5,7 +5,8 @@ #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 @@ -56,6 +57,7 @@ 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; /// 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 +99,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. diff --git a/src/runtime/system/system_fields.cpp b/src/runtime/system/system_fields.cpp index 460c04b42..db83f3c8b 100644 --- a/src/runtime/system/system_fields.cpp +++ b/src/runtime/system/system_fields.cpp @@ -60,7 +60,7 @@ 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); s.prim_to_cons = std::move(prim_to_cons); s.cons_to_prim = std::move(cons_to_prim); @@ -118,7 +118,14 @@ std::vector System::get_primitive_state(const std::string& name) { 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()); + const RecoveryReport recovery = s.cons_to_prim(cell_in.data(), cell_out.data()); + if (!recovery.publication_permitted()) + throw std::runtime_error( + "System::get_primitive_state : variable recovery failed for block '" + name + + "' at local cell " + std::to_string(k) + " (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) + ")"); for (int c = 0; c < nc; ++c) prim[static_cast(c) * nn + k] = cell_out[c]; } diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index 4ed735086..f2434cbe8 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -135,7 +135,8 @@ 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) VariableSet cons_vs, prim_vs; detail::BuiltBlock bb; if (P->polar_) { diff --git a/tests/cpp/integration/runtime/test_facade_routing.cpp b/tests/cpp/integration/runtime/test_facade_routing.cpp index 6f076ba7d..6b06fb62b 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,31 @@ 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"; +} diff --git a/tests/cpp/unit/codegen/test_block_builder.cpp b/tests/cpp/unit/codegen/test_block_builder.cpp index ec684a924..d4d2713c6 100644 --- a/tests/cpp/unit/codegen/test_block_builder.cpp +++ b/tests/cpp/unit/codegen/test_block_builder.cpp @@ -23,7 +23,9 @@ #include #include +#include #include +#include #include using namespace pops; @@ -148,3 +150,35 @@ 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, 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}; + 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_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_GE(failure.failing_component, 1); + EXPECT_EQ(primitive, sentinel); +} 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..10f56cbfa --- /dev/null +++ b/tests/python/architecture/test_variable_recovery_consumer_cutover.py @@ -0,0 +1,52 @@ +"""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" + + +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_recovery_before_copying_candidate(): + source = SYSTEM_FIELDS.read_text(encoding="utf-8") + materialization = _between( + source, + "std::vector System::get_primitive_state", + "\nSolveReport System::solve_fields_in_place_", + ) + + recovery = materialization.index("const RecoveryReport recovery") + refusal = materialization.index("if (!recovery.publication_permitted())") + publication = materialization.index("prim[static_cast(c) * nn + k] = cell_out[c]") + assert recovery < refusal < publication + assert "variable recovery failed" 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 == [] From 80463b6095d07c735baacf09be7cf8420ce23f58 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 22:56:14 +0200 Subject: [PATCH 27/93] fix(recovery): roll back abandoned tentative publication --- .../nonlinear/prepared_variable_recovery.hpp | 13 ++++++++++ .../numerics/test_variable_recovery_chain.cpp | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp index df97c4b44..8cff67db2 100644 --- a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -401,6 +401,19 @@ class RecoveryPublicationTransaction { 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) { diff --git a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp index 02760797b..ba3dc8e72 100644 --- a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp +++ b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp @@ -175,6 +175,30 @@ TEST(PreparedVariableRecovery, tentative_publication_rolls_back_solution_and_war 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)}; From 6bc885174e19c444fe784b36e0e1d16e9a5576bc Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 23:21:05 +0200 Subject: [PATCH 28/93] ci(cpp): budget variable recovery chain --- tests/cpp/build_durations.json | 1 + tests/cpp/test_durations.json | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/cpp/build_durations.json b/tests/cpp/build_durations.json index 63f1b081b..8e5899c98 100644 --- a/tests/cpp/build_durations.json +++ b/tests/cpp/build_durations.json @@ -199,6 +199,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/test_durations.json b/tests/cpp/test_durations.json index 855ccd973..7fc022d0a 100644 --- a/tests/cpp/test_durations.json +++ b/tests/cpp/test_durations.json @@ -199,6 +199,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, From ed7e163013d703e1ae81f576dd6f758728927163 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 23:46:12 +0200 Subject: [PATCH 29/93] feat(recovery): consume typed face recovery before fluxes --- include/pops/numerics/fv/flux_failure.hpp | 51 ++++- .../spatial/embedded_boundary/operator.hpp | 30 ++- .../spatial/operators/cartesian_operator.hpp | 205 +++++++++++------- .../spatial/operators/masked_operator.hpp | 88 +++++--- .../spatial/operators/polar_operator.hpp | 30 ++- .../numerics/spatial/primitives/face_flux.hpp | 170 ++++++++++++--- include/pops/numerics/spatial_operator.hpp | 2 +- 7 files changed, 404 insertions(+), 172 deletions(-) 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/spatial/embedded_boundary/operator.hpp b/include/pops/numerics/spatial/embedded_boundary/operator.hpp index 951b7ac69..49f91c994 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) 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..85ca4523b 100644 --- a/include/pops/numerics/spatial/operators/polar_operator.hpp +++ b/include/pops/numerics/spatial/operators/polar_operator.hpp @@ -141,13 +141,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 +185,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) 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_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. From efe57ea27be7060b036bbea1917684284d74e21a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 23:46:18 +0200 Subject: [PATCH 30/93] test(recovery): prove face refusals across device and MPI --- .../mpi/test_mpi_flux_failure_collective.cpp | 24 ++++++ .../unit/numerics/test_flux_interfaces.cpp | 82 +++++++++++++++++++ ...test_variable_recovery_consumer_cutover.py | 41 +++++++++- 3 files changed, 146 insertions(+), 1 deletion(-) 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/unit/numerics/test_flux_interfaces.cpp b/tests/cpp/unit/numerics/test_flux_interfaces.cpp index 7c3d7a211..cff637966 100644 --- a/tests/cpp/unit/numerics/test_flux_interfaces.cpp +++ b/tests/cpp/unit/numerics/test_flux_interfaces.cpp @@ -3,11 +3,15 @@ #include #include #include +#include #include +#include #include #include #include +#include +#include namespace { @@ -109,6 +113,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 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 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) { @@ -297,6 +317,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/python/architecture/test_variable_recovery_consumer_cutover.py b/tests/python/architecture/test_variable_recovery_consumer_cutover.py index 10f56cbfa..72027bc14 100644 --- a/tests/python/architecture/test_variable_recovery_consumer_cutover.py +++ b/tests/python/architecture/test_variable_recovery_consumer_cutover.py @@ -7,6 +7,15 @@ 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" +FLUX_FAILURE = ROOT / "include/pops/numerics/fv/flux_failure.hpp" +FACE_FLUX = ROOT / "include/pops/numerics/spatial/primitives/face_flux.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: @@ -15,7 +24,9 @@ def _between(source: str, begin: str, end: str) -> str: 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") + 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 @@ -50,3 +61,31 @@ def test_runtime_layer_has_no_independent_direct_primitive_recovery(): if re.search(r"\b(?:m|model)\.to_primitive\s*\(", source): bypasses.append(path.relative_to(ROOT).as_posix()) assert bypasses == [] + + +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 == [] From cff0b1c7da6651b672bea5cfb23d2913ff491b53 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 23:46:24 +0200 Subject: [PATCH 31/93] docs(recovery): document face publication contract --- docs/ALGORITHMS.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index f8e298ef3..b46462ffb 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -145,6 +145,14 @@ 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 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 @@ -378,7 +386,9 @@ 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: From 30892c37ff84370803e537b760fa6a00957eff8b Mon Sep 17 00:00:00 2001 From: desp0042 Date: Wed, 29 Jul 2026 23:49:53 +0200 Subject: [PATCH 32/93] ci(cpp): budget prepared numerics gate --- tests/cpp/build_durations.json | 1 + tests/cpp/test_durations.json | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/cpp/build_durations.json b/tests/cpp/build_durations.json index 8e5899c98..1ff8483a7 100644 --- a/tests/cpp/build_durations.json +++ b/tests/cpp/build_durations.json @@ -161,6 +161,7 @@ "test_polar_transport_mms": 2.0, "test_positivity_floor": 2.0, "test_prepared_boundary_plan": 2.0, + "test_prepared_numerics_gate": 2.0, "test_primitive_recon": 2.0, "test_pure_field_algebra_extreme_dot": 2.0, "test_profiler": 2.0, diff --git a/tests/cpp/test_durations.json b/tests/cpp/test_durations.json index 7fc022d0a..5ea0e8980 100644 --- a/tests/cpp/test_durations.json +++ b/tests/cpp/test_durations.json @@ -161,6 +161,7 @@ "test_polar_transport_mms": 2.56, "test_positivity_floor": 0.02, "test_prepared_boundary_plan": 0.02, + "test_prepared_numerics_gate": 0.02, "test_primitive_recon": 0.01, "test_pure_field_algebra_extreme_dot": 0.02, "test_profiler": 0.04, From 927590c5283c4930967e0e386ddfb02bb0ddfe65 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 01:22:40 +0200 Subject: [PATCH 33/93] test(boundary): cover merged conversion topology and CI routing --- .../mesh/boundary/prepared_hyperbolic_boundary.hpp | 3 +-- .../cpp/unit/mesh/test_prepared_boundary_plan.cpp | 14 ++++++++++++++ .../unit/runtime/test_program_context_contract.cpp | 1 + tests/python/test_durations.json | 6 ++++-- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp index fbf8bb01e..db915dff2 100644 --- a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -614,8 +614,7 @@ 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& component_roles, bool explicit_periodic_identifications = false, const std::vector& face_representations = {}, const std::vector& face_converter_identities = {}) { if (face_types.size() != static_cast(2 * Dim) || diff --git a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index 5e3643930..50d1ef183 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -230,6 +230,20 @@ TEST(test_prepared_boundary_plan, primitive_fixed_state_conversion_is_transactio 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); diff --git a/tests/cpp/unit/runtime/test_program_context_contract.cpp b/tests/cpp/unit/runtime/test_program_context_contract.cpp index 2487de2fc..b1d2594a5 100644 --- a/tests/cpp/unit/runtime/test_program_context_contract.cpp +++ b/tests/cpp/unit/runtime/test_program_context_contract.cpp @@ -537,6 +537,7 @@ TEST(ProgramContextContract, CommitManySnapshotsSourcesThatAreAlsoTargets) { EXPECT_EQ(first.fab(0).const_array()(first.box(0).lo[0], first.box(0).lo[1], 0), Real(13)); EXPECT_EQ(second.fab(0).const_array()(second.box(0).lo[0], second.box(0).lo[1], 0), Real(3)); } + TEST(ProgramContextContract, GeneratedScratchIsPersistentExactAndNonAliasing) { ensure_kokkos(); SystemConfig cfg; diff --git a/tests/python/test_durations.json b/tests/python/test_durations.json index 99d6d8c03..d69b4ee42 100644 --- a/tests/python/test_durations.json +++ b/tests/python/test_durations.json @@ -174,6 +174,7 @@ "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_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, @@ -409,7 +410,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": 224, + "estimated_count": 225, "estimated_files": [ "tests/python/examples/final/test_hyqmom15_final_example.py", "tests/python/examples/final/test_scalar_advection_final_example.py", @@ -515,6 +516,7 @@ "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_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", @@ -636,6 +638,6 @@ "tests/python/unit/time/test_typed_provenance_guards.py", "tests/python/unit/time/test_typed_schedule.py" ], - "total_files": 405 + "total_files": 406 } } From 0aaf4b011aa46e7c74a044e2aa26aec5b0109315 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 02:04:33 +0200 Subject: [PATCH 34/93] Fail closed on mapped analytic boundary coordinates --- .../pops/mesh/boundary/prepared_boundary_plan.hpp | 4 ++++ tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index 2b3cb1825..e57b94b8c 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -744,6 +744,10 @@ class PreparedBoundaryPlan { 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 != diff --git a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index c5641fd4f..4a18fc2a1 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -550,6 +550,19 @@ TEST(test_prepared_boundary_plan, mapped_periodicity_refuses_unmapped_vector_com 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}}}; From 889ad00fa263b075668c37a99ddce276836e78e6 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 02:04:40 +0200 Subject: [PATCH 35/93] Document mapped analytic boundary limitation --- docs/design/native-capability-matrix.md | 11 ++++++----- python/pops/_capabilities_report.py | 3 ++- tests/python/unit/codegen/test_fail_closed_reports.py | 1 + 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index fa200cfbb..b612a93c7 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -106,11 +106,12 @@ Supported native routes include: 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 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. 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. Separate `unavailable` rows expose the missing characteristic no-inflow kernel and - post-Riemann flux transformation. + 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. Separate `unavailable` rows expose the missing characteristic + no-inflow kernel and post-Riemann flux transformation. 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 diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 23fce9cc3..c7a630581 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -440,7 +440,8 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: "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, and analytic ghost depth may not exceed the normal domain extent" + "unavailable, analytic ghost depth may not exceed the normal domain extent, and " + "axis-permuted periodic coordinates require a prepared coordinate map" ), source=source, ), diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 08373ec09..199eb3756 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -107,6 +107,7 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k 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 expected_unavailable = { "boundary:characteristic_no_inflow": ( From 11beff7740db14e3b86ee3f93a9cd20572bdad20 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 02:48:26 +0200 Subject: [PATCH 36/93] test(solvers): fence prepared local nonlinear authority --- ...test_prepared_local_nonlinear_authority.py | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/python/architecture/test_prepared_local_nonlinear_authority.py 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..0ba426ee8 --- /dev/null +++ b/tests/python/architecture/test_prepared_local_nonlinear_authority.py @@ -0,0 +1,125 @@ +"""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" +IMPLICIT_STEPPER = ROOT / "include/pops/numerics/time/integrators/implicit_stepper.hpp" +MODEL_KERNELS = ROOT / "python/pops/codegen/program_emit_model_kernels.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 From 08edcb2f1ed78e2ce72bb8348813c686e514691a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 03:25:51 +0200 Subject: [PATCH 37/93] Make analytic boundary installation collective --- .../mesh/boundary/prepared_boundary_plan.hpp | 99 +++++++++++++++++++ .../runtime/analytic/collective_preflight.hpp | 50 +++++++--- src/runtime/amr/amr_system.cpp | 72 +++++++++----- src/runtime/system/system_install.cpp | 63 ++++++++---- .../test_mpi_system_analytic_level_set.cpp | 91 +++++++++++++++++ 5 files changed, 314 insertions(+), 61 deletions(-) diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index e57b94b8c..e8c5b6954 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -23,9 +23,12 @@ #include #include +#include #include +#include #include #include +#include #include #include #include @@ -88,6 +91,102 @@ struct PreparedBoundaryReadDependencies { std::vector fields; }; +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 { 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/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index da4372a4d..27f776e33 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1354,32 +1354,52 @@ POPS_EXPORT void AmrSystem::install_boundary_plan( 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"); - 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, - std::move(read_dependencies), std::move(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"); - 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, diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index a4326d03b..23dab2e13 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -332,27 +332,48 @@ POPS_EXPORT void System::install_boundary_plan( 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 + "'"); - 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, - 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, 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..29a076644 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 @@ -190,6 +190,97 @@ 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"); + const long failures = all_reduce_sum(local_failures); comm_finalize(); return failures == 0 ? 0 : 1; From 79e0d98f48d8f0ed1d9b8d9f43536a6497fb15ea Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 03:33:14 +0200 Subject: [PATCH 38/93] Preflight analytic boundaries before halo mutation --- docs/design/native-capability-matrix.md | 6 +- .../mesh/boundary/prepared_boundary_plan.hpp | 35 +++-- .../boundary/prepared_hyperbolic_boundary.hpp | 127 ++++++++++++++++-- .../test_mpi_system_analytic_level_set.cpp | 57 ++++++++ .../unit/mesh/test_prepared_boundary_plan.cpp | 31 +++-- 5 files changed, 224 insertions(+), 32 deletions(-) diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index b612a93c7..7c4c6fab6 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -105,7 +105,11 @@ Supported native routes include: through the exact compiled block-model `to_conservative` provider, and typed-role slip wall. 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 - route remains `partial`: primitive per-point conversion and discrete state/field/input reads are + 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 diff --git a/include/pops/mesh/boundary/prepared_boundary_plan.hpp b/include/pops/mesh/boundary/prepared_boundary_plan.hpp index e8c5b6954..c767765cc 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -524,8 +524,9 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, domain); fill_native_halos_(state, domain); - hyperbolic_boundary_.fill_physical(state, domain); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } void fill_same_level_and_physical(MultiFab& state, const Box2D& domain, @@ -534,8 +535,9 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, domain); fill_native_halos_(state, domain, lane); - hyperbolic_boundary_.fill_physical(state, domain); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry) const { @@ -543,8 +545,9 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); + auto physical_preflight = hyperbolic_boundary_.preflight_physical(state, geometry); fill_native_halos_(state, geometry.domain); - hyperbolic_boundary_.fill_physical(state, geometry); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } void fill_same_level_and_physical(MultiFab& state, const Geometry& geometry, @@ -553,8 +556,10 @@ class PreparedBoundaryPlan { throw std::runtime_error( "PreparedBoundaryPlan native components require an exact BoundaryEvaluationPoint"); validate_for(state); + auto physical_preflight = + hyperbolic_boundary_.preflight_physical(state, geometry, lane.communicator()); fill_native_halos_(state, geometry.domain, lane); - hyperbolic_boundary_.fill_physical(state, geometry, lane.communicator()); + hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } /// One-shot control/diagnostic adapter. It materializes a fresh component session and workspace; @@ -893,8 +898,9 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical(MultiFab throw std::invalid_argument( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); plan_->validate_for(state); + auto physical_preflight = plan_->hyperbolic_boundary_.preflight_physical(state, domain); plan_->fill_native_halos_(state, domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, domain); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( @@ -904,8 +910,10 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( throw std::invalid_argument( "PreparedBoundaryPlan component session requires an exact BoundaryEvaluationPoint"); plan_->validate_for(state); + auto physical_preflight = + plan_->hyperbolic_boundary_.preflight_physical(state, geometry, lane_->communicator()); plan_->fill_native_halos_(state, geometry.domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, geometry, lane_->communicator()); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( @@ -916,9 +924,10 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( 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_native_halos_(state, geometry.domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, geometry, static_cast(point.physical_time), - point.clock, lane_->communicator()); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); } inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( @@ -926,9 +935,10 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical_control( 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_native_halos_(state, geometry.domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, geometry, static_cast(point.physical_time), - point.clock, lane_->communicator()); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); detail::BoundaryFieldRegistry fields; fields.configure_states(plan_->required_state_identities()); fields.configure_fields(plan_->required_field_identities()); @@ -958,9 +968,10 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( 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_native_halos_(state, geometry.domain, *lane_); - plan_->hyperbolic_boundary_.fill_physical(state, geometry, static_cast(point.physical_time), - point.clock, lane_->communicator()); + plan_->hyperbolic_boundary_.fill_physical_preflighted(state, std::move(physical_preflight)); if (ghost_workspaces_.size() != ghost_components_.size()) throw std::logic_error( "PreparedBoundaryPlan ghost executor was not materialized before numerical execution"); diff --git a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp index 1bedcfc47..ce71356d2 100644 --- a/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp +++ b/include/pops/mesh/boundary/prepared_hyperbolic_boundary.hpp @@ -411,6 +411,60 @@ class PreparedHyperbolicBoundary { 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( @@ -514,10 +568,8 @@ class PreparedHyperbolicBoundary { /// 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 { - if (has_analytic_state()) - throw std::logic_error( - "analytic hyperbolic boundary requires physical Geometry at execution"); - fill_physical_impl_(state, domain, nullptr, Real(0), {}, false, world_communicator_view()); + auto preflight = preflight_physical(state, domain); + fill_physical_preflighted(state, std::move(preflight)); } void fill_physical(MultiFab& state, const Geometry& geometry) const { @@ -526,7 +578,8 @@ class PreparedHyperbolicBoundary { void fill_physical(MultiFab& state, const Geometry& geometry, CommunicatorView communicator) const { - fill_physical_impl_(state, geometry.domain, &geometry, Real(0), {}, false, communicator); + 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, @@ -536,14 +589,58 @@ class PreparedHyperbolicBoundary { void fill_physical(MultiFab& state, const Geometry& geometry, Real physical_time, std::string_view clock, CommunicatorView communicator) const { - fill_physical_impl_(state, geometry.domain, &geometry, physical_time, clock, true, - communicator); + 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: - void fill_physical_impl_(MultiFab& state, const Box2D& domain, const Geometry* geometry, - Real physical_time, std::string_view clock, bool has_evaluation_point, - CommunicatorView communicator) const { + 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( @@ -553,7 +650,7 @@ class PreparedHyperbolicBoundary { "prepared hyperbolic boundary component count differs from the state"); const int depth = state.n_grow(); if (depth == 0) - return; + return PhysicalFillPreflight(this, &state, domain, geometry, physical_time); if (has_analytic_state()) { if (geometry == nullptr || geometry->domain != domain) throw std::invalid_argument( @@ -591,7 +688,15 @@ class PreparedHyperbolicBoundary { 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(); 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 29a076644..af07c5ead 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 @@ -281,6 +284,60 @@ int run_analytic_level_set_collective_preflight(int argc, char** argv) { 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/unit/mesh/test_prepared_boundary_plan.cpp b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp index 4a18fc2a1..3bc169dfa 100644 --- a/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp +++ b/tests/cpp/unit/mesh/test_prepared_boundary_plan.cpp @@ -44,10 +44,12 @@ PreparedHyperbolicBoundary<2> periodic_boundary( 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}) { + 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>( - {"dirichlet", "foextrap", "foextrap", "foextrap"}, std::vector(4, 0.0), + 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{}, @@ -204,25 +206,38 @@ TEST(test_prepared_boundary_plan, EXPECT_EQ(after, before); } -TEST(test_prepared_boundary_plan, analytic_inflow_preflights_nonfinite_values_before_mutation) { +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)); - MultiFab state = scalar_field(domain, 1, 1); + 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); }); + 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})); + 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); - if (state.local_size() > 0) - EXPECT_EQ(state.fab(0)(-1, 1, 0), Real(-99)); + 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) { From 0c40994e0d4865624ae1b836ed7d26332d12b1f1 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 03:48:01 +0200 Subject: [PATCH 39/93] Fix Kokkos concurrency reporting compatibility --- include/pops/runtime/runtime_environment.hpp | 3 ++- tests/cpp/integration/runtime/test_runtime_environment.cpp | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/include/pops/runtime/runtime_environment.hpp b/include/pops/runtime/runtime_environment.hpp index 73ee92e84..a0f52b878 100644 --- a/include/pops/runtime/runtime_environment.hpp +++ b/include/pops/runtime/runtime_environment.hpp @@ -136,7 +136,8 @@ inline RuntimeEnvironmentReport runtime_environment_report() { report.kokkos_stream = native_stream_identity(); report.kokkos_stream_synchronous = native_stream_is_synchronous(); if (report.kokkos_initialized) { - report.kokkos_concurrency = Kokkos::DefaultExecutionSpace::concurrency(); + const Kokkos::DefaultExecutionSpace execution_space{}; + report.kokkos_concurrency = execution_space.concurrency(); } if (report.kokkos_initialized_by_pops) { report.kokkos_ownership = "pops-owned-lazy"; diff --git a/tests/cpp/integration/runtime/test_runtime_environment.cpp b/tests/cpp/integration/runtime/test_runtime_environment.cpp index 808b73135..dbdbeb82c 100644 --- a/tests/cpp/integration/runtime/test_runtime_environment.cpp +++ b/tests/cpp/integration/runtime/test_runtime_environment.cpp @@ -29,7 +29,7 @@ TEST(RuntimeEnvironment, ReportsDimensionPrecisionAndBackends) { EXPECT_TRUE(report.has_kokkos) << "has_kokkos"; EXPECT_TRUE(!report.kokkos_backend.empty()) << "kokkos_backend_named"; if (report.kokkos_initialized) { - EXPECT_TRUE(report.kokkos_concurrency == Kokkos::DefaultExecutionSpace::concurrency()) + EXPECT_TRUE(report.kokkos_concurrency == Kokkos::DefaultExecutionSpace{}.concurrency()) << "initialized_kokkos_concurrency"; } else { EXPECT_TRUE(report.kokkos_concurrency == 0) << "inactive_kokkos_concurrency"; @@ -45,7 +45,7 @@ TEST(RuntimeEnvironment, ReportsDimensionPrecisionAndBackends) { const RuntimeEnvironmentReport initialized_report = runtime_environment_report(); EXPECT_TRUE(initialized_report.kokkos_initialized) << "kokkos_initialized_for_exact_probe"; EXPECT_TRUE(initialized_report.kokkos_concurrency == - Kokkos::DefaultExecutionSpace::concurrency()) + Kokkos::DefaultExecutionSpace{}.concurrency()) << "exact_default_execution_space_concurrency"; } #else From 7c0f42b84243049e786bc06aebfc15c348d6192d Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 03:49:55 +0200 Subject: [PATCH 40/93] Expand ADC-757 gate with delivered P2 evidence --- scripts/run_adc757_prepared_numerics_gate.py | 12 ++++-- tests/gates/adc757_prepared_numerics.toml | 42 +++++++++++++++++-- .../test_adc757_prepared_numerics_gate.py | 6 ++- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index d17f6c47e..857ceb837 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -20,11 +20,14 @@ "typed_fallible_evaluation", "transactional_recovery_publication", "allocation_aware_cell_hot_path", + "prepared_boundary_publication", + "typed_flux_recovery_consumption", + "runtime_recovery_consumer_publication", } EXPECTED_DEFERRED = ( - "boundary_geometry_riemann_and_spatial_provider_families", + "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "python_ir_generated_abi_and_restart_parity", - "runtime_consumer_cutover_and_legacy_deletion", + "remaining_legacy_recovery_boundary_and_riemann_authority_deletion", "amr_regrid_migration_and_restart_coherence", "mpi_collective_execution", "gpu_backend_execution", @@ -79,8 +82,9 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: errors.append("gate must be exactly 'adc757-prepared-numerics-slice'") if data.get("issue") != "ADC-757": errors.append("issue must be exactly ADC-757") - if data.get("evidence_from") != ["ADC-750", "ADC-753"]: - errors.append("evidence_from must be exactly ADC-750 then ADC-753") + expected_evidence = ["ADC-749", "ADC-750", "ADC-753", "ADC-754", "ADC-755"] + 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") diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index fe563c85a..b0c3309db 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -1,11 +1,11 @@ schema_version = 1 gate = "adc757-prepared-numerics-slice" issue = "ADC-757" -evidence_from = ["ADC-750", "ADC-753"] +evidence_from = ["ADC-749", "ADC-750", "ADC-753", "ADC-754", "ADC-755"] deferred = [ - "boundary_geometry_riemann_and_spatial_provider_families", + "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "python_ir_generated_abi_and_restart_parity", - "runtime_consumer_cutover_and_legacy_deletion", + "remaining_legacy_recovery_boundary_and_riemann_authority_deletion", "amr_regrid_migration_and_restart_coherence", "mpi_collective_execution", "gpu_backend_execution", @@ -63,3 +63,39 @@ 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 = "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 = "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$" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 598b972ce..8d639d87b 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 8 + assert len(data["check"]) == 14 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert runner.main(["--check-only"]) == 0 @@ -38,7 +38,9 @@ def test_adc757_slice_does_not_claim_full_mpi_gpu_or_runtime_closure(): assert data["deferred"] == list(runner.EXPECTED_DEFERRED) assert "mpi_collective_execution" in data["deferred"] assert "gpu_backend_execution" in data["deferred"] - assert "runtime_consumer_cutover_and_legacy_deletion" in data["deferred"] + assert "remaining_legacy_recovery_boundary_and_riemann_authority_deletion" 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 all( "mpi" not in row["target"].lower() and "gpu" not in row["target"].lower() for row in data["check"] From 29270afd134ba25d45118c1f185b472ea49c463a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 03:54:04 +0200 Subject: [PATCH 41/93] Track ADC-752 in the prepared numerics evidence ledger --- scripts/run_adc757_prepared_numerics_gate.py | 2 +- tests/gates/adc757_prepared_numerics.toml | 2 +- .../architecture/test_adc757_prepared_numerics_gate.py | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 857ceb837..638970cdc 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -82,7 +82,7 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: 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-749", "ADC-750", "ADC-753", "ADC-754", "ADC-755"] + expected_evidence = ["ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755"] if data.get("evidence_from") != expected_evidence: errors.append("evidence_from must be exactly %s" % expected_evidence) if data.get("deferred") != list(EXPECTED_DEFERRED): diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index b0c3309db..648460605 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -1,7 +1,7 @@ schema_version = 1 gate = "adc757-prepared-numerics-slice" issue = "ADC-757" -evidence_from = ["ADC-749", "ADC-750", "ADC-753", "ADC-754", "ADC-755"] +evidence_from = ["ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755"] deferred = [ "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "python_ir_generated_abi_and_restart_parity", diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 8d639d87b..43c053a2f 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -28,6 +28,14 @@ def test_adc757_slice_references_exact_real_mandatory_native_proofs(): assert not errors, "ADC-757 slice matrix is invalid:\n " + "\n ".join(errors) assert len(data["check"]) == 14 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS + assert data["evidence_from"] == [ + "ADC-749", + "ADC-750", + "ADC-752", + "ADC-753", + "ADC-754", + "ADC-755", + ] assert runner.main(["--check-only"]) == 0 From 414f11af69a068a751a42c6d3605034cfceacc22 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 04:03:03 +0200 Subject: [PATCH 42/93] Fix MPI execution context in axial boundary proof --- .../integration/runtime/test_axial_slip_wall_pipeline.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py index 406d0cf07..39e665402 100644 --- a/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py +++ b/tests/python/integration/runtime/test_axial_slip_wall_pipeline.py @@ -18,6 +18,7 @@ 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] @@ -91,7 +92,11 @@ def test_axial_role_compiles_binds_and_round_trips_native_metadata( 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}) + 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", From d67f67253b3beda1fb254b45f935f35a382bc525 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 04:49:53 +0200 Subject: [PATCH 43/93] test(numerics): execute exact MPI boundary proof --- scripts/run_adc757_prepared_numerics_gate.py | 39 +++++++++++++++++-- tests/gates/adc757_prepared_numerics.toml | 9 ++++- .../test_adc757_prepared_numerics_gate.py | 31 +++++++++++---- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 638970cdc..53c65f8d9 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -29,7 +29,6 @@ "python_ir_generated_abi_and_restart_parity", "remaining_legacy_recovery_boundary_and_riemann_authority_deletion", "amr_regrid_migration_and_restart_coherence", - "mpi_collective_execution", "gpu_backend_execution", "workspace_reentrancy_and_stream_partitioning", "performance_baselines_and_end_to_end_benchmarks", @@ -95,9 +94,14 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: suites = _cpp_suites() coverage: dict[str, set[str]] = defaultdict(set) identities = Counter() + mpi_checks = 0 for index, row in enumerate(checks, 1): where = "check[%d]" % index - if set(row) != {"requirement", "polarity", "target", "test_regex"}: + kind = row.get("kind", "ctest") + 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") @@ -122,8 +126,33 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: if target not in suites: errors.append("%s references unknown CTest target %r" % (where, target)) continue - if "mpi" in str(target).lower() or "gpu" in str(target).lower(): - errors.append("%s claims a deferred MPI/GPU target %r" % (where, target)) + suite = suites[target] + labels = {str(label) for label in suite.get("labels", ())} + if kind == "mpi_ctest": + mpi_checks += 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)) names, source_errors = _declared_gtests(suites[target]) errors.extend("%s: %s" % (where, error) for error in source_errors) try: @@ -139,6 +168,8 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: duplicates = sorted(identity for identity, count in identities.items() if count > 1) if duplicates: errors.append("duplicate executable checks: %s" % duplicates) + if mpi_checks != 1: + errors.append("the closed mpi_collective_execution family requires exactly one MPI CTest") for requirement in sorted(EXPECTED_REQUIREMENTS): missing = {"positive", "refusal"} - coverage[requirement] if missing: diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 648460605..262098c5b 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -7,7 +7,6 @@ deferred = [ "python_ir_generated_abi_and_restart_parity", "remaining_legacy_recovery_boundary_and_riemann_authority_deletion", "amr_regrid_migration_and_restart_coherence", - "mpi_collective_execution", "gpu_backend_execution", "workspace_reentrancy_and_stream_partitioning", "performance_baselines_and_end_to_end_benchmarks", @@ -76,6 +75,14 @@ polarity = "refusal" target = "test_prepared_boundary_plan" test_regex = "^test_prepared_boundary_plan\\.analytic_inflow_preflights_nonfinite_values_before_any_mutation$" +[[check]] +requirement = "prepared_boundary_publication" +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 = "typed_flux_recovery_consumption" polarity = "positive" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 43c053a2f..2bb6e3328 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 14 + assert len(data["check"]) == 15 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", @@ -39,20 +39,29 @@ def test_adc757_slice_references_exact_real_mandatory_native_proofs(): assert runner.main(["--check-only"]) == 0 -def test_adc757_slice_does_not_claim_full_mpi_gpu_or_runtime_closure(): +def test_adc757_slice_claims_only_the_exact_delivered_mpi_collective_proof(): runner = _load_runner() data, errors = runner.validate_manifest(MANIFEST) assert not errors assert data["deferred"] == list(runner.EXPECTED_DEFERRED) - assert "mpi_collective_execution" in data["deferred"] + assert "mpi_collective_execution" not in data["deferred"] assert "gpu_backend_execution" in data["deferred"] assert "remaining_legacy_recovery_boundary_and_riemann_authority_deletion" 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 all( - "mpi" not in row["target"].lower() and "gpu" not in row["target"].lower() - for row in data["check"] - ) + assert [ + row for row in data["check"] if row.get("kind") == "mpi_ctest" + ] == [ + { + "requirement": "prepared_boundary_publication", + "polarity": "positive", + "kind": "mpi_ctest", + "target": "test_mpi_system_analytic_level_set", + "test_regex": "^test_mpi_system_analytic_level_set_np2$", + "nproc": 2, + } + ] + assert all("gpu" not in row["target"].lower() for row in data["check"]) assert runner.main(["--check-only", "--closure"]) == 3 @@ -80,6 +89,14 @@ def test_adc757_manifest_refuses_missing_polarity_and_unknown_target(tmp_path): _, 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) + def test_adc757_runner_refuses_a_declared_but_unbuilt_proof(monkeypatch, tmp_path): runner = _load_runner() From bdb662c3e3cf16b619f8d0f7032baf0eb6a5beca Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 05:06:06 +0200 Subject: [PATCH 44/93] fix(boundary): require prepared linearization sessions --- src/runtime/system/system_program.cpp | 32 +++++++----------- ...epared_boundary_linearization_authority.py | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 tests/python/architecture/test_prepared_boundary_linearization_authority.py diff --git a/src/runtime/system/system_program.cpp b/src/runtime/system/system_program.cpp index 648881949..4f322b507 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, 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 From ba0b6019ec79f0c7fa9cf68b259c58e5d06043b5 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 05:10:11 +0200 Subject: [PATCH 45/93] fix(architecture): admit analytic boundary expressions --- tests/python/architecture/test_import_graph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"}, From 0d2213e3c2c97e3a53dce5d04512366a2f7c8005 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 05:26:14 +0200 Subject: [PATCH 46/93] fix(nonlinear): reject unpublished Newton diagnostics --- include/pops/runtime/system.hpp | 15 +++++++------- .../system/system_diagnostics_registry.hpp | 16 +++++++-------- python/bindings/core/init/init_system.cpp | 8 +++----- python/pops/runtime/_system_install.py | 10 ++++++++++ src/runtime/system/system_install.cpp | 20 +++++++++---------- .../test_program_only_temporal_facades.py | 19 ++++++++++++++++++ .../test_numerical_defaults_reports.py | 16 +++++++++++++-- 7 files changed, 70 insertions(+), 34 deletions(-) diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 98c1ec182..15e5e300d 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -224,11 +224,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). @@ -247,9 +246,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 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/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index ae37aa3c3..b1ac8864e 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -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), @@ -268,9 +268,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) { diff --git a/python/pops/runtime/_system_install.py b/python/pops/runtime/_system_install.py index b33ae6b92..54b90299d 100644 --- a/python/pops/runtime/_system_install.py +++ b/python/pops/runtime/_system_install.py @@ -41,6 +41,14 @@ _System = object +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" + ) + + class _SystemInstall(_System): """Block/equation/coupling installation methods of System.""" @@ -75,6 +83,7 @@ def add_block(self, name: Any, model: Any, spatial: Any = None, time: Any = None _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() + _reject_unpublished_newton_diagnostics(time, where="System.add_block") # 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") @@ -117,6 +126,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") diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index cf55ebfb0..aef8c5f6b 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. @@ -161,12 +165,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 @@ -618,14 +616,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"); + throw std::runtime_error( + "System::newton_report : no typed implicit Program consumer published diagnostics for " + "block '" + + name + "'"); const NewtonReport& r = *rp; return SourceNewtonReport{r.enabled, r.converged, diff --git a/tests/python/architecture/test_program_only_temporal_facades.py b/tests/python/architecture/test_program_only_temporal_facades.py index 57bfd9901..69ec2371b 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" @@ -332,6 +333,24 @@ 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_block = _python_function_source(python, "add_block") + 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 + for entrypoint in (python_add_block, python_add_equation): + assert entrypoint.index("_reject_unpublished_newton_diagnostics(time") < entrypoint.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/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) From ed42f91aaf69b00fb2127a2f2d8a0f05922c5af7 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 05:28:17 +0200 Subject: [PATCH 47/93] fix(riemann): reject non-finite HLLC candidates --- docs/ALGORITHMS.md | 6 +- include/pops/numerics/fv/flux_interfaces.hpp | 5 ++ include/pops/numerics/fv/numerical_flux.hpp | 30 +++++++- .../unit/numerics/test_flux_interfaces.cpp | 68 +++++++++++++++++++ .../test_flux_interface_fences.py | 21 ++++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index e76f2eb30..1b84381c7 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -230,8 +230,10 @@ through those same capabilities. A missing capability is rejected during route r is no component-count inference and no implicit HLL/Rusanov substitution. The 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 instead of publishing a successful NaN result; the -runtime then rolls the owning step transaction back without selecting another solver. +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. 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 diff --git a/include/pops/numerics/fv/flux_interfaces.hpp b/include/pops/numerics/fv/flux_interfaces.hpp index 0d1855b17..d7d616cf4 100644 --- a/include/pops/numerics/fv/flux_interfaces.hpp +++ b/include/pops/numerics/fv/flux_interfaces.hpp @@ -113,6 +113,11 @@ enum class RiemannFailureCause : std::uint32_t { 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), diff --git a/include/pops/numerics/fv/numerical_flux.hpp b/include/pops/numerics/fv/numerical_flux.hpp index 08a200cb5..8b743ca48 100644 --- a/include/pops/numerics/fv/numerical_flux.hpp +++ b/include/pops/numerics/fv/numerical_flux.hpp @@ -206,26 +206,52 @@ struct HLLCFlux { 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, diff --git a/tests/cpp/unit/numerics/test_flux_interfaces.cpp b/tests/cpp/unit/numerics/test_flux_interfaces.cpp index 98f220d4d..686b0d454 100644 --- a/tests/cpp/unit/numerics/test_flux_interfaces.cpp +++ b/tests/cpp/unit/numerics/test_flux_interfaces.cpp @@ -44,6 +44,47 @@ struct NonFiniteRoeFluxAdvect : Advect { } }; +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 Aux&, int) const { + return failure_site == HllcFailureSite::kPhysicalFlux + ? State{std::numeric_limits::quiet_NaN()} + : state; + } + POPS_HD pops::Real max_wave_speed(const State&, const Aux&, int) const { return pops::Real(1); } + POPS_HD void wave_speeds(const State&, const Aux&, 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; @@ -327,6 +368,33 @@ TEST(test_flux_interfaces, roe_rejects_nonfinite_dissipation_with_a_typed_cause) 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) { static_assert(std::is_trivially_copyable_v); static_assert(sizeof(pops::FluxEvaluationTracker) == sizeof(std::uint64_t)); diff --git a/tests/python/architecture/test_flux_interface_fences.py b/tests/python/architecture/test_flux_interface_fences.py index 1083f28e5..508528340 100644 --- a/tests/python/architecture/test_flux_interface_fences.py +++ b/tests/python/architecture/test_flux_interface_fences.py @@ -55,3 +55,24 @@ 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 From 34b7d7f52284f2b99688122fef9e4c54f849104a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 05:35:04 +0200 Subject: [PATCH 48/93] test(numerics): admit capability-driven Riemann gate proofs --- scripts/run_adc757_prepared_numerics_gate.py | 3 ++- tests/gates/adc757_prepared_numerics.toml | 20 ++++++++++++++++++- .../test_adc757_prepared_numerics_gate.py | 5 +++-- .../test_flux_interface_fences.py | 19 ++++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 53c65f8d9..eae33ac2d 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -21,13 +21,14 @@ "transactional_recovery_publication", "allocation_aware_cell_hot_path", "prepared_boundary_publication", + "capability_driven_riemann", "typed_flux_recovery_consumption", "runtime_recovery_consumer_publication", } EXPECTED_DEFERRED = ( "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "python_ir_generated_abi_and_restart_parity", - "remaining_legacy_recovery_boundary_and_riemann_authority_deletion", + "remaining_legacy_recovery_and_boundary_authority_deletion", "amr_regrid_migration_and_restart_coherence", "gpu_backend_execution", "workspace_reentrancy_and_stream_partitioning", diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 262098c5b..909d05f3e 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -5,7 +5,7 @@ evidence_from = ["ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755 deferred = [ "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "python_ir_generated_abi_and_restart_parity", - "remaining_legacy_recovery_boundary_and_riemann_authority_deletion", + "remaining_legacy_recovery_and_boundary_authority_deletion", "amr_regrid_migration_and_restart_coherence", "gpu_backend_execution", "workspace_reentrancy_and_stream_partitioning", @@ -83,6 +83,24 @@ target = "test_mpi_system_analytic_level_set" test_regex = "^test_mpi_system_analytic_level_set_np2$" nproc = 2 +[[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 = "typed_flux_recovery_consumption" polarity = "positive" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 2bb6e3328..83e246cf1 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 15 + assert len(data["check"]) == 18 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", @@ -46,7 +46,8 @@ def test_adc757_slice_claims_only_the_exact_delivered_mpi_collective_proof(): assert data["deferred"] == list(runner.EXPECTED_DEFERRED) assert "mpi_collective_execution" not in data["deferred"] assert "gpu_backend_execution" in data["deferred"] - assert "remaining_legacy_recovery_boundary_and_riemann_authority_deletion" in data["deferred"] + assert "remaining_legacy_recovery_and_boundary_authority_deletion" 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 [ diff --git a/tests/python/architecture/test_flux_interface_fences.py b/tests/python/architecture/test_flux_interface_fences.py index 508528340..49918c29e 100644 --- a/tests/python/architecture/test_flux_interface_fences.py +++ b/tests/python/architecture/test_flux_interface_fences.py @@ -76,3 +76,22 @@ def test_hllc_rejects_nonfinite_provider_stages_before_publication(): 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 From 600836da33424a03a1c6d70b709888ba8d1fb397 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 05:36:16 +0200 Subject: [PATCH 49/93] test(numerics): prove collective MPI failure handling --- scripts/run_adc757_prepared_numerics_gate.py | 7 +++++-- tests/gates/adc757_prepared_numerics.toml | 10 +++++++++- .../test_adc757_prepared_numerics_gate.py | 14 +++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index eae33ac2d..979ae96c9 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -22,6 +22,7 @@ "allocation_aware_cell_hot_path", "prepared_boundary_publication", "capability_driven_riemann", + "mpi_collective_execution", "typed_flux_recovery_consumption", "runtime_recovery_consumer_publication", } @@ -169,8 +170,10 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: duplicates = sorted(identity for identity, count in identities.items() if count > 1) if duplicates: errors.append("duplicate executable checks: %s" % duplicates) - if mpi_checks != 1: - errors.append("the closed mpi_collective_execution family requires exactly one MPI CTest") + if mpi_checks != 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: diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 909d05f3e..4d2ad3f4e 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -76,13 +76,21 @@ target = "test_prepared_boundary_plan" test_regex = "^test_prepared_boundary_plan\\.analytic_inflow_preflights_nonfinite_values_before_any_mutation$" [[check]] -requirement = "prepared_boundary_publication" +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 = "capability_driven_riemann" polarity = "positive" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 83e246cf1..4bd78d9c4 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 18 + assert len(data["check"]) == 19 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", @@ -54,13 +54,21 @@ def test_adc757_slice_claims_only_the_exact_delivered_mpi_collective_proof(): row for row in data["check"] if row.get("kind") == "mpi_ctest" ] == [ { - "requirement": "prepared_boundary_publication", + "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, + }, ] assert all("gpu" not in row["target"].lower() for row in data["check"]) assert runner.main(["--check-only", "--closure"]) == 3 From 61bd2f3a2b85cef590219c348e230a4f6628f0c3 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 06:52:42 +0200 Subject: [PATCH 50/93] fix(codegen): bind qualified flux provider packs (ADC-682) --- include/pops/numerics/fv/flux_interfaces.hpp | 1 + python/pops/codegen/_compiler_lowering.py | 9 ++ .../pops/codegen/component_provider_packs.py | 104 ++++++++++++++++++ python/pops/codegen/module_emit_brick.py | 6 +- python/pops/codegen/module_lowering.py | 35 ++---- python/pops/model/provider_pack.py | 55 ++++++++- python/pops/physics/_authoring_view.py | 73 ++++++++++-- python/pops/physics/_facade_compile.py | 16 +++ 8 files changed, 265 insertions(+), 34 deletions(-) create mode 100644 python/pops/codegen/component_provider_packs.py diff --git a/include/pops/numerics/fv/flux_interfaces.hpp b/include/pops/numerics/fv/flux_interfaces.hpp index 0265fad9f..a8e33b31d 100644 --- a/include/pops/numerics/fv/flux_interfaces.hpp +++ b/include/pops/numerics/fv/flux_interfaces.hpp @@ -96,6 +96,7 @@ struct QualifiedProviderRequirement { const char* layout; const char* value_kind; const char* producer; + bool available; int storage_slot; }; 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/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/module_emit_brick.py b/python/pops/codegen/module_emit_brick.py index 1bdc4c745..7389c5e76 100644 --- a/python/pops/codegen/module_emit_brick.py +++ b/python/pops/codegen/module_emit_brick.py @@ -241,8 +241,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")) diff --git a/python/pops/codegen/module_lowering.py b/python/pops/codegen/module_lowering.py index 56cce3234..fd1f1fb34 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 @@ -75,30 +74,13 @@ 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()) + m.__pops_bind_component_provider_packs__( + resolve_component_provider_packs(module) + ) # 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 @@ -191,7 +173,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 m._component_provider_pack: key_data = key.to_data() stable_key = "%s/%s/%s" % ( key_data["space_kind"], key_data["space_name"], key_data["component"]) @@ -441,6 +423,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/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/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/_facade_compile.py b/python/pops/physics/_facade_compile.py index 746de5ad1..f1ef03f44 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) From d7b34014847ab7d1c71dff35b43cdd18024a1dab Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 06:52:47 +0200 Subject: [PATCH 51/93] test(codegen): prove exact flux provider identity (ADC-682) --- .../codegen/test_compiled_model_boundary.py | 3 + .../codegen/test_compiler_model_provider.py | 59 +++++++++++++++++++ .../codegen/test_component_provider_pack.py | 38 ++++++++++++ 3 files changed, 100 insertions(+) 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..28f6e0aef 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,46 @@ 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 + + +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_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) From a34649cc5fb471f8370cea4d14083a8179afd204 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 07:50:22 +0200 Subject: [PATCH 52/93] feat(numerics): bind generated flux provider slots --- include/pops/numerics/fv/flux_interfaces.hpp | 80 +++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/include/pops/numerics/fv/flux_interfaces.hpp b/include/pops/numerics/fv/flux_interfaces.hpp index a8e33b31d..937f46dd5 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 { @@ -127,6 +129,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. @@ -136,6 +178,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, @@ -176,15 +220,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 From 2cc1f15d4388a96c62a77b4f26c4b462a3984bf5 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 07:50:26 +0200 Subject: [PATCH 53/93] tests: prove qualified native flux binding --- .../unit/numerics/test_flux_interfaces.cpp | 68 +++++++++++++++++++ .../test_flux_interface_fences.py | 8 +++ .../codegen/test_compiler_model_provider.py | 2 + 3 files changed, 78 insertions(+) diff --git a/tests/cpp/unit/numerics/test_flux_interfaces.cpp b/tests/cpp/unit/numerics/test_flux_interfaces.cpp index 7c3d7a211..c8f5efa9f 100644 --- a/tests/cpp/unit/numerics/test_flux_interfaces.cpp +++ b/tests/cpp/unit/numerics/test_flux_interfaces.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -68,6 +69,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{}; @@ -253,6 +297,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)}; diff --git a/tests/python/architecture/test_flux_interface_fences.py b/tests/python/architecture/test_flux_interface_fences.py index 1083f28e5..03efb02b7 100644 --- a/tests/python/architecture/test_flux_interface_fences.py +++ b/tests/python/architecture/test_flux_interface_fences.py @@ -48,6 +48,14 @@ def test_bound_native_flux_pack_is_exact_and_does_not_store_global_aux(): assert "FluxDensity checked_density() const" in header +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 diff --git a/tests/python/unit/codegen/test_compiler_model_provider.py b/tests/python/unit/codegen/test_compiler_model_provider.py index 28f6e0aef..c915bb37c 100644 --- a/tests/python/unit/codegen/test_compiler_model_provider.py +++ b/tests/python/unit/codegen/test_compiler_model_provider.py @@ -132,6 +132,8 @@ def test_facade_and_formula_carrier_share_one_minimal_flux_provider_pack(): 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 def test_field_dependent_flux_without_provider_fails_before_native_source(): From 3c33ea6948a07b852b62ba955640de214b283770 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 07:50:32 +0200 Subject: [PATCH 54/93] docs: record generated flux provider ABI --- CHANGELOG.md | 3 +++ docs/ARCHITECTURE.md | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89338adb0..b9849c6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning ### Changed +- 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. - AMR checkpoint capability reports now distinguish same-rank bit-identical replay from non-bit-identical rank-count rematerialization with Dense persisted histories. The M3 gate executes the persisted two-rank to one-rank restart proof. The explicit `RegridOnRestart()` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1da4cb10d..a9dfc20fb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -767,6 +767,11 @@ 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. Hand-written C++ test models +that do not declare this generated ABI retain the full-width fixture path; generated PoPS models +never use that route. ## Limitations From cae125275b78d8eda750622000193297c973c71b Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sat, 1 Aug 2026 23:43:27 +0200 Subject: [PATCH 55/93] test(boundary): ratchet remaining ADC-749 legacy authorities --- ...t_hyperbolic_boundary_authority_ratchet.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py 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..c3864e422 --- /dev/null +++ b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py @@ -0,0 +1,92 @@ +"""ADC-749: legacy transport-boundary authorities may only disappear. + +The prepared hyperbolic boundary path is already the compiled Uniform/AMR +route. A few older native authorities still exist while their replacements +need metric, characteristic, and post-Riemann kernels. Keep their remaining +lexical surface bounded so adjacent work cannot silently create another +transport-boundary engine before that cutover is complete. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +PRODUCTION_ROOTS = (ROOT / "include/pops", ROOT / "src/runtime") + +# Exact upper bounds on the consolidated ADC-749 branch. Counts deliberately +# include comments: closure requires deleting the legacy vocabulary as well as +# its executable branches. A deletion passes without editing this ledger; +# any new file or additional occurrence fails closed. +LEGACY_AUTHORITY_LIMITS = { + "AmrBoundaryFillAuthority": { + "include/pops/coupling/amr/amr_coupler_mp.hpp": 2, + "include/pops/numerics/time/amr/levels/amr_subcycling.hpp": 6, + "include/pops/runtime/amr/amr_runtime.hpp": 1, + "include/pops/runtime/builders/compiled/amr_dsl_block.hpp": 1, + }, + "make_amr_boundary_fill_authority": { + "include/pops/numerics/time/amr/levels/amr_subcycling.hpp": 1, + "include/pops/runtime/builders/compiled/amr_dsl_block.hpp": 1, + }, + "wall_radial": { + "include/pops/numerics/spatial/operators/polar_operator.hpp": 9, + "include/pops/runtime/builders/block/block_builder_polar.hpp": 13, + "src/runtime/system/system_polar.cpp": 2, + }, + "fill_ghosts_polar": { + "include/pops/runtime/builders/block/block_builder_polar.hpp": 3, + }, + "transport_bc": { + "include/pops/runtime/amr/amr_runtime.hpp": 3, + "include/pops/runtime/builders/compiled/amr_dsl_block.hpp": 7, + "include/pops/runtime/program/amr_program_context.hpp": 2, + }, +} + + +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 LEGACY_AUTHORITY_LIMITS + } + 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_can_only_shrink() -> None: + occurrences = _occurrences() + violations = [] + for identifier, limits in LEGACY_AUTHORITY_LIMITS.items(): + for path, count in occurrences[identifier].items(): + limit = limits.get(path, 0) + if count > limit: + violations.append( + "%s: %s has %d occurrence(s), allowed at most %d" + % (identifier, path, count, limit) + ) + + assert not violations, ( + "legacy transport-boundary authority expanded; lower the route to " + "PreparedBoundaryPlan instead:\n " + "\n ".join(violations) + ) From cc765737e583d1b549416e718ed56b73732b8679 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sat, 1 Aug 2026 23:50:07 +0200 Subject: [PATCH 56/93] feat(riemann): expose recovery capability boundary --- python/pops/_capabilities_report.py | 41 +++++++++++++++++++ .../unit/codegen/test_fail_closed_reports.py | 30 ++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index c7a630581..479ec9ee0 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -462,6 +462,47 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: alternative="add the typed NumericalFlux boundary component interface", 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, and reason code; face failures are reduced and " + "reject the owning transaction, but no fallback solver can be selected" + ), + source=source, + ), + _row( + "riemann:prepared_recovery_policy", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "there is no prepared ordered Riemann recovery chain, requested-versus-used " + "solver outcome, block counter, or restart metadata; a typed candidate failure " + "rejects the step and never substitutes another solver" + ), + requested=( + "prepared Riemann recovery chain with requested/used solver diagnostics" + ), + available_route=( + "one explicitly selected Riemann solver with typed rejection and transactional " + "rollback" + ), + alternative=( + "select one supported Riemann route explicitly and consume rejection through " + "the step retry/failure policy" + ), + source=source, + ), _row( "amr:field_coupled_rhs_jacvec", layout="amr", diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 199eb3756..4fa86d22a 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -128,6 +128,36 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k assert route.error_message +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 "typed status, stability bound, and reason code" in typed.limitation + assert "reject the owning transaction" in typed.limitation + assert "no fallback solver can be selected" in typed.limitation + + policy = routes["riemann:prepared_recovery_policy"] + assert policy.status == "unavailable" + assert policy.layout == "uniform|amr" + assert policy.backend == "none" + assert "no prepared ordered Riemann recovery chain" in policy.limitation + assert "requested-versus-used solver outcome" in policy.limitation + assert "never substitutes another solver" in policy.limitation + assert "typed rejection and transactional rollback" in policy.available_route + assert "consume rejection through the step retry/failure policy" in policy.alternative + assert policy.error_message + + def test_defaults_source_only_is_not_used_for_a_loaded_broken_extension(monkeypatch): monkeypatch.setattr(defaults, "_native_extension", lambda: None) assert defaults.numerical_defaults_report()["source"] == "source-only" From e5d0dbd38f8ed68ea3d099b8d6ebb99197e1408a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sat, 1 Aug 2026 23:50:18 +0200 Subject: [PATCH 57/93] docs(riemann): state recovery policy non-claims --- CHANGELOG.md | 5 +++++ docs/design/native-capability-matrix.md | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4abeb63..c33e636f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning ### Changed +- 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. - 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 diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index 7c4c6fab6..6c8fa7909 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -126,6 +126,13 @@ Supported native routes include: 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 to model capability requirements. + `riemann:typed_failure_outcome` is deliberately `partial`: every built-in returns the common + device-copyable `FluxEvaluation` with typed status, stability bound, and reason code, and a + reduced failure rejects the owning transaction instead of publishing a candidate or silently + selecting another solver. `riemann:prepared_recovery_policy` is separately `unavailable`: + there is no prepared ordered solver chain, requested-versus-used solver outcome, block counter, + or restart metadata yet. Callers can therefore request the single-solver typed-rejection route + explicitly, but cannot claim a configured fallback policy. - 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. From 4b3d1fcbf4711ac0c574a2c1740a50d22994a732 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sat, 1 Aug 2026 23:55:42 +0200 Subject: [PATCH 58/93] feat(recovery): expose consumer cutover capability boundary --- python/pops/_capabilities_report.py | 42 +++++++++++++++++++ .../unit/codegen/test_fail_closed_reports.py | 30 +++++++++++++ 2 files changed, 72 insertions(+) diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index c7a630581..57c56746c 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -462,6 +462,48 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: alternative="add the typed NumericalFlux boundary component interface", 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; System conservative-to-primitive " + "materialization and Cartesian, polar, masked, and embedded-boundary face " + "reconstruction consume publication permission before copying or flux " + "evaluation, with no implicit repair, fallback, or mutable cache" + ), + source=source, + ), + _row( + "recovery:complete_consumer_cutover", + layout="uniform|amr", + backend="none", + platform="host", + mpi=mpi, + gpu=gpu, + status="unavailable", + limitation=( + "initial and analytic materialization, model/source conversion, AMR " + "transfer/regrid, primitive boundary traces, fallible primitive-to-conservative " + "conversion, persistent warm starts, 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 materialization and spatial face " + "reconstruction" + ), + alternative=( + "use the delivered conservative-to-primitive consumers or implement the missing " + "fallible provider and cache/restart contracts" + ), + source=source, + ), _row( "amr:field_coupled_rhs_jacvec", layout="amr", diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 199eb3756..5995e021b 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -128,6 +128,36 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k assert route.error_message +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 "consume publication permission" in prepared.limitation + assert "no implicit repair, fallback, or mutable cache" in prepared.limitation + + cutover = routes["recovery:complete_consumer_cutover"] + assert cutover.status == "unavailable" + assert cutover.layout == "uniform|amr" + assert cutover.backend == "none" + assert "initial and analytic materialization" in cutover.limitation + assert "AMR transfer/regrid" in cutover.limitation + assert "persistent warm starts" in cutover.limitation + assert "System materialization and spatial face reconstruction" in cutover.available_route + assert "missing fallible provider and cache/restart contracts" in cutover.alternative + assert cutover.error_message + + def test_defaults_source_only_is_not_used_for_a_loaded_broken_extension(monkeypatch): monkeypatch.setattr(defaults, "_native_extension", lambda: None) assert defaults.numerical_defaults_report()["source"] == "source-only" From 9af3540a96b35e0f0264d423fff11724b94d3024 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sat, 1 Aug 2026 23:55:57 +0200 Subject: [PATCH 59/93] docs(recovery): state complete cutover non-claims --- CHANGELOG.md | 5 +++++ docs/design/native-capability-matrix.md | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4abeb63..e7b4a6835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning ### Changed +- 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 diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index 7c4c6fab6..c968b3ce9 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -126,6 +126,15 @@ Supported native routes include: 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 to model capability requirements. +- Prepared variable recovery is explicitly `partial`. One block-prepared closed-form method returns + a device-copyable `RecoveryOutcome`/`RecoveryReport`; System conservative-to-primitive + materialization and Cartesian, polar, masked, and embedded-boundary face reconstruction consume + publication permission before copying a candidate or evaluating a flux. This route adds no + implicit repair, fallback, or mutable cache. The separate + `recovery:complete_consumer_cutover` capability remains `unavailable`: initial/analytic and + model/source conversion, AMR transfer/regrid, primitive boundary traces, fallible + primitive-to-conservative conversion, persistent warm starts, cache/restart, 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. From aef0c0ff269186bfd4dba455d9abe2cbd5436464 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:06:42 +0200 Subject: [PATCH 60/93] feat(recovery): enforce model admissibility policies --- include/pops/core/model/physical_model.hpp | 14 ++++ .../nonlinear/prepared_variable_recovery.hpp | 26 +++++++- .../pops/physics/composition/composite.hpp | 11 ++++ python/pops/codegen/_compile_emit.py | 5 ++ python/pops/codegen/module_emit_brick.py | 20 ++++++ python/pops/physics/_authoring_recovery.py | 64 +++++++++++++++++++ python/pops/physics/_facade.py | 13 +++- python/pops/physics/_model.py | 6 +- python/pops/physics/_model_contract.py | 1 + 9 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 python/pops/physics/_authoring_recovery.py diff --git a/include/pops/core/model/physical_model.hpp b/include/pops/core/model/physical_model.hpp index d6b201c42..e53725771 100644 --- a/include/pops/core/model/physical_model.hpp +++ b/include/pops/core/model/physical_model.hpp @@ -171,6 +171,20 @@ 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/numerics/nonlinear/prepared_variable_recovery.hpp b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp index fa17721a4..607ec2a2d 100644 --- a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -406,6 +406,23 @@ struct FiniteModelRecoveryAdmissibility { } }; +/// 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 { @@ -442,9 +459,12 @@ template POPS_HD constexpr auto prepare_model_variable_recovery(const Model& model) { constexpr int N = Model::n_vars; if constexpr (HasPrimitiveVars) { - return prepare_variable_recovery( - FiniteModelRecoveryAdmissibility{}, - recovery_methods(ClosedFormModelRecoveryMethod{model})); + 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{})); diff --git a/include/pops/physics/composition/composite.hpp b/include/pops/physics/composition/composite.hpp index aacb38bd6..28481c731 100644 --- a/include/pops/physics/composition/composite.hpp +++ b/include/pops/physics/composition/composite.hpp @@ -63,6 +63,17 @@ 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); } { diff --git a/python/pops/codegen/_compile_emit.py b/python/pops/codegen/_compile_emit.py index 162c811e2..b9ae1f792 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, [])))) diff --git a/python/pops/codegen/module_emit_brick.py b/python/pops/codegen/module_emit_brick.py index de7c6c9b7..f942885ea 100644 --- a/python/pops/codegen/module_emit_brick.py +++ b/python/pops/codegen/module_emit_brick.py @@ -474,6 +474,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/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/_facade.py b/python/pops/physics/_facade.py index a2ca573e2..6117355eb 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) diff --git a/python/pops/physics/_model.py b/python/pops/physics/_model.py index 0669ef5ab..2388d8b1f 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) diff --git a/python/pops/physics/_model_contract.py b/python/pops/physics/_model_contract.py index 3ae804449..44700acb2 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 From b5e61e75604bee9758f791e30f0188386bdf1326 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:08:02 +0200 Subject: [PATCH 61/93] test(recovery): cover declared model admissibility --- .../numerics/test_variable_recovery_chain.cpp | 58 +++++++++++++++++++ .../test_recovery_admissibility_codegen.py | 48 +++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/python/unit/codegen/test_recovery_admissibility_codegen.py diff --git a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp index ba3dc8e72..c70192b78 100644 --- a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp +++ b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp @@ -1,6 +1,9 @@ #include #include +#include +#include +#include #include #include @@ -89,6 +92,40 @@ struct RepairCandidate { } }; +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{})); @@ -237,4 +274,25 @@ TEST(PreparedVariableRecovery, malformed_and_repair_candidates_fail_closed) { 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()); + + 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/python/unit/codegen/test_recovery_admissibility_codegen.py b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py new file mode 100644 index 000000000..384bcf319 --- /dev/null +++ b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import pytest + +from pops.physics import Model + + +def _scalar_model(*, guarded: bool) -> Model: + model = Model("scalar") + (conserved,) = model.conservative_vars("q") + (primitive,) = model.primitive_vars(q=conserved) + model.conservative_from([primitive]) + model.flux(x=[conserved], y=[conserved]) + model.eigenvalues(x=[1], y=[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._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._m.emit_cpp_brick(name="PlainScalar") + assert guarded._model_hash() != plain._model_hash() + + +def test_recovery_admissibility_rejects_ambiguous_authoring() -> None: + model = Model("guarded_scalar") + (conserved,) = model.conservative_vars("q") + + with pytest.raises(ValueError, match=r"primitive_vars\(\.\.\.\) first"): + model.recovery_admissibility(q=conserved > 0) + + (primitive,) = model.primitive_vars(q=conserved) + 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) From 0249f8ad8456b1f538e53d1c9cf19a577c9b0389 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:08:27 +0200 Subject: [PATCH 62/93] docs(recovery): document model admissibility authoring --- docs/ALGORITHMS.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index 1b84381c7..db783cf38 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -408,10 +408,27 @@ 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 +rho, u, v, p = model.primitive_vars(rho=rho, u=u, v=v, p=pressure) +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_variable_recovery_chain` (a model-declared physical predicate blocks publication +and preserves the typed failing component). --- From 24536d45108390415046aa034148657d7c6c8395 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:11:01 +0200 Subject: [PATCH 63/93] feat(recovery): expose admissibility on public models --- include/pops/core/model/physical_model.hpp | 3 +-- .../nonlinear/prepared_variable_recovery.hpp | 3 ++- python/pops/physics/board.py | 20 ++++++++++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/include/pops/core/model/physical_model.hpp b/include/pops/core/model/physical_model.hpp index e53725771..eece21d5c 100644 --- a/include/pops/core/model/physical_model.hpp +++ b/include/pops/core/model/physical_model.hpp @@ -180,8 +180,7 @@ concept HasPrimitiveVars = /// 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) { + HasPrimitiveVars && requires(const M m, const typename M::Prim p, int* failing_component) { { m.recovery_admissible(p, failing_component) } -> std::same_as; }; diff --git a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp index 607ec2a2d..39a379b03 100644 --- a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -461,7 +461,8 @@ POPS_HD constexpr auto prepare_model_variable_recovery(const Model& model) { if constexpr (HasPrimitiveVars) { const auto methods = recovery_methods(ClosedFormModelRecoveryMethod{model}); if constexpr (HasRecoveryAdmissibility) { - return prepare_variable_recovery(DeclaredModelRecoveryAdmissibility{model}, methods); + return prepare_variable_recovery(DeclaredModelRecoveryAdmissibility{model}, + methods); } else { return prepare_variable_recovery(FiniteModelRecoveryAdmissibility{}, methods); } diff --git a/python/pops/physics/board.py b/python/pops/physics/board.py index 7246baf8d..4ec675024 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) From f20f6fe5fe4b48edebf0c3085f6aaf2690015849 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:11:12 +0200 Subject: [PATCH 64/93] test(recovery): exercise public admissibility authoring --- .../test_recovery_admissibility_codegen.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/tests/python/unit/codegen/test_recovery_admissibility_codegen.py b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py index 384bcf319..16c33a1d9 100644 --- a/tests/python/unit/codegen/test_recovery_admissibility_codegen.py +++ b/tests/python/unit/codegen/test_recovery_admissibility_codegen.py @@ -3,15 +3,20 @@ 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") - (conserved,) = model.conservative_vars("q") - (primitive,) = model.primitive_vars(q=conserved) - model.conservative_from([primitive]) - model.flux(x=[conserved], y=[conserved]) - model.eigenvalues(x=[1], y=[1]) + 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 @@ -21,23 +26,23 @@ def test_recovery_admissibility_is_emitted_and_hashed() -> None: guarded = _scalar_model(guarded=True) plain = _scalar_model(guarded=False) - generated = guarded._m.emit_cpp_brick(name="GuardedScalar") + 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._m.emit_cpp_brick(name="PlainScalar") - assert guarded._model_hash() != plain._model_hash() + 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") - (conserved,) = model.conservative_vars("q") + model = Model("guarded_scalar", frame=FRAME) with pytest.raises(ValueError, match=r"primitive_vars\(\.\.\.\) first"): - model.recovery_admissibility(q=conserved > 0) + model.recovery_admissibility(q=1) - (primitive,) = model.primitive_vars(q=conserved) + 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"): From 2c861e107cb32fbbfef9d66c8f557cb75a3cdcdf Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:11:21 +0200 Subject: [PATCH 65/93] docs(recovery): use the public model surface --- docs/ALGORITHMS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index db783cf38..46484d939 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -412,7 +412,7 @@ For a Python-authored model, finite primitive recovery can be strengthened with constraints after declaring the primitive layout: ```python -rho, u, v, p = model.primitive_vars(rho=rho, u=u, v=v, p=pressure) +# after model.primitive_state(rho, u, v, p, conservative=(...)) model.recovery_admissibility(rho=rho > 0, p=p > 0) ``` From 55c6513e49119eade604940c08a1e81dab7e06ee Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:11:28 +0200 Subject: [PATCH 66/93] feat(reconstruction): add prepared MC and Superbee providers (ADC-751) --- include/pops/numerics/fv/reconstruction.hpp | 49 +++++++++++++++++++ .../spatial/embedded_boundary/operator.hpp | 2 +- include/pops/runtime/amr_system.hpp | 5 +- .../runtime/builders/block/block_builder.hpp | 3 ++ .../runtime/builders/compiled/dsl_block.hpp | 3 +- .../pops/runtime/builders/scheme_dispatch.hpp | 6 ++- .../config/generated_component_abi.hpp | 2 +- .../config/generated_component_catalog.hpp | 14 ++++-- .../config/generated_route_accessors.inc | 2 +- include/pops/runtime/module_capabilities.hpp | 18 +++---- .../program/external_riemann_brick.hpp | 2 +- include/pops/runtime/system.hpp | 11 +++-- .../init/generated_component_invokers.inc | 2 +- python/pops/_capabilities_report.py | 18 ++----- .../pops/_generated_component_interfaces.py | 4 +- .../pops/model/_generated_component_schema.py | 4 +- .../pops/numerics/reconstruction/__init__.py | 10 ++-- .../pops/numerics/reconstruction/limiters.py | 16 +++++- python/pops/runtime/_amr_system.py | 3 +- python/pops/runtime/_bricks_scheme.py | 8 +-- .../runtime/_generated_component_routes.py | 16 +++--- python/pops/runtime/amr/_view.py | 2 +- python/pops/runtime/doctor.py | 7 +-- python/pops/runtime/routes.py | 2 + schemas/component_catalog.v2.json | 28 +++++++++++ 25 files changed, 173 insertions(+), 64 deletions(-) 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/spatial/embedded_boundary/operator.hpp b/include/pops/numerics/spatial/embedded_boundary/operator.hpp index 49f91c994..873211b8e 100644 --- a/include/pops/numerics/spatial/embedded_boundary/operator.hpp +++ b/include/pops/numerics/spatial/embedded_boundary/operator.hpp @@ -424,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/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 81e800e90..b265d8ece 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -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" diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index 915721b15..ad0016a83 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -789,6 +789,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); } diff --git a/include/pops/runtime/builders/compiled/dsl_block.hpp b/include/pops/runtime/builders/compiled/dsl_block.hpp index 7214fdfa1..5782b90e3 100644 --- a/include/pops/runtime/builders/compiled/dsl_block.hpp +++ b/include/pops/runtime/builders/compiled/dsl_block.hpp @@ -97,7 +97,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/generated_component_abi.hpp b/include/pops/runtime/config/generated_component_abi.hpp index 2403ce736..65895f7fa 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 "84c68fcee96663f71e7e7fa7589ec1ddee0d1037e741a678e4afd83c9749620a" +#define POPS_COMPONENT_CATALOG_SHA256_V1 "8e26814c1bd3dadb62ad91511debdeee75c1fb31a1514e854237d901f88288fe" #define POPS_COMPONENT_PROTOCOL_ABI_V1 1u #define POPS_COMPONENT_COMMON_ABI_V1 1u diff --git a/include/pops/runtime/config/generated_component_catalog.hpp b/include/pops/runtime/config/generated_component_catalog.hpp index 2c4cb1c08..a3d892802 100644 --- a/include/pops/runtime/config/generated_component_catalog.hpp +++ b/include/pops/runtime/config/generated_component_catalog.hpp @@ -102,14 +102,18 @@ enum class LimiterRouteId : int { 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 +245,8 @@ inline constexpr LimiterTag kLimiters[] = { {"minmod", 2}, {"vanleer", 2}, {"weno5", 3}, + {"mc", 2}, + {"superbee", 2}, }; struct RiemannTag { @@ -301,9 +307,9 @@ inline constexpr int kComponentCatalogSchemaVersion = 1; inline constexpr int kComponentManifestSchemaVersion = 2; inline constexpr int kRouteRegistryVersion = 2; inline constexpr int kCapabilityVocabularyVersion = 2; -inline constexpr const char* kComponentCatalogSha256 = "84c68fcee96663f71e7e7fa7589ec1ddee0d1037e741a678e4afd83c9749620a"; -inline constexpr const char* kComponentCatalogSemanticSha256 = "c0e14d4a3dd082612d052b2ce293e17de712c6916e39f430d0ff311ed0f24ef8"; -inline constexpr const char* kRouteRegistrySignature = "v2:c0e14d4a3dd082612d052b2ce293e17de712c6916e39f430d0ff311ed0f24ef8"; +inline constexpr const char* kComponentCatalogSha256 = "8e26814c1bd3dadb62ad91511debdeee75c1fb31a1514e854237d901f88288fe"; +inline constexpr const char* kComponentCatalogSemanticSha256 = "c26ee448d6689af86d464b04669bbb21fd70c8f4eab9155d5aa1d3535d41d52e"; +inline constexpr const char* kRouteRegistrySignature = "v2:c26ee448d6689af86d464b04669bbb21fd70c8f4eab9155d5aa1d3535d41d52e"; inline constexpr const char* kComponentManifestSemanticFields[] = { "schema_version", "uri", diff --git a/include/pops/runtime/config/generated_route_accessors.inc b/include/pops/runtime/config/generated_route_accessors.inc index 886506256..06e738d41 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 84c68fcee96663f71e7e7fa7589ec1ddee0d1037e741a678e4afd83c9749620a; DO NOT EDIT. +// Generated from component catalog 8e26814c1bd3dadb62ad91511debdeee75c1fb31a1514e854237d901f88288fe; 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/module_capabilities.hpp b/include/pops/runtime/module_capabilities.hpp index 2cbf1a89c..394578c59 100644 --- a/include/pops/runtime/module_capabilities.hpp +++ b/include/pops/runtime/module_capabilities.hpp @@ -236,21 +236,19 @@ inline std::vector native_capability_routes( 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), 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/system.hpp b/include/pops/runtime/system.hpp index 15e5e300d..0b5b57deb 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -187,7 +187,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) @@ -268,8 +269,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 @@ -392,7 +394,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); /// @} diff --git a/python/bindings/core/init/generated_component_invokers.inc b/python/bindings/core/init/generated_component_invokers.inc index cc1600dc8..ed281ee33 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 84c68fcee96663f71e7e7fa7589ec1ddee0d1037e741a678e4afd83c9749620a; DO NOT EDIT. +// Generated by scripts/generate_component_catalog.py from catalog 8e26814c1bd3dadb62ad91511debdeee75c1fb31a1514e854237d901f88288fe; DO NOT EDIT. // This file is the sole Python/native request marshaller. init_component_loader.cpp only registers it. #include diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index c7a630581..bb88f1766 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -767,7 +767,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( @@ -783,23 +783,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 66d02552c..1e0f4abbb 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 = '84c68fcee96663f71e7e7fa7589ec1ddee0d1037e741a678e4afd83c9749620a' -NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = 'c0e14d4a3dd082612d052b2ce293e17de712c6916e39f430d0ff311ed0f24ef8' +NATIVE_COMPONENT_CATALOG_SHA256 = '8e26814c1bd3dadb62ad91511debdeee75c1fb31a1514e854237d901f88288fe' +NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = 'c26ee448d6689af86d464b04669bbb21fd70c8f4eab9155d5aa1d3535d41d52e' NATIVE_TAGGING_PROGRAM_ABI = {'version': 1, 'execution_modes': {'native_backend': 1, 'host': 2}, 'collective_scopes': {'none': 0}, diff --git a/python/pops/model/_generated_component_schema.py b/python/pops/model/_generated_component_schema.py index 84612fb23..1405e6a1b 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 = '84c68fcee96663f71e7e7fa7589ec1ddee0d1037e741a678e4afd83c9749620a' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'c0e14d4a3dd082612d052b2ce293e17de712c6916e39f430d0ff311ed0f24ef8' +COMPONENT_CATALOG_SHA256 = '8e26814c1bd3dadb62ad91511debdeee75c1fb31a1514e854237d901f88288fe' +COMPONENT_CATALOG_SEMANTIC_SHA256 = 'c26ee448d6689af86d464b04669bbb21fd70c8f4eab9155d5aa1d3535d41d52e' 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/numerics/reconstruction/__init__.py b/python/pops/numerics/reconstruction/__init__.py index 9f2c881d1..62f68b02e 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") ) @@ -156,7 +158,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 +208,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/runtime/_amr_system.py b/python/pops/runtime/_amr_system.py index 1e4c3319f..16416f356 100644 --- a/python/pops/runtime/_amr_system.py +++ b/python/pops/runtime/_amr_system.py @@ -312,7 +312,8 @@ def add_block(self, name: Any, model: Any, spatial: Any = None, time: Any = None @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 + none / minmod / vanleer / weno5 / mc / superbee, 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 diff --git a/python/pops/runtime/_bricks_scheme.py b/python/pops/runtime/_bricks_scheme.py index 7ee99202b..2ceb33140 100644 --- a/python/pops/runtime/_bricks_scheme.py +++ b/python/pops/runtime/_bricks_scheme.py @@ -65,7 +65,8 @@ def __init__(self, a: Any, b: Any, rate: Any) -> None: _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()" _RECON_SUGGEST = "pops.numerics.variables.Conservative() / Primitive()" @@ -134,9 +135,10 @@ 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). diff --git a/python/pops/runtime/_generated_component_routes.py b/python/pops/runtime/_generated_component_routes.py index 41b38a9e8..d7cbc9094 100644 --- a/python/pops/runtime/_generated_component_routes.py +++ b/python/pops/runtime/_generated_component_routes.py @@ -9,11 +9,11 @@ CAPABILITY_VOCAB_VERSION = 2 -COMPONENT_CATALOG_SHA256 = '84c68fcee96663f71e7e7fa7589ec1ddee0d1037e741a678e4afd83c9749620a' +COMPONENT_CATALOG_SHA256 = '8e26814c1bd3dadb62ad91511debdeee75c1fb31a1514e854237d901f88288fe' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'c0e14d4a3dd082612d052b2ce293e17de712c6916e39f430d0ff311ed0f24ef8' +COMPONENT_CATALOG_SEMANTIC_SHA256 = 'c26ee448d6689af86d464b04669bbb21fd70c8f4eab9155d5aa1d3535d41d52e' -ROUTE_REGISTRY_SIGNATURE = 'v2:c0e14d4a3dd082612d052b2ce293e17de712c6916e39f430d0ff311ed0f24ef8' +ROUTE_REGISTRY_SIGNATURE = 'v2:c26ee448d6689af86d464b04669bbb21fd70c8f4eab9155d5aa1d3535d41d52e' ROUTE_TABLES = {'riemann': (('rusanov', 'pops::RusanovFlux', @@ -40,7 +40,9 @@ '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)', @@ -125,7 +127,9 @@ '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': {}}, @@ -168,7 +172,7 @@ 'ids': ('kRusanov', 'kHll', 'kHllc', 'kRoe')}, '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/amr/_view.py b/python/pops/runtime/amr/_view.py index a8b0f8605..79ed6f2af 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."], diff --git a/python/pops/runtime/doctor.py b/python/pops/runtime/doctor.py index 47af90394..b6bb746bf 100644 --- a/python/pops/runtime/doctor.py +++ b/python/pops/runtime/doctor.py @@ -17,7 +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") +_LIMITER_ORDER = ("none", "minmod", "vanleer", "weno5", "mc", "superbee") # 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 @@ -40,8 +40,9 @@ 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 diff --git a/python/pops/runtime/routes.py b/python/pops/runtime/routes.py index aadad5e60..e02a0eeec 100644 --- a/python/pops/runtime/routes.py +++ b/python/pops/runtime/routes.py @@ -276,6 +276,8 @@ def route_registry_hash() -> str: 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"] diff --git a/schemas/component_catalog.v2.json b/schemas/component_catalog.v2.json index c48a03d1b..69fd55b26 100644 --- a/schemas/component_catalog.v2.json +++ b/schemas/component_catalog.v2.json @@ -575,6 +575,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 + } } ] }, From 4ddf094b889a22fb9b5863d03f2ca6f0f975d408 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:11:38 +0200 Subject: [PATCH 67/93] test(reconstruction): fence MC and Superbee semantics (ADC-751) --- .../integration/runtime/test_route_ids.cpp | 8 +-- tests/cpp/unit/mesh/test_dispatch_tags.cpp | 8 +++ .../unit/numerics/test_weno_convergence.cpp | 57 +++++++++++++++++++ .../cpp/unit/runtime/test_scheme_dispatch.cpp | 6 +- .../test_route_registry_parity.py | 26 +++++++++ .../amr/test_amr_weno5_hllc_roe.py | 2 +- .../unit/codegen/test_fail_closed_reports.py | 9 +++ .../descriptors/test_descriptor_protocol.py | 7 ++- .../test_external_reconstruction_contract.py | 6 +- .../runtime/test_destring_finite_volume.py | 18 +++++- .../unit/time/test_program_solve_final.py | 4 +- 11 files changed, 137 insertions(+), 14 deletions(-) diff --git a/tests/cpp/integration/runtime/test_route_ids.cpp b/tests/cpp/integration/runtime/test_route_ids.cpp index 46b4f1ef2..ff283eada 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"); }); diff --git a/tests/cpp/unit/mesh/test_dispatch_tags.cpp b/tests/cpp/unit/mesh/test_dispatch_tags.cpp index b8397fdac..a46dc1724 100644 --- a/tests/cpp/unit/mesh/test_dispatch_tags.cpp +++ b/tests/cpp/unit/mesh/test_dispatch_tags.cpp @@ -100,11 +100,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,6 +117,10 @@ 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 && diff --git a/tests/cpp/unit/numerics/test_weno_convergence.cpp b/tests/cpp/unit/numerics/test_weno_convergence.cpp index d10b0d853..ed882b66f 100644 --- a/tests/cpp/unit/numerics/test_weno_convergence.cpp +++ b/tests/cpp/unit/numerics/test_weno_convergence.cpp @@ -8,8 +8,10 @@ #include #include +#include #include #include +#include using namespace pops; @@ -79,6 +81,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 +110,57 @@ 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_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/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/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/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/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 199eb3756..161721126 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -68,6 +68,15 @@ 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 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/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/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/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") From 14e426fd588c218d8f9fc692f9d22376aac6f4e4 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:11:47 +0200 Subject: [PATCH 68/93] docs(reconstruction): document executable MC and Superbee (ADC-751) --- docs/ALGORITHMS.md | 11 ++++++++--- docs/design/native-capability-matrix.md | 4 +++- docs/tuto/scalar_advection/README.md | 10 ++++++---- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index 1b84381c7..cfa5135cc 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -384,8 +384,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 @@ -401,6 +401,10 @@ before any face flux; the value-only wrappers remain low-level compatibility hel 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. @@ -411,7 +415,8 @@ step stays bounded by the CFL of section 1, `dt <= C dx / max|lambda|`. Limits a **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). --- diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index 7c4c6fab6..74b3b6a2b 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -149,10 +149,12 @@ Supported native routes include: multi-block/multi-level accepted state under active regridding, including topology ownership, clocks, the held cadence window, the last accepted Program interval, histories and transfer provenance. +- 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 accepted-state v5. diff --git a/docs/tuto/scalar_advection/README.md b/docs/tuto/scalar_advection/README.md index 19951b227..981f4f3b8 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 From b5cb3fc4e1c8870d4f348a249b46132f259f86da Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:32:32 +0200 Subject: [PATCH 69/93] gate(numerics): select limiter and admissibility proofs --- scripts/run_adc757_prepared_numerics_gate.py | 2 ++ .../numerics/test_variable_recovery_chain.cpp | 4 ++++ tests/gates/adc757_prepared_numerics.toml | 24 +++++++++++++++++++ .../test_adc757_prepared_numerics_gate.py | 2 +- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 979ae96c9..ff8560f83 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -25,6 +25,8 @@ "mpi_collective_execution", "typed_flux_recovery_consumption", "runtime_recovery_consumer_publication", + "model_declared_admissibility", + "prepared_limiter_provider", } EXPECTED_DEFERRED = ( "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", diff --git a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp index c70192b78..a6ba8e5a5 100644 --- a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp +++ b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp @@ -286,7 +286,11 @@ TEST(PreparedVariableRecovery, model_declared_admissibility_blocks_publication) 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); diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 4d2ad3f4e..78554d98e 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -51,6 +51,18 @@ 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" @@ -109,6 +121,18 @@ 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 = "refusal" +target = "test_dispatch_tags" +test_regex = "^test_dispatch_tags\\.validate_limiter_accepts_and_rejects$" + [[check]] requirement = "typed_flux_recovery_consumption" polarity = "positive" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 4bd78d9c4..d3b92cda2 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 19 + assert len(data["check"]) == 23 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", From c8c1c029eee79884c39c682a271a920561f0fed8 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:33:39 +0200 Subject: [PATCH 70/93] feat(time): persist cell temporal partition authority --- include/pops/runtime/amr_system.hpp | 3 + .../program/amr_program_checkpoint.hpp | 45 +++- .../runtime/program/amr_program_context.hpp | 25 +- .../program/cell_temporal_partition.hpp | 225 ++++++++++++++++++ python/bindings/core/init/init_amr.cpp | 1 + .../pops/runtime/_amr_checkpoint_contract.py | 31 ++- python/pops/runtime/_amr_checkpoint_v3.py | 24 +- python/pops/runtime/program_report.py | 159 +++++++++---- src/runtime/amr/amr_system.cpp | 16 ++ 9 files changed, 464 insertions(+), 65 deletions(-) create mode 100644 include/pops/runtime/program/cell_temporal_partition.hpp diff --git a/include/pops/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index 71c70314a..0b483efb0 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -852,6 +852,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; diff --git a/include/pops/runtime/program/amr_program_checkpoint.hpp b/include/pops/runtime/program/amr_program_checkpoint.hpp index c0f5ebbc1..608400295 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,9 @@ 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(); + const bool carries_temporal_partition = magic == 0x3554534153504f50ULL; + if (!carries_temporal_partition && magic != 0x3454534153504f50ULL) throw std::runtime_error( "invalid AMR Program accepted-state payload: unsupported magic/version"); AmrProgramAcceptedState state; @@ -529,6 +546,30 @@ inline AmrProgramAcceptedState deserialize_amr_program_accepted_state( clock = read_clock(in); state.logical_clock_ticks = read_map(in, [](Reader& r) { return r.i64(); }); + if (carries_temporal_partition) { + 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..0c3bcd222 100644 --- a/include/pops/runtime/program/amr_program_context.hpp +++ b/include/pops/runtime/program/amr_program_context.hpp @@ -1278,6 +1278,10 @@ class AmrProgramContext : public ProgramExecutionServices { AttemptSnapshot& saved = attempt_snapshot_; capture_engine_attempt_snapshot_(saved, borrows_facade_snapshot); import_program_accepted_state_(); + // ADC-756 foundation: an authenticated cell-local checkpoint must never fall through to the + // existing hierarchy-global driver. The future prepared Kokkos partition executor will consume + // this same authority; until then, fail before the Program body or any published clock mutates. + temporal_partition_.require_global_execution_route(); capture_program_attempt_snapshot_(saved); conservative_ledger_.begin(); try { @@ -1382,6 +1386,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"); @@ -1507,6 +1521,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 +1576,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); + temporal_partition_.restore(std::move(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); @@ -1705,6 +1721,7 @@ 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; std::set active_flux; std::map flux; std::map> flux_contributions; @@ -1928,6 +1945,7 @@ 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(); copy_set_in_place_(snapshot.active_flux, active_flux_ledger_); for (auto entry = snapshot.flux.begin(); entry != snapshot.flux.end();) { @@ -2047,6 +2065,8 @@ class AmrProgramContext : public ProgramExecutionServices { current_window_ = snapshot.window; current_sync_clock_ = snapshot.sync_clock; current_level_dt_ = snapshot.level_dt; + temporal_partition_.rollback(); + temporal_partition_.restore(snapshot.temporal_partition); } template @@ -3275,8 +3295,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, @@ -3534,6 +3554,7 @@ class AmrProgramContext : public ProgramExecutionServices { mutable bool restart_regrid_prepared_ = false; mutable int automatic_regrid_macro_step_ = -1; mutable std::vector level_clocks_; + mutable BatchedCellTemporalPartition temporal_partition_; 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..003c67540 --- /dev/null +++ b/include/pops/runtime/program/cell_temporal_partition.hpp @@ -0,0 +1,225 @@ +#pragma once + +#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; + } +} + +/// 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_); + } + + 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); + accepted_ = std::move(accepted); + } + + void require_global_execution_route() const { + if (accepted_.kind != TemporalPartitionKind::Global) + throw std::logic_error( + "cell-local temporal partition requires its prepared batched executor; the global AMR " + "step cannot silently replace it"); + } + + 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/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index 324affaa5..25fab07ab 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -824,6 +824,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) 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/program_report.py b/python/pops/runtime/program_report.py index 6194a5727..28fa57b14 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,6 +142,7 @@ 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 []) @@ -135,36 +157,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 +203,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 +273,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 +281,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 +294,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/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index 683a7b0d7..a79594bce 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -3328,6 +3328,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 +3444,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()) From ca4862e68640d914e01a8ded125cb1f8db2b5df8 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:33:50 +0200 Subject: [PATCH 71/93] test(time): cover temporal partition restart and no-bypass --- tests/CMakeLists.txt | 3 + tests/cpp/build_durations.json | 6 +- .../amr/test_temporal_partition_restart.cpp | 155 ++++++++++++++++++ tests/cpp/test_durations.json | 6 +- tests/cpp/test_sources.cmake | 1 + .../runtime/test_amr_checkpoint_contract.py | 68 ++++++-- .../unit/runtime/test_program_report.py | 57 +++++-- tests/test_manifest.toml | 5 + 8 files changed, 272 insertions(+), 29 deletions(-) create mode 100644 tests/cpp/integration/amr/test_temporal_partition_restart.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c28cb399d..b11d2f92c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -692,6 +692,9 @@ 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_amr_transfer_properties) pops_add_gtest_suite(NAME test_amr_transfer_properties SOURCES "${_src}" EXTRA_LIBS ${CMAKE_DL_LIBS} pops_runtime_amr) diff --git a/tests/cpp/build_durations.json b/tests/cpp/build_durations.json index 63f1b081b..7231d4a15 100644 --- a/tests/cpp/build_durations.json +++ b/tests/cpp/build_durations.json @@ -6,7 +6,8 @@ "test_amr_program_diffusion", "test_amr_program_positivity_floor", "test_flux_failure_loader_transaction", - "test_interface_flux_fragment_ledger" + "test_interface_flux_fragment_ledger", + "test_temporal_partition_restart" ], "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 +17,7 @@ "refresh_source_run": "30190778708", "source_job": "87152034744", "source_run": "29352485297", - "target_count": 187, + "target_count": 188, "unit_seconds": "modeled shard wall time: measured serial-pool TU or parallel-share floor" }, "test_adaptive_multirate": 2.0, @@ -191,6 +192,7 @@ "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, 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..841b834ee --- /dev/null +++ b/tests/cpp/integration/amr/test_temporal_partition_restart.cpp @@ -0,0 +1,155 @@ +#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; +} + +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_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, + 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); + + AmrProgramAcceptedState accepted = + deserialize_amr_program_accepted_state(system.program_accepted_state()); + accepted.temporal_partition = 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(); + EXPECT_THROW(system.step(0.01), std::logic_error) + << "an authenticated cell-local schedule cannot degrade to the global AMR driver"; + 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"; +} diff --git a/tests/cpp/test_durations.json b/tests/cpp/test_durations.json index 855ccd973..29032f556 100644 --- a/tests/cpp/test_durations.json +++ b/tests/cpp/test_durations.json @@ -6,7 +6,8 @@ "test_amr_program_diffusion", "test_amr_program_positivity_floor", "test_flux_failure_loader_transaction", - "test_interface_flux_fragment_ledger" + "test_interface_flux_fragment_ledger", + "test_temporal_partition_restart" ], "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 +17,7 @@ "refresh_source_run": "30190778708", "source_job": "87152034744", "source_run": "29352485297", - "target_count": 187, + "target_count": 188, "unit_seconds": "aggregate CTest wall time per build target" }, "test_adaptive_multirate": 0.02, @@ -191,6 +192,7 @@ "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, diff --git a/tests/cpp/test_sources.cmake b/tests/cpp/test_sources.cmake index 8dee480e5..76106bb50 100644 --- a/tests/cpp/test_sources.cmake +++ b/tests/cpp/test_sources.cmake @@ -55,6 +55,7 @@ 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_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") 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_program_report.py b/tests/python/unit/runtime/test_program_report.py index 005d933eb..1d948b959 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 @@ -82,8 +83,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 +115,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 +131,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 +176,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) diff --git a/tests/test_manifest.toml b/tests/test_manifest.toml index 3b55de01e..eddf28dd6 100644 --- a/tests/test_manifest.toml +++ b/tests/test_manifest.toml @@ -1134,6 +1134,11 @@ 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_residual_operator" sources = ["tests/cpp/unit/runtime/test_residual_operator.cpp"] From 653f582442453b44f0adde09689398bd4821c372 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:33:54 +0200 Subject: [PATCH 72/93] docs(time): bound the ADC-756 restart foundation --- docs/design/temporal-execution-contract.md | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/design/temporal-execution-contract.md b/docs/design/temporal-execution-contract.md index 6e25499c8..ac83e90c9 100644 --- a/docs/design/temporal-execution-contract.md +++ b/docs/design/temporal-execution-contract.md @@ -61,6 +61,34 @@ 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. + +This foundation deliberately does not pretend that the existing global AMR driver is cell-local +stepping. If a cell-local image reaches that driver, execution fails before the Program body instead +of silently falling back to a global `dt`. ADC-707/ADC-708 still own the prepared patch/task graph; +ADC-756 still requires Kokkos rung batches, actual local-stage boundary evaluation, time-integrated +same-level/MPI/coarse-fine flux ledgers, regrid/rank-change rematerialization, device determinism and +performance evidence. None of those execution or conservation claims is made by this restart slice. + 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 From caf37d3229806c7b308cf6cac78f628caf08c95b Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:40:23 +0200 Subject: [PATCH 73/93] gate(numerics): select temporal partition authority --- scripts/run_adc757_prepared_numerics_gate.py | 11 ++++++++++- tests/gates/adc757_prepared_numerics.toml | 14 +++++++++++++- .../test_adc757_prepared_numerics_gate.py | 3 ++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index ff8560f83..11269c0c9 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -27,6 +27,7 @@ "runtime_recovery_consumer_publication", "model_declared_admissibility", "prepared_limiter_provider", + "cell_local_temporal_partition_authority", } EXPECTED_DEFERRED = ( "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", @@ -85,7 +86,15 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: 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-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755"] + expected_evidence = [ + "ADC-749", + "ADC-750", + "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): diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 78554d98e..d93a74e1a 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -1,7 +1,7 @@ schema_version = 1 gate = "adc757-prepared-numerics-slice" issue = "ADC-757" -evidence_from = ["ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755"] +evidence_from = ["ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] deferred = [ "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "python_ir_generated_abi_and_restart_parity", @@ -156,3 +156,15 @@ requirement = "runtime_recovery_consumer_publication" polarity = "refusal" target = "test_facade_routing" test_regex = "^FacadeRouting\\.PrimitiveMaterializationFailsClosedWithoutMutatingAcceptedState$" + +[[check]] +requirement = "cell_local_temporal_partition_authority" +polarity = "positive" +target = "test_temporal_partition_restart" +test_regex = "^test_temporal_partition_restart\\.batched_attempt_commit_and_rollback_are_exact$" + +[[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$" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index d3b92cda2..1362024c3 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 23 + assert len(data["check"]) == 25 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", @@ -35,6 +35,7 @@ def test_adc757_slice_references_exact_real_mandatory_native_proofs(): "ADC-753", "ADC-754", "ADC-755", + "ADC-756", ] assert runner.main(["--check-only"]) == 0 From 07608fbb0a2d1a21d5ebf9deb624c76dcd576563 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 00:58:59 +0200 Subject: [PATCH 74/93] fix(ci): classify new P2 validation files --- include/pops_headers.manifest | 1 + tests/python/test_durations.json | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/include/pops_headers.manifest b/include/pops_headers.manifest index d276ed182..ee03efb4e 100644 --- a/include/pops_headers.manifest +++ b/include/pops_headers.manifest @@ -213,6 +213,7 @@ 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/clock_schedule.hpp sdk-root pops/runtime/program/coeff_elliptic_ops.hpp sdk-root pops/runtime/program/component_package.hpp diff --git a/tests/python/test_durations.json b/tests/python/test_durations.json index 0b911cef1..49870c31d 100644 --- a/tests/python/test_durations.json +++ b/tests/python/test_durations.json @@ -174,6 +174,7 @@ "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, @@ -410,7 +411,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": 225, + "estimated_count": 226, "estimated_files": [ "tests/python/examples/final/test_hyqmom15_final_example.py", "tests/python/examples/final/test_scalar_advection_final_example.py", @@ -516,6 +517,7 @@ "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", @@ -638,6 +640,6 @@ "tests/python/unit/time/test_typed_provenance_guards.py", "tests/python/unit/time/test_typed_schedule.py" ], - "total_files": 406 + "total_files": 407 } } From 17b62804ef51dfdf93c8d57e066ff105b627e04c Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 01:06:43 +0200 Subject: [PATCH 75/93] gate(numerics): execute Python ABI and restart parity --- scripts/run_adc757_prepared_numerics_gate.py | 112 ++++++++++++++++-- tests/gates/adc757_prepared_numerics.toml | 31 ++++- .../test_adc757_prepared_numerics_gate.py | 101 +++++++++++++++- 3 files changed, 233 insertions(+), 11 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 11269c0c9..b01751728 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import ast from collections import Counter, defaultdict from pathlib import Path import re @@ -28,10 +29,10 @@ "model_declared_admissibility", "prepared_limiter_provider", "cell_local_temporal_partition_authority", + "python_ir_generated_abi_and_restart_parity", } EXPECTED_DEFERRED = ( "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", - "python_ir_generated_abi_and_restart_parity", "remaining_legacy_recovery_and_boundary_authority_deletion", "amr_regrid_migration_and_restart_coherence", "gpu_backend_execution", @@ -47,6 +48,24 @@ def _cpp_suites() -> dict[str, dict]: 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[set[str], list[str]]: names: set[str] = set() errors: list[str] = [] @@ -62,6 +81,39 @@ def _declared_gtests(suite: dict) -> tuple[set[str], list[str]]: return names, 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_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: """Return the manifest and deterministic source-only validation errors.""" try: @@ -80,8 +132,8 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: } if set(data) != expected_fields: errors.append("manifest fields must be exactly %s" % sorted(expected_fields)) - if data.get("schema_version") != 1: - errors.append("schema_version must be exactly 1") + if data.get("schema_version") != 2: + errors.append("schema_version must be exactly 2") if data.get("gate") != "adc757-prepared-numerics-slice": errors.append("gate must be exactly 'adc757-prepared-numerics-slice'") if data.get("issue") != "ADC-757": @@ -105,13 +157,17 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: errors.append("manifest must contain [[check]] rows") checks = [] suites = _cpp_suites() + python_files = _python_files() coverage: dict[str, set[str]] = defaultdict(set) identities = Counter() mpi_checks = 0 for index, row in enumerate(checks, 1): where = "check[%d]" % index kind = row.get("kind", "ctest") - expected_row_fields = {"requirement", "polarity", "target", "test_regex"} + 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: @@ -127,7 +183,26 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: errors.append("%s polarity must be positive or refusal" % where) else: coverage[str(requirement)].add(str(polarity)) - identity = (target, selector) + 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) @@ -216,6 +291,18 @@ def _run_ctest(build_dir: Path, target: str, selector: str) -> None: subprocess.run(command, cwd=ROOT, check=True) +def _run_pytest(relative: str, test_name: str) -> None: + command = [ + sys.executable, + "-m", + "pytest", + "-q", + "%s::%s" % (relative, test_name), + ] + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, check=True) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) @@ -247,8 +334,19 @@ def main(argv: list[str] | None = None) -> int: return 3 if args.check_only: return 0 - for row in sorted(data["check"], key=lambda value: (value["target"], value["test_regex"])): - _run_ctest(args.build_dir, row["target"], row["test_regex"]) + 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 diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index d93a74e1a..9f3f0ed04 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -1,10 +1,9 @@ -schema_version = 1 +schema_version = 2 gate = "adc757-prepared-numerics-slice" issue = "ADC-757" evidence_from = ["ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] deferred = [ "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", - "python_ir_generated_abi_and_restart_parity", "remaining_legacy_recovery_and_boundary_authority_deletion", "amr_regrid_migration_and_restart_coherence", "gpu_backend_execution", @@ -168,3 +167,31 @@ 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 = "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" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 1362024c3..5b0840c3f 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 25 + assert len(data["check"]) == 29 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", @@ -71,10 +71,50 @@ def test_adc757_slice_claims_only_the_exact_delivered_mpi_collective_proof(): "nproc": 2, }, ] - assert all("gpu" not in row["target"].lower() for row in data["check"]) + assert all( + "gpu" not in row.get("target", row.get("path", "")).lower() + for row in data["check"] + ) assert runner.main(["--check-only", "--closure"]) == 3 +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"] == [ + { + "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_manifest_refuses_missing_polarity_and_unknown_target(tmp_path): runner = _load_runner() source = MANIFEST.read_text(encoding="utf-8") @@ -107,6 +147,35 @@ def test_adc757_manifest_refuses_missing_polarity_and_unknown_target(tmp_path): _, 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) + def test_adc757_runner_refuses_a_declared_but_unbuilt_proof(monkeypatch, tmp_path): runner = _load_runner() @@ -122,3 +191,31 @@ def empty_ctest_listing(command, **kwargs): "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): + 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", + ) + assert calls == [ + ( + [ + runner.sys.executable, + "-m", + "pytest", + "-q", + "tests/python/unit/codegen/test_recovery_admissibility_codegen.py::" + "test_recovery_admissibility_is_emitted_and_hashed", + ], + {"cwd": runner.ROOT, "check": True}, + ) + ] From 4fe72ac1f68ea7fc7abec933e76801816cf0c4d2 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 01:20:11 +0200 Subject: [PATCH 76/93] test(numerics): qualify MC and Superbee reconstruction --- .../unit/numerics/test_weno_convergence.cpp | 152 ++++++++++++++++++ tests/gates/adc757_prepared_numerics.toml | 12 ++ .../test_adc757_prepared_numerics_gate.py | 2 +- 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/tests/cpp/unit/numerics/test_weno_convergence.cpp b/tests/cpp/unit/numerics/test_weno_convergence.cpp index ed882b66f..cb4a7d899 100644 --- a/tests/cpp/unit/numerics/test_weno_convergence.cpp +++ b/tests/cpp/unit/numerics/test_weno_convergence.cpp @@ -12,6 +12,7 @@ #include #include #include +#include using namespace pops; @@ -55,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 { @@ -161,6 +234,85 @@ TEST(test_muscl_limiters, sweby_tvd_bounds_and_finite_extremes) { 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/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 9f3f0ed04..02923861b 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -126,6 +126,18 @@ 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" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 5b0840c3f..b85fcb4ad 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 29 + assert len(data["check"]) == 31 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", From 7cf70d0319f69303298949be650c37753b88fabf Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 01:22:40 +0200 Subject: [PATCH 77/93] gate(numerics): prove host workspace reentrancy --- scripts/run_adc757_prepared_numerics_gate.py | 32 ++++++++++---- tests/gates/adc757_prepared_numerics.toml | 14 ++++++- .../test_adc757_prepared_numerics_gate.py | 42 ++++++++++++++++++- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index b01751728..ea0614225 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -30,13 +30,14 @@ "prepared_limiter_provider", "cell_local_temporal_partition_authority", "python_ir_generated_abi_and_restart_parity", + "host_workspace_reentrancy", } EXPECTED_DEFERRED = ( "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "remaining_legacy_recovery_and_boundary_authority_deletion", "amr_regrid_migration_and_restart_coherence", "gpu_backend_execution", - "workspace_reentrancy_and_stream_partitioning", + "accelerator_stream_partitioning", "performance_baselines_and_end_to_end_benchmarks", "local_time_and_load_balance_provider_families", ) @@ -66,8 +67,8 @@ def _python_files() -> set[str]: return files -def _declared_gtests(suite: dict) -> tuple[set[str], list[str]]: - names: set[str] = set() +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 @@ -75,10 +76,21 @@ def _declared_gtests(suite: dict) -> tuple[set[str], list[str]]: errors.append("missing source %s" % relative) continue text = source.read_text(encoding="utf-8") - if "GTEST_SKIP" in text or "DISABLED_" in text: - errors.append("%s contains a skip/disabled marker" % relative) - names.update("%s.%s" % match.groups() for match in GTEST_PATTERN.finditer(text)) - return names, errors + 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]]: @@ -241,10 +253,10 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: continue if "mpi" in labels or "gpu" in labels: errors.append("%s ordinary CTest claims a deferred MPI/GPU target %r" % (where, target)) - names, source_errors = _declared_gtests(suites[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 names if re.fullmatch(selector, name)) + 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 @@ -252,6 +264,8 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: 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: diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 9f3f0ed04..d4ab4f613 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -7,7 +7,7 @@ deferred = [ "remaining_legacy_recovery_and_boundary_authority_deletion", "amr_regrid_migration_and_restart_coherence", "gpu_backend_execution", - "workspace_reentrancy_and_stream_partitioning", + "accelerator_stream_partitioning", "performance_baselines_and_end_to_end_benchmarks", "local_time_and_load_balance_provider_families", ] @@ -168,6 +168,18 @@ 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 = "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" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 5b0840c3f..7a47053bb 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 29 + assert len(data["check"]) == 31 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", @@ -47,6 +47,8 @@ def test_adc757_slice_claims_only_the_exact_delivered_mpi_collective_proof(): assert data["deferred"] == list(runner.EXPECTED_DEFERRED) assert "mpi_collective_execution" not in data["deferred"] assert "gpu_backend_execution" in data["deferred"] + assert "accelerator_stream_partitioning" in data["deferred"] + assert "workspace_reentrancy_and_stream_partitioning" not in data["deferred"] assert "remaining_legacy_recovery_and_boundary_authority_deletion" 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"] @@ -78,6 +80,30 @@ def test_adc757_slice_claims_only_the_exact_delivered_mpi_collective_proof(): assert runner.main(["--check-only", "--closure"]) == 3 +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) @@ -176,6 +202,20 @@ def test_adc757_manifest_refuses_missing_polarity_and_unknown_target(tmp_path): ).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) + def test_adc757_runner_refuses_a_declared_but_unbuilt_proof(monkeypatch, tmp_path): runner = _load_runner() From b196bcfb1a894385f6349c51f75384a54a2604d7 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 01:24:39 +0200 Subject: [PATCH 78/93] test(numerics): compose limiter and workspace gate proofs --- tests/python/architecture/test_adc757_prepared_numerics_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 7a47053bb..cdcff64d0 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 31 + assert len(data["check"]) == 33 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-749", From 761b1061246d4f8dc382bbfcbedd16d571ddac46 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 01:40:37 +0200 Subject: [PATCH 79/93] gate(numerics): execute qualified flux provider proofs --- scripts/run_adc757_prepared_numerics_gate.py | 2 ++ tests/gates/adc757_prepared_numerics.toml | 15 +++++++- .../test_adc757_prepared_numerics_gate.py | 34 +++++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index ea0614225..07d09eb61 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -22,6 +22,7 @@ "transactional_recovery_publication", "allocation_aware_cell_hot_path", "prepared_boundary_publication", + "qualified_flux_provider_pack", "capability_driven_riemann", "mpi_collective_execution", "typed_flux_recovery_consumption", @@ -151,6 +152,7 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: if data.get("issue") != "ADC-757": errors.append("issue must be exactly ADC-757") expected_evidence = [ + "ADC-682", "ADC-749", "ADC-750", "ADC-752", diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index d4ab4f613..f7fb5071f 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -1,7 +1,7 @@ schema_version = 2 gate = "adc757-prepared-numerics-slice" issue = "ADC-757" -evidence_from = ["ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] +evidence_from = ["ADC-682", "ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] deferred = [ "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "remaining_legacy_recovery_and_boundary_authority_deletion", @@ -102,6 +102,19 @@ 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" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 7a47053bb..0446a8ee8 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,9 +26,10 @@ 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"]) == 31 + assert len(data["check"]) == 33 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ + "ADC-682", "ADC-749", "ADC-750", "ADC-752", @@ -40,6 +41,30 @@ def test_adc757_slice_references_exact_real_mandatory_native_proofs(): assert runner.main(["--check-only"]) == 0 +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_claims_only_the_exact_delivered_mpi_collective_proof(): runner = _load_runner() data, errors = runner.validate_manifest(MANIFEST) @@ -108,7 +133,12 @@ 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"] == [ + 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", From 37e90fb3827d6ef87550953af0e5fc8c891823ec Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 01:59:37 +0200 Subject: [PATCH 80/93] gate(numerics): attest ADC-751 limiter proofs --- scripts/run_adc757_prepared_numerics_gate.py | 1 + tests/gates/adc757_prepared_numerics.toml | 2 +- .../python/architecture/test_adc757_prepared_numerics_gate.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 07d09eb61..3f98592de 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -155,6 +155,7 @@ def validate_manifest(path: Path = DEFAULT_MANIFEST) -> tuple[dict, list[str]]: "ADC-682", "ADC-749", "ADC-750", + "ADC-751", "ADC-752", "ADC-753", "ADC-754", diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 4c21a7f69..32cfb4e43 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -1,7 +1,7 @@ schema_version = 2 gate = "adc757-prepared-numerics-slice" issue = "ADC-757" -evidence_from = ["ADC-682", "ADC-749", "ADC-750", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] +evidence_from = ["ADC-682", "ADC-749", "ADC-750", "ADC-751", "ADC-752", "ADC-753", "ADC-754", "ADC-755", "ADC-756"] deferred = [ "remaining_3d_metric_eb_characteristic_and_spatial_provider_matrix", "remaining_legacy_recovery_and_boundary_authority_deletion", diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 0446a8ee8..60ef93cd3 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,12 +26,13 @@ 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"]) == 33 + assert len(data["check"]) == 35 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-682", "ADC-749", "ADC-750", + "ADC-751", "ADC-752", "ADC-753", "ADC-754", From b160e6aaa9f510325548c7b8c4a243a7c5ffca5a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 02:09:25 +0200 Subject: [PATCH 81/93] fix(boundary): reject inert transport descriptors at resolve --- CHANGELOG.md | 4 +++ python/pops/_capabilities_report.py | 7 ++--- python/pops/boundary/transport.py | 11 +++++++- .../unit/boundary/test_transport_authoring.py | 27 ++++++++----------- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 681ed3aa1..6f30df928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning 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. - 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 diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 9961f6239..6c124131f 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -389,7 +389,8 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: "(x,y,t,params) fixed state, model primitive-to-conservative fixed-state conversion, " "and typed-role slip wall; 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" + "double-physical corners explicitly not required by dimension-split FV stencils; " + "numerical resolution rejects every descriptor outside this executable envelope" ), source=source, ), @@ -402,8 +403,8 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: gpu=gpu, status="unavailable", limitation=( - "the prepared transport plan rejects characteristic closure until executable " - "model eigenstructure, incoming-mode data, and sonic/sign policies are installed" + "numerical resolution rejects characteristic closure until executable model " + "eigenstructure, incoming-mode data, and sonic/sign policies are installed" ), requested="characteristic no-inflow/outflow transport boundary", available_route="explicit fixed-state inflow or extrapolated outflow", diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index b16a92bb4..95c2a7c9b 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -618,6 +618,12 @@ def __post_init__(self) -> None: 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 { @@ -651,7 +657,10 @@ 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 states = {row.state for row in self.conditions} diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index ff54cd04b..1cf0ba309 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -179,15 +179,14 @@ def test_primitive_fixed_state_lowers_only_through_the_exact_block_model_convert converted_condition, provider=replace(converted_condition.provider, dependencies=forged_dependencies), ) - forged_authority = replace( - authority, - conditions=tuple( - forged_condition if row is converted_condition else row - for row in authority.conditions - ), - ) with pytest.raises(NotImplementedError, match="exact model_primitive_to_conservative"): - forged_authority.compile_boundary_data() + 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(): @@ -272,9 +271,8 @@ def test_analytic_inflow_fails_closed_for_primitive_per_point_conversion(): ) case.numerics(numerics, block=block) - authority = case._resolved_numerics_for("tracer").boundaries[0] with pytest.raises(NotImplementedError, match="analytic primitive inflow"): - authority.compile_boundary_data() + case._resolved_numerics_for("tracer") def test_analytic_inflow_fails_closed_for_discrete_setup_inputs(): @@ -294,9 +292,8 @@ def test_analytic_inflow_fails_closed_for_discrete_setup_inputs(): ) case.numerics(numerics, block=block) - authority = case._resolved_numerics_for("tracer").boundaries[0] with pytest.raises(NotImplementedError, match="setup-program discrete inputs"): - authority.compile_boundary_data() + case._resolved_numerics_for("tracer") def test_analytic_inflow_fails_closed_when_one_plan_mixes_logical_clocks(): @@ -319,9 +316,8 @@ def test_analytic_inflow_fails_closed_when_one_plan_mixes_logical_clocks(): ) case.numerics(numerics, block=block) - authority = case._resolved_numerics_for("tracer").boundaries[0] with pytest.raises(ValueError, match="plan cannot mix several logical Clocks"): - authority.compile_boundary_data() + case._resolved_numerics_for("tracer") def test_transport_set_rejects_incomplete_geometry_at_resolution(): @@ -404,12 +400,11 @@ def resolve_condition(self, **kwargs): })) case.numerics(numerics, block=block) - authority = case._resolved_numerics_for("tracer").boundaries[0] with pytest.raises( NotImplementedError, match="prepared model eigenstructure.*cannot fall back", ): - authority.compile_boundary_data() + case._resolved_numerics_for("tracer") def test_slip_wall_requires_roles_and_lowers_one_model_aware_face_law(): From d85aadf49e822c177af5f0eec11afb843b2cf392 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 02:09:36 +0200 Subject: [PATCH 82/93] test(boundary): ratchet resolve-time executable authority --- ...t_hyperbolic_boundary_authority_ratchet.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py index c3864e422..b284edd71 100644 --- a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py +++ b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py @@ -9,6 +9,7 @@ from __future__ import annotations +import ast import re from pathlib import Path @@ -90,3 +91,28 @@ def test_legacy_transport_boundary_authorities_can_only_shrink() -> None: "legacy transport-boundary authority expanded; lower the route to " "PreparedBoundaryPlan instead:\n " + "\n ".join(violations) ) + + +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" + ) From 06d6062bc5b37c3f0ccf6d7565d90f76912e1992 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 02:19:16 +0200 Subject: [PATCH 83/93] refactor(boundary): retain typed provider law identities --- python/pops/mesh/boundaries/__init__.py | 10 ++- python/pops/mesh/boundaries/providers.py | 89 ++++++++++++++++--- .../test_field_residual_solve_contract.py | 4 +- .../unit/mesh/test_boundary_topology_ports.py | 23 +++++ 4 files changed, 109 insertions(+), 17 deletions(-) diff --git a/python/pops/mesh/boundaries/__init__.py b/python/pops/mesh/boundaries/__init__.py index 44321531a..edf73763e 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, + 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", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", "BoundaryComponentBinding", "BoundaryLinearizationContribution", "BoundaryResidualContribution", diff --git a/python/pops/mesh/boundaries/providers.py b/python/pops/mesh/boundaries/providers.py index b944c968b..5a188c60e 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,34 @@ _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" + 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.CONSTRAINT_RESIDUAL: ConstraintResidual, +} def _handle(value: Any, *, where: str, kind: str) -> Handle: @@ -79,8 +108,11 @@ class BoundaryProvider: handle: Handle outputs: tuple[BoundaryPort, ...] dependencies: BoundaryDependencies + kind: BoundaryProviderKind def __post_init__(self) -> None: + if not isinstance(self.kind, BoundaryProviderKind): + raise TypeError("BoundaryProvider.kind must be a BoundaryProviderKind") _handle(self.handle, where="BoundaryProvider.handle", kind="boundary_provider") if not isinstance(self.outputs, tuple) or not self.outputs: raise TypeError("BoundaryProvider.outputs must be a non-empty tuple") @@ -90,6 +122,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 +152,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 +162,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 +173,62 @@ 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) @dataclass(frozen=True, slots=True) @@ -273,7 +337,8 @@ def resolve(self, topology: Any, needs: Any) -> ResolvedBoundaryPlan: __all__ = [ - "BoundaryProvider", "BoundaryProviderRegistry", "DirectionalTransport", "Dirichlet", + "BoundaryProvider", "BoundaryProviderKind", "BoundaryProviderRegistry", "DirectionalTransport", + "Dirichlet", "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", ] 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 fd37487de..7a6a385d0 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, @@ -289,6 +290,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,), @@ -304,6 +316,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, @@ -311,6 +325,14 @@ 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_resolution_diagnostics_cover_missing_double_extra_ambiguous_and_periodic_physical(): topology = _topology() @@ -414,6 +436,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), From 92f949aa05026a18b246b975af91080e389c1ccc Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 02:19:28 +0200 Subject: [PATCH 84/93] fix(boundary): reject mismatched transport provider laws --- python/pops/boundary/transport.py | 18 ++++++++++++++++- .../unit/boundary/test_transport_authoring.py | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/python/pops/boundary/transport.py b/python/pops/boundary/transport.py index 95c2a7c9b..c17788328 100644 --- a/python/pops/boundary/transport.py +++ b/python/pops/boundary/transport.py @@ -258,7 +258,7 @@ class ResolvedTransportCondition: 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") @@ -274,6 +274,22 @@ def __post_init__(self) -> None: 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, + )), + "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 { diff --git a/tests/python/unit/boundary/test_transport_authoring.py b/tests/python/unit/boundary/test_transport_authoring.py index 1cf0ba309..03966c437 100644 --- a/tests/python/unit/boundary/test_transport_authoring.py +++ b/tests/python/unit/boundary/test_transport_authoring.py @@ -407,6 +407,26 @@ def resolve_condition(self, **kwargs): case._resolved_numerics_for("tracer") +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({ From 52051344600bcf1e9172656afa23ab978a610c29 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 02:19:39 +0200 Subject: [PATCH 85/93] test(boundary): ratchet provider law authentication --- CHANGELOG.md | 3 ++ python/pops/_capabilities_report.py | 5 ++-- ...t_hyperbolic_boundary_authority_ratchet.py | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f30df928..6da5051f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning 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, while the still-missing post-Riemann execution ABI remains 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 diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 6c124131f..08e560c0f 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -455,8 +455,9 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: gpu=gpu, status="unavailable", limitation=( - "the prepared boundary component ABI has ghost, residual, and JVP operations " - "but no post-Riemann numerical-flux transformation port" + "the typed NumericalFlux port and immutable no_flux provider law resolve without " + "semantic inference, but the prepared boundary component ABI has ghost, residual, " + "and JVP operations and no post-Riemann numerical-flux transformation port" ), requested="post-Riemann transport-boundary flux provider", available_route="prepared ghost-state/exterior-state transport boundary", diff --git a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py index b284edd71..42a550e46 100644 --- a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py +++ b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py @@ -116,3 +116,31 @@ def test_resolved_transport_authority_accepts_only_executable_descriptors() -> N "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" + ) From 7c387151caaec0abb8b93c506f8d31c800f3d526 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 02:24:29 +0200 Subject: [PATCH 86/93] test(boundary): expose typed no-flux nonclaim --- tests/python/unit/codegen/test_fail_closed_reports.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index d22b49c05..ac627fce5 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -137,6 +137,8 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k assert limitation in route.limitation assert alternative in route.alternative assert route.error_message + assert "immutable no_flux provider law" in \ + routes["boundary:post_riemann_flux"].limitation def test_riemann_recovery_routes_distinguish_typed_rejection_from_missing_policy(): From cc7c8ff7c327a0cdc2b46ebfcda0030fa80f7fee Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 07:37:00 +0200 Subject: [PATCH 87/93] feat(recovery): preserve executed method identity --- CHANGELOG.md | 3 ++ docs/ALGORITHMS.md | 5 ++- docs/design/native-capability-matrix.md | 4 +- .../nonlinear/prepared_variable_recovery.hpp | 26 ++++++++++++ python/pops/_capabilities_report.py | 3 +- scripts/run_adc757_prepared_numerics_gate.py | 1 + src/runtime/system/system_fields.cpp | 4 +- tests/cpp/unit/codegen/test_block_builder.cpp | 4 ++ .../numerics/test_variable_recovery_chain.cpp | 42 +++++++++++++++++++ tests/gates/adc757_prepared_numerics.toml | 12 ++++++ .../test_adc757_prepared_numerics_gate.py | 2 +- ...test_variable_recovery_consumer_cutover.py | 29 +++++++++++++ .../unit/codegen/test_fail_closed_reports.py | 1 + 13 files changed, 131 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da5051f4..d50a75b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning ### Changed +- 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, diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index 1182db5fe..a8ac1c78f 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -151,7 +151,10 @@ Every conservative-to-primitive stencil sample is evaluated through 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 pointwise route is fixed-size, `POPS_HD`, allocation-free and callback-free. +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 diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index c9d931efc..33338ebdf 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -160,7 +160,9 @@ Supported native routes include: or restart metadata yet. Callers can therefore request the single-solver typed-rejection route explicitly, but cannot claim a configured fallback policy. - Prepared variable recovery is explicitly `partial`. One block-prepared closed-form method returns - a device-copyable `RecoveryOutcome`/`RecoveryReport`; System conservative-to-primitive + 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 materialization and Cartesian, polar, masked, and embedded-boundary face reconstruction consume publication permission before copying a candidate or evaluating a flux. This route adds no implicit repair, fallback, or mutable cache. The separate diff --git a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp index 39a379b03..ab4814f86 100644 --- a/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp +++ b/include/pops/numerics/nonlinear/prepared_variable_recovery.hpp @@ -134,6 +134,8 @@ struct RecoveryOutcome { 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(); @@ -155,6 +157,8 @@ struct RecoveryReport { 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(); @@ -175,6 +179,8 @@ POPS_HD inline RecoveryReport recovery_report(const RecoveryOutcome& outcome) 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, @@ -196,6 +202,24 @@ inline constexpr const char* recovery_status_name(RecoveryStatus status) { 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: @@ -322,6 +346,7 @@ POPS_HD inline void execute_recovery_chain(const RecoveryMethodList& 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; @@ -378,6 +403,7 @@ POPS_HD inline void execute_recovery_chain(const RecoveryMethodList& outcome.status = RecoveryStatus::kRecovered; outcome.cause = RecoveryCause::kNone; outcome.selected_method = MethodIndex; + outcome.selected_method_kind = Head::kind; outcome.failing_component = -1; } diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 08e560c0f..be451b639 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -515,7 +515,8 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: status="partial", limitation=( "one block-prepared closed-form method returns a device-copyable " - "RecoveryOutcome/RecoveryReport; System conservative-to-primitive " + "RecoveryOutcome/RecoveryReport retaining selected and last-attempted method " + "kinds across type erasure; System conservative-to-primitive " "materialization and Cartesian, polar, masked, and embedded-boundary face " "reconstruction consume publication permission before copying or flux " "evaluation, with no implicit repair, fallback, or mutable cache" diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 3f98592de..7c9501d73 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -27,6 +27,7 @@ "mpi_collective_execution", "typed_flux_recovery_consumption", "runtime_recovery_consumer_publication", + "type_erased_recovery_method_identity", "model_declared_admissibility", "prepared_limiter_provider", "cell_local_temporal_partition_authority", diff --git a/src/runtime/system/system_fields.cpp b/src/runtime/system/system_fields.cpp index 335a0b944..afbf2b19d 100644 --- a/src/runtime/system/system_fields.cpp +++ b/src/runtime/system/system_fields.cpp @@ -133,7 +133,9 @@ std::vector System::get_primitive_state(const std::string& name) { "' at local cell " + std::to_string(k) + " (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) + ")"); + ", 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) + ")"); for (int c = 0; c < nc; ++c) prim[static_cast(c) * nn + k] = cell_out[c]; } diff --git a/tests/cpp/unit/codegen/test_block_builder.cpp b/tests/cpp/unit/codegen/test_block_builder.cpp index d4d2713c6..d32a65492 100644 --- a/tests/cpp/unit/codegen/test_block_builder.cpp +++ b/tests/cpp/unit/codegen/test_block_builder.cpp @@ -164,6 +164,8 @@ TEST(test_block_builder, cell_primitive_conversion_consumes_prepared_recovery_ou 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); @@ -179,6 +181,8 @@ TEST(test_block_builder, cell_primitive_conversion_consumes_prepared_recovery_ou 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); } diff --git a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp index a6ba8e5a5..dbeb96328 100644 --- a/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp +++ b/tests/cpp/unit/numerics/test_variable_recovery_chain.cpp @@ -145,10 +145,31 @@ TEST(PreparedVariableRecovery, ordered_chain_uses_common_prepared_solver) { 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{})); @@ -159,6 +180,10 @@ TEST(PreparedVariableRecovery, rejected_chain_never_changes_solution_or_cache) { 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)}; @@ -177,6 +202,23 @@ TEST(PreparedVariableRecovery, rejected_chain_never_changes_solution_or_cache) { 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>{}, diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 32cfb4e43..137b73998 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -181,6 +181,18 @@ polarity = "refusal" target = "test_facade_routing" test_regex = "^FacadeRouting\\.PrimitiveMaterializationFailsClosedWithoutMutatingAcceptedState$" +[[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" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index 60ef93cd3..b72a2533b 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 35 + assert len(data["check"]) == 37 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-682", diff --git a/tests/python/architecture/test_variable_recovery_consumer_cutover.py b/tests/python/architecture/test_variable_recovery_consumer_cutover.py index 72027bc14..8a25b75fd 100644 --- a/tests/python/architecture/test_variable_recovery_consumer_cutover.py +++ b/tests/python/architecture/test_variable_recovery_consumer_cutover.py @@ -9,6 +9,7 @@ SYSTEM_FIELDS = ROOT / "src/runtime/system/system_fields.cpp" 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", @@ -50,6 +51,34 @@ def test_runtime_materialization_consumes_recovery_before_copying_candidate(): assert "variable recovery failed" 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"), diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index ac627fce5..46cc8aac3 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -186,6 +186,7 @@ def test_variable_recovery_routes_separate_delivered_consumers_from_complete_cut 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 "no implicit repair, fallback, or mutable cache" in prepared.limitation From cef647465823c9a4224563fdadc1739066b25b9a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 07:38:48 +0200 Subject: [PATCH 88/93] feat(boundary): define typed post-Riemann flux port --- docs/design/external-component-packages.md | 5 +- .../config/generated_component_abi.hpp | 34 +++++++++++- .../config/generated_component_catalog.hpp | 6 +-- .../config/generated_route_accessors.inc | 2 +- .../init/generated_component_invokers.inc | 2 +- .../pops/_generated_component_interfaces.py | 13 ++++- python/pops/mesh/boundaries/__init__.py | 4 +- python/pops/mesh/boundaries/ghost_plan.py | 51 +++++++++++++++++- python/pops/mesh/boundaries/providers.py | 26 ++++++++- .../pops/model/_generated_component_schema.py | 4 +- .../runtime/_generated_component_routes.py | 6 +-- schemas/component_catalog.v2.json | 14 +++++ scripts/generate_component_catalog.py | 29 ++++++++++ .../test_external_interface_backend.py | 3 +- .../unit/mesh/test_boundary_topology_ports.py | 37 +++++++++++++ .../unit/mesh/test_ghost_producer_plan.py | 53 +++++++++++++++++++ 16 files changed, 267 insertions(+), 22 deletions(-) diff --git a/docs/design/external-component-packages.md b/docs/design/external-component-packages.md index caed1db16..fdf18cfde 100644 --- a/docs/design/external-component-packages.md +++ b/docs/design/external-component-packages.md @@ -57,8 +57,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 diff --git a/include/pops/runtime/config/generated_component_abi.hpp b/include/pops/runtime/config/generated_component_abi.hpp index c48753999..f1e1304bc 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 "a7d1f895537a503d54218e1e2822e978e4c6f2d2c744bd44283774cc94d2ac12" +#define POPS_COMPONENT_CATALOG_SHA256_V1 "a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0" #define POPS_COMPONENT_PROTOCOL_ABI_V1 1u #define POPS_COMPONENT_COMMON_ABI_V1 1u @@ -26,6 +26,7 @@ typedef enum PopsNativeInterfaceIdV1 { POPS_NATIVE_INTERFACE_TAGGER_V2 = 3, POPS_NATIVE_INTERFACE_CLUSTERING_V1 = 4, POPS_NATIVE_INTERFACE_TRANSFER_V1 = 5, + POPS_NATIVE_INTERFACE_BOUNDARY_FLUX_V1 = 6, POPS_NATIVE_INTERFACE_FIELD_SOLVER_V2 = 7, POPS_NATIVE_INTERFACE_WRITER_V1 = 8, POPS_NATIVE_INTERFACE_FIELD_TOPOLOGY_V2 = 9, @@ -289,6 +290,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; @@ -724,6 +754,7 @@ 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_BOUNDARY_FLUX_V1: return sizeof(PopsBoundaryFluxApiV1); 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); @@ -739,6 +770,7 @@ 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_BOUNDARY_FLUX_V1: return "PopsBoundaryFluxApiV1"; 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"; diff --git a/include/pops/runtime/config/generated_component_catalog.hpp b/include/pops/runtime/config/generated_component_catalog.hpp index f5090a814..a409e94be 100644 --- a/include/pops/runtime/config/generated_component_catalog.hpp +++ b/include/pops/runtime/config/generated_component_catalog.hpp @@ -307,9 +307,9 @@ inline constexpr int kComponentCatalogSchemaVersion = 1; inline constexpr int kComponentManifestSchemaVersion = 2; inline constexpr int kRouteRegistryVersion = 2; inline constexpr int kCapabilityVocabularyVersion = 4; -inline constexpr const char* kComponentCatalogSha256 = "a7d1f895537a503d54218e1e2822e978e4c6f2d2c744bd44283774cc94d2ac12"; -inline constexpr const char* kComponentCatalogSemanticSha256 = "cbdf7a6604c05276f4a308b692a4fb3bed00d756929d06780b5f4d6c39bfa778"; -inline constexpr const char* kRouteRegistrySignature = "v2:cbdf7a6604c05276f4a308b692a4fb3bed00d756929d06780b5f4d6c39bfa778"; +inline constexpr const char* kComponentCatalogSha256 = "a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0"; +inline constexpr const char* kComponentCatalogSemanticSha256 = "9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1"; +inline constexpr const char* kRouteRegistrySignature = "v2:9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1"; inline constexpr const char* kComponentManifestSemanticFields[] = { "schema_version", "uri", diff --git a/include/pops/runtime/config/generated_route_accessors.inc b/include/pops/runtime/config/generated_route_accessors.inc index 313844dc6..2d90110f9 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 a7d1f895537a503d54218e1e2822e978e4c6f2d2c744bd44283774cc94d2ac12; DO NOT EDIT. +// Generated from component catalog a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0; 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/python/bindings/core/init/generated_component_invokers.inc b/python/bindings/core/init/generated_component_invokers.inc index b1c768e6d..e9aee010c 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 a7d1f895537a503d54218e1e2822e978e4c6f2d2c744bd44283774cc94d2ac12; DO NOT EDIT. +// Generated by scripts/generate_component_catalog.py from catalog a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0; DO NOT EDIT. // This file is the sole Python/native request marshaller. init_component_loader.cpp only registers it. #include diff --git a/python/pops/_generated_component_interfaces.py b/python/pops/_generated_component_interfaces.py index 189ece0f2..fc193e650 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 = 'a7d1f895537a503d54218e1e2822e978e4c6f2d2c744bd44283774cc94d2ac12' -NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = 'cbdf7a6604c05276f4a308b692a4fb3bed00d756929d06780b5f4d6c39bfa778' +NATIVE_COMPONENT_CATALOG_SHA256 = 'a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0' +NATIVE_COMPONENT_CATALOG_SEMANTIC_SHA256 = '9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' 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': 'boundary_flux', + 'uri': 'pops://interfaces/boundary-flux', + 'version': 1, + 'cpp_table': 'PopsBoundaryFluxApiV1', + 'hot_path': True, + 'facets': ('provider', 'lowering', 'fallible_evaluation'), + 'operations': ('transform_faces',)}, {'id': 7, 'name': 'field_solver', 'uri': 'pops://interfaces/field-solver', @@ -102,6 +110,7 @@ '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/mesh/boundaries/__init__.py b/python/pops/mesh/boundaries/__init__.py index edf73763e..2befc9dac 100644 --- a/python/pops/mesh/boundaries/__init__.py +++ b/python/pops/mesh/boundaries/__init__.py @@ -31,7 +31,7 @@ RepresentationFlow, SignDependence, SonicPolicy) from .providers import ( BoundaryProvider, BoundaryProviderKind, BoundaryProviderRegistry, DirectionalTransport, - Dirichlet, GhostFormula, Inflow, Mixed, Neumann, NoFlux, Outflow, + Dirichlet, GhostFormula, Inflow, Mixed, Neumann, NoFlux, Outflow, PostRiemannFlux, ResolvedBoundaryBinding, ResolvedBoundaryPlan) from .topology import ( BoundaryHandle, BoundaryOrientation, BoundarySide, BoundaryTopology, @@ -125,7 +125,7 @@ def options(self) -> dict: "IncomingMultiplicity", "NumericalFlux", "RepresentationFlow", "SignDependence", "SonicPolicy", "BoundaryProvider", "BoundaryProviderKind", "BoundaryProviderRegistry", "DirectionalTransport", "Dirichlet", "GhostFormula", "Inflow", "Mixed", "Neumann", - "NoFlux", "Outflow", + "NoFlux", "Outflow", "PostRiemannFlux", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", "BoundaryComponentBinding", "BoundaryLinearizationContribution", "BoundaryResidualContribution", 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/providers.py b/python/pops/mesh/boundaries/providers.py index 5a188c60e..4568d72eb 100644 --- a/python/pops/mesh/boundaries/providers.py +++ b/python/pops/mesh/boundaries/providers.py @@ -31,6 +31,7 @@ class BoundaryProviderKind(Enum): DIRICHLET = "dirichlet" NEUMANN = "neumann" NO_FLUX = "no_flux" + POST_RIEMANN_FLUX = "post_riemann_flux" CONSTRAINT_RESIDUAL = "constraint_residual" @@ -43,6 +44,7 @@ class BoundaryProviderKind(Enum): BoundaryProviderKind.DIRICHLET: ExteriorTrace, BoundaryProviderKind.NEUMANN: ConstraintResidual, BoundaryProviderKind.NO_FLUX: NumericalFlux, + BoundaryProviderKind.POST_RIEMANN_FLUX: NumericalFlux, BoundaryProviderKind.CONSTRAINT_RESIDUAL: ConstraintResidual, } @@ -113,7 +115,12 @@ class BoundaryProvider: def __post_init__(self) -> None: if not isinstance(self.kind, BoundaryProviderKind): raise TypeError("BoundaryProvider.kind must be a BoundaryProviderKind") - _handle(self.handle, where="BoundaryProvider.handle", kind="boundary_provider") + 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): @@ -231,6 +238,21 @@ def NoFlux(*, handle: Any, output: NumericalFlux, 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) class ResolvedBoundaryBinding: need: BoundaryPort @@ -339,6 +361,6 @@ def resolve(self, topology: Any, needs: Any) -> ResolvedBoundaryPlan: __all__ = [ "BoundaryProvider", "BoundaryProviderKind", "BoundaryProviderRegistry", "DirectionalTransport", "Dirichlet", - "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", + "GhostFormula", "Inflow", "Mixed", "Neumann", "NoFlux", "Outflow", "PostRiemannFlux", "ResolvedBoundaryBinding", "ResolvedBoundaryPlan", ] diff --git a/python/pops/model/_generated_component_schema.py b/python/pops/model/_generated_component_schema.py index 3c50c63b6..92f7f57da 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 = 'a7d1f895537a503d54218e1e2822e978e4c6f2d2c744bd44283774cc94d2ac12' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'cbdf7a6604c05276f4a308b692a4fb3bed00d756929d06780b5f4d6c39bfa778' +COMPONENT_CATALOG_SHA256 = 'a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0' +COMPONENT_CATALOG_SEMANTIC_SHA256 = '9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' 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/runtime/_generated_component_routes.py b/python/pops/runtime/_generated_component_routes.py index e68c1d9f3..d28cc27ab 100644 --- a/python/pops/runtime/_generated_component_routes.py +++ b/python/pops/runtime/_generated_component_routes.py @@ -9,11 +9,11 @@ CAPABILITY_VOCAB_VERSION = 4 -COMPONENT_CATALOG_SHA256 = 'a7d1f895537a503d54218e1e2822e978e4c6f2d2c744bd44283774cc94d2ac12' +COMPONENT_CATALOG_SHA256 = 'a10653b4730d0e5a8d8b1c21d3bb4263f3ca8fc93ebdfb1af59b88c7cbce07f0' -COMPONENT_CATALOG_SEMANTIC_SHA256 = 'cbdf7a6604c05276f4a308b692a4fb3bed00d756929d06780b5f4d6c39bfa778' +COMPONENT_CATALOG_SEMANTIC_SHA256 = '9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' -ROUTE_REGISTRY_SIGNATURE = 'v2:cbdf7a6604c05276f4a308b692a4fb3bed00d756929d06780b5f4d6c39bfa778' +ROUTE_REGISTRY_SIGNATURE = 'v2:9d7d38624833a7a7d7462ac7113bbdc215e6bce145ad1150b8cc1decf2d297b1' ROUTE_TABLES = {'riemann': (('rusanov', 'pops::RusanovFlux', diff --git a/schemas/component_catalog.v2.json b/schemas/component_catalog.v2.json index 00c629597..18002fbd2 100644 --- a/schemas/component_catalog.v2.json +++ b/schemas/component_catalog.v2.json @@ -158,6 +158,16 @@ "facets": ["stencil", "lowering"], "operations": ["apply"] }, + { + "id": 6, + "name": "boundary_flux", + "uri": "pops://interfaces/boundary-flux", + "version": 1, + "cpp_table": "PopsBoundaryFluxApiV1", + "hot_path": true, + "facets": ["provider", "lowering", "fallible_evaluation"], + "operations": ["transform_faces"] + }, { "id": 7, "name": "field_solver", @@ -206,6 +216,10 @@ "interface": "numerical_flux", "operation": "evaluate_faces" }, + "boundary_flux_provider": { + "interface": "boundary_flux", + "operation": "transform_faces" + }, "residual_operator": { "interface": "field_boundary_closure", "operation": "residual" diff --git a/scripts/generate_component_catalog.py b/scripts/generate_component_catalog.py index 90d861deb..ce95300ff 100644 --- a/scripts/generate_component_catalog.py +++ b/scripts/generate_component_catalog.py @@ -925,6 +925,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; 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..1e8a4d604 100644 --- a/tests/python/integration/native_loader/test_external_interface_backend.py +++ b/tests/python/integration/native_loader/test_external_interface_backend.py @@ -11,7 +11,7 @@ def test_all_required_native_families_are_generated_data_only_contracts(): expected = { - "numerical_flux", "ghost_boundary", "field_boundary_closure", "tagger", + "numerical_flux", "ghost_boundary", "boundary_flux", "field_boundary_closure", "tagger", "clustering", "transfer", "field_solver", "writer", "field_topology", } resolved = {name: interfaces.resolve(name) for name in expected} @@ -95,6 +95,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/unit/mesh/test_boundary_topology_ports.py b/tests/python/unit/mesh/test_boundary_topology_ports.py index 7a6a385d0..919f2378e 100644 --- a/tests/python/unit/mesh/test_boundary_topology_ports.py +++ b/tests/python/unit/mesh/test_boundary_topology_ports.py @@ -31,6 +31,7 @@ Outflow, PeriodicIdentification, PeriodicOrientation, + PostRiemannFlux, RepresentationFlow, SignDependence, SonicPolicy, @@ -105,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") @@ -334,6 +339,38 @@ def test_noflux_satisfies_numerical_flux_only(): 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) + ghost = GhostState(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 + + 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() boundary = topology.physical[0] 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)), From e400f2e83fed48fea6dd961cfa5164d7b3350670 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 07:39:07 +0200 Subject: [PATCH 89/93] feat(boundary): execute post-Riemann flux transforms --- docs/design/native-capability-matrix.md | 10 +- .../boundary/boundary_component_executor.hpp | 182 ++++++++++++++++++ .../boundary/prepared_boundary_component.hpp | 34 +++- .../mesh/boundary/prepared_boundary_plan.hpp | 99 +++++++++- include/pops/runtime/amr_system.hpp | 3 + .../runtime/builders/block/block_builder.hpp | 28 ++- .../builders/compiled/amr_dsl_block.hpp | 2 + include/pops/runtime/context/grid_context.hpp | 24 +++ .../runtime/dynamic/component_consumers.hpp | 70 +++++++ include/pops/runtime/system.hpp | 3 + python/bindings/core/init/init_amr.cpp | 14 ++ python/bindings/core/init/init_system.cpp | 14 ++ python/pops/_capabilities_report.py | 16 +- python/pops/runtime/_runtime_authorities.py | 7 + src/runtime/amr/amr_system.cpp | 13 ++ src/runtime/system/system_install.cpp | 15 ++ .../native_loader/test_amr_native_loader.cpp | 135 ++++++++++++- .../runtime/test_component_interfaces.cpp | 60 ++++++ ...t_hyperbolic_boundary_authority_ratchet.py | 49 ++++- .../unit/codegen/test_fail_closed_reports.py | 13 +- ...est_boundary_component_prepare_contract.py | 28 ++- 21 files changed, 788 insertions(+), 31 deletions(-) diff --git a/docs/design/native-capability-matrix.md b/docs/design/native-capability-matrix.md index c9d931efc..ccfa8dd59 100644 --- a/docs/design/native-capability-matrix.md +++ b/docs/design/native-capability-matrix.md @@ -140,8 +140,14 @@ Supported native routes include: 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. Separate `unavailable` rows expose the missing characteristic - no-inflow kernel and post-Riemann flux transformation. + admissibility projection. A separate `unavailable` row exposes the missing characteristic + no-inflow kernel. 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 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/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 c767765cc..4e4416401 100644 --- a/include/pops/mesh/boundary/prepared_boundary_plan.hpp +++ b/include/pops/mesh/boundary/prepared_boundary_plan.hpp @@ -204,9 +204,11 @@ 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_)) {} Session& operator=(Session&& other) noexcept { @@ -215,9 +217,11 @@ 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_); } @@ -236,6 +240,9 @@ class PreparedBoundaryPlan { 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, @@ -247,6 +254,9 @@ 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, @@ -262,6 +272,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; @@ -274,9 +287,11 @@ 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_; }; @@ -354,6 +369,11 @@ class PreparedBoundaryPlan { 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)); @@ -371,8 +391,10 @@ 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 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 @@ -436,6 +458,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_) @@ -447,6 +471,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_) @@ -661,6 +687,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 { @@ -680,6 +716,7 @@ class PreparedBoundaryPlan { PreparedBoundaryReadDependencies read_dependencies_; std::vector periodic_identifications_; std::vector> ghost_components_; + std::vector> flux_components_; std::vector> residual_components_; std::vector> jvp_components_; std::size_t component_revision_ = 0; @@ -873,10 +910,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_) @@ -980,6 +1020,51 @@ inline void PreparedBoundaryPlan::Session::fill_same_level_and_physical( 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.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( + "post-Riemann boundary flux fields require the N-ary prepared registry seam"); + fields.bind_field(dependencies.front(), *auxiliary); + } + 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::transform_fluxes( + const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, + const detail::BoundaryFieldRegistry& fields, const Geometry& geometry, MultiFab& fx, + MultiFab& fy) const { + validate_current_(); + if (flux_workspaces_.size() != flux_components_.size()) + throw std::logic_error( + "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( const runtime::multiblock::BoundaryEvaluationPoint& point, const MultiFab& state, const MultiFab* auxiliary, const Geometry& geometry, MultiFab& residual) const { @@ -1089,6 +1174,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/runtime/amr_system.hpp b/include/pops/runtime/amr_system.hpp index ebe5d9b34..8b080a1ac 100644 --- a/include/pops/runtime/amr_system.hpp +++ b/include/pops/runtime/amr_system.hpp @@ -384,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); diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index ad0016a83..d0c29181c 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -126,7 +126,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())); @@ -152,6 +154,15 @@ inline void assemble_rhs_without_prepared_interfaces( context.geom.dy(), reconstruct_primitive, positivity_floor, weno_epsilon); } + 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_interface_fluxes(fx, fy, context); mf_eval_rhs(model, state, *context.aux, fx, fy, context.geom.dx(), context.geom.dy(), residual); } @@ -203,20 +214,23 @@ 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_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) { diff --git a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp index 01ff570e3..83e718405 100644 --- a/include/pops/runtime/builders/compiled/amr_dsl_block.hpp +++ b/include/pops/runtime/builders/compiled/amr_dsl_block.hpp @@ -505,6 +505,7 @@ 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); + transform_grid_boundary_fluxes(U, Fx, Fy, boundary, point); detail::zero_prepared_interface_fluxes(Fx, Fy, boundary.context()); pops::mf_eval_rhs(model, U, aux, Fx, Fy, geom.dx(), geom.dy(), R); }; @@ -517,6 +518,7 @@ 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); + transform_grid_boundary_fluxes(U, Fx, Fy, boundary, point); detail::zero_prepared_interface_fluxes(Fx, Fy, boundary.context()); pops::mf_eval_rhs(sm, U, aux, Fx, Fy, geom.dx(), geom.dy(), R); }; diff --git a/include/pops/runtime/context/grid_context.hpp b/include/pops/runtime/context/grid_context.hpp index 5822d9854..bf6f1a1c0 100644 --- a/include/pops/runtime/context/grid_context.hpp +++ b/include/pops/runtime/context/grid_context.hpp @@ -185,6 +185,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); @@ -266,6 +267,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 +441,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) { diff --git a/include/pops/runtime/dynamic/component_consumers.hpp b/include/pops/runtime/dynamic/component_consumers.hpp index ccdaeb506..d47114356 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) { diff --git a/include/pops/runtime/system.hpp b/include/pops/runtime/system.hpp index 0b5b57deb..e36a558a8 100644 --- a/include/pops/runtime/system.hpp +++ b/include/pops/runtime/system.hpp @@ -365,6 +365,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); diff --git a/python/bindings/core/init/init_amr.cpp b/python/bindings/core/init/init_amr.cpp index dbe09e67a..6bb5a210b 100644 --- a/python/bindings/core/init/init_amr.cpp +++ b/python/bindings/core/init/init_amr.cpp @@ -303,6 +303,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, diff --git a/python/bindings/core/init/init_system.cpp b/python/bindings/core/init/init_system.cpp index b1ac8864e..67635e887 100644 --- a/python/bindings/core/init/init_system.cpp +++ b/python/bindings/core/init/init_system.cpp @@ -220,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, diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 08e560c0f..4f4d8267e 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -449,19 +449,21 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: _row( "boundary:post_riemann_flux", layout="uniform|amr", - backend="none", + backend="production", platform="host", mpi=mpi, gpu=gpu, - status="unavailable", + status="partial", limitation=( - "the typed NumericalFlux port and immutable no_flux provider law resolve without " - "semantic inference, but the prepared boundary component ABI has ghost, residual, " - "and JVP operations and no post-Riemann numerical-flux transformation port" + "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="prepared ghost-state/exterior-state transport boundary", - alternative="add the typed NumericalFlux boundary component interface", + available_route="PostRiemannFlux plus one qualified BoundaryFlux component", source=source, ), _row( diff --git a/python/pops/runtime/_runtime_authorities.py b/python/pops/runtime/_runtime_authorities.py index 387616733..393eb4666 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), } @@ -333,6 +334,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, diff --git a/src/runtime/amr/amr_system.cpp b/src/runtime/amr/amr_system.cpp index 1adaf702d..97785f31e 100644 --- a/src/runtime/amr/amr_system.cpp +++ b/src/runtime/amr/amr_system.cpp @@ -1439,6 +1439,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) { diff --git a/src/runtime/system/system_install.cpp b/src/runtime/system/system_install.cpp index aef8c5f6b..1cef0ab76 100644 --- a/src/runtime/system/system_install.cpp +++ b/src/runtime/system/system_install.cpp @@ -412,6 +412,21 @@ 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->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) { 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 ea6f77c06..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)}}}; } @@ -1137,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/unit/runtime/test_component_interfaces.cpp b/tests/cpp/unit/runtime/test_component_interfaces.cpp index 155514c4f..8888958d1 100644 --- a/tests/cpp/unit/runtime/test_component_interfaces.cpp +++ b/tests/cpp/unit/runtime/test_component_interfaces.cpp @@ -580,6 +580,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) { diff --git a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py index 42a550e46..2929be774 100644 --- a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py +++ b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py @@ -2,7 +2,7 @@ The prepared hyperbolic boundary path is already the compiled Uniform/AMR route. A few older native authorities still exist while their replacements -need metric, characteristic, and post-Riemann kernels. Keep their remaining +need metric and characteristic kernels. Keep their remaining lexical surface bounded so adjacent work cannot silently create another transport-boundary engine before that cutover is complete. """ @@ -10,6 +10,7 @@ from __future__ import annotations import ast +import json import re from pathlib import Path @@ -144,3 +145,49 @@ def test_boundary_provider_identity_cannot_erase_its_selected_law() -> None: 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") + + 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 diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index ac627fce5..1708407bc 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -125,10 +125,6 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k "executable model eigenstructure", "prepared characteristic kernel", ), - "boundary:post_riemann_flux": ( - "no post-Riemann numerical-flux transformation port", - "NumericalFlux boundary component interface", - ), } for feature, (limitation, alternative) in expected_unavailable.items(): route = routes[feature] @@ -137,8 +133,13 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k assert limitation in route.limitation assert alternative in route.alternative assert route.error_message - assert "immutable no_flux provider law" in \ - routes["boundary:post_riemann_flux"].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 def test_riemann_recovery_routes_distinguish_typed_rejection_from_missing_policy(): 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 0a3481558..63129ccf9 100644 --- a/tests/python/unit/runtime/test_boundary_component_prepare_contract.py +++ b/tests/python/unit/runtime/test_boundary_component_prepare_contract.py @@ -40,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", @@ -56,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"], @@ -92,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)) @@ -104,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") @@ -152,6 +173,7 @@ class BoundaryBlock: assert native.state_routes == [("block", "case::block::state")] assert native.discarded is False assert native.prepare_overrides == ("", "") + assert native.installer == expected_installer @pytest.mark.parametrize( From 8697657c101bc4815493185ecbf1bc3fc6266495 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 10:47:27 +0200 Subject: [PATCH 90/93] fix(numerics): fail closed on unqualified boundary flux routes --- .../runtime/builders/block/block_builder.hpp | 19 ++--- python/pops/_capabilities_report.py | 2 +- scripts/run_adc757_prepared_numerics_gate.py | 59 +++++++++++++--- tests/gates/adc757_prepared_numerics.toml | 13 ++++ .../test_adc757_prepared_numerics_gate.py | 70 +++++++++++++++---- ...t_hyperbolic_boundary_authority_ratchet.py | 11 +++ .../unit/codegen/test_fail_closed_reports.py | 8 +++ .../unit/mesh/test_boundary_topology_ports.py | 9 ++- 8 files changed, 155 insertions(+), 36 deletions(-) diff --git a/include/pops/runtime/builders/block/block_builder.hpp b/include/pops/runtime/builders/block/block_builder.hpp index d0c29181c..f63dd7510 100644 --- a/include/pops/runtime/builders/block/block_builder.hpp +++ b/include/pops/runtime/builders/block/block_builder.hpp @@ -187,19 +187,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, diff --git a/python/pops/_capabilities_report.py b/python/pops/_capabilities_report.py index 892388daf..2d7d47e93 100644 --- a/python/pops/_capabilities_report.py +++ b/python/pops/_capabilities_report.py @@ -452,7 +452,7 @@ def _python_contract_rows(flags: Any, source: str) -> list[Any]: backend="production", platform="host", mpi=mpi, - gpu=gpu, + gpu=False, status="partial", limitation=( "one typed BoundaryFlux component transforms the already evaluated outward-normal " diff --git a/scripts/run_adc757_prepared_numerics_gate.py b/scripts/run_adc757_prepared_numerics_gate.py index 7c9501d73..465c3147f 100755 --- a/scripts/run_adc757_prepared_numerics_gate.py +++ b/scripts/run_adc757_prepared_numerics_gate.py @@ -6,11 +6,14 @@ 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] @@ -22,6 +25,7 @@ "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", @@ -309,16 +313,53 @@ def _run_ctest(build_dir: Path, target: str, selector: str) -> None: 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: - command = [ - sys.executable, - "-m", - "pytest", - "-q", - "%s::%s" % (relative, test_name), - ] - print("+", " ".join(command), flush=True) - subprocess.run(command, cwd=ROOT, check=True) + 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 main(argv: list[str] | None = None) -> int: diff --git a/tests/gates/adc757_prepared_numerics.toml b/tests/gates/adc757_prepared_numerics.toml index 137b73998..a31e190d0 100644 --- a/tests/gates/adc757_prepared_numerics.toml +++ b/tests/gates/adc757_prepared_numerics.toml @@ -86,6 +86,19 @@ 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" diff --git a/tests/python/architecture/test_adc757_prepared_numerics_gate.py b/tests/python/architecture/test_adc757_prepared_numerics_gate.py index b72a2533b..0d2ed5316 100644 --- a/tests/python/architecture/test_adc757_prepared_numerics_gate.py +++ b/tests/python/architecture/test_adc757_prepared_numerics_gate.py @@ -26,7 +26,7 @@ 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"]) == 37 + assert len(data["check"]) == 39 assert {row["requirement"] for row in data["check"]} == runner.EXPECTED_REQUIREMENTS assert data["evidence_from"] == [ "ADC-682", @@ -66,6 +66,31 @@ def test_adc757_slice_executes_qualified_flux_provider_pack_proofs(): ] +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_claims_only_the_exact_delivered_mpi_collective_proof(): runner = _load_runner() data, errors = runner.validate_manifest(MANIFEST) @@ -269,6 +294,8 @@ def test_adc757_runner_executes_one_exact_pytest(monkeypatch): 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) @@ -277,16 +304,33 @@ def capture_pytest(command, **kwargs): "tests/python/unit/codegen/test_recovery_admissibility_codegen.py", "test_recovery_admissibility_is_emitted_and_hashed", ) - assert calls == [ - ( - [ - runner.sys.executable, - "-m", - "pytest", - "-q", - "tests/python/unit/codegen/test_recovery_admissibility_codegen.py::" - "test_recovery_admissibility_is_emitted_and_hashed", - ], - {"cwd": runner.ROOT, "check": True}, + [(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_hyperbolic_boundary_authority_ratchet.py b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py index 2929be774..1c5916246 100644 --- a/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py +++ b/tests/python/architecture/test_hyperbolic_boundary_authority_ratchet.py @@ -183,6 +183,17 @@ def test_post_riemann_flux_is_one_typed_outward_oriented_pipeline_stage() -> Non 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" diff --git a/tests/python/unit/codegen/test_fail_closed_reports.py b/tests/python/unit/codegen/test_fail_closed_reports.py index 2b38d2e93..6773f344d 100644 --- a/tests/python/unit/codegen/test_fail_closed_reports.py +++ b/tests/python/unit/codegen/test_fail_closed_reports.py @@ -148,6 +148,14 @@ def test_transport_boundary_routes_report_exact_supported_envelope_and_missing_k 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(): diff --git a/tests/python/unit/mesh/test_boundary_topology_ports.py b/tests/python/unit/mesh/test_boundary_topology_ports.py index 919f2378e..fc9531e51 100644 --- a/tests/python/unit/mesh/test_boundary_topology_ports.py +++ b/tests/python/unit/mesh/test_boundary_topology_ports.py @@ -344,7 +344,6 @@ def test_post_riemann_flux_has_one_exact_typed_component_route(): state, _, _ = _model_values() _, conservative = _representations() flux = NumericalFlux(boundary, state, conservative) - ghost = GhostState(boundary, state, conservative) provider = PostRiemannFlux( handle=_flux_provider_handle("wall_flux"), output=flux, @@ -357,6 +356,14 @@ def test_post_riemann_flux_has_one_exact_typed_component_route(): 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"), From 5cb3ebe681197166d62acbae5c18ca4d0b05d1fe Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 18:13:10 +0200 Subject: [PATCH 91/93] fix(codegen): retain resolved provider pack authority --- python/pops/codegen/module_lowering.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/python/pops/codegen/module_lowering.py b/python/pops/codegen/module_lowering.py index 8b44d3e12..d5df9934b 100644 --- a/python/pops/codegen/module_lowering.py +++ b/python/pops/codegen/module_lowering.py @@ -108,9 +108,8 @@ def _body_for_state(body: Any) -> Any: resolve_component_provider_packs, ) - m.__pops_bind_component_provider_packs__( - resolve_component_provider_packs(module) - ) + 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 @@ -199,7 +198,7 @@ def _declare_aux(nm: Any, key: Any) -> None: coverage_rows.append(LoweringCoverageRow( "module:%s:eigenvalues" % module.name, "documentary")) - for key in m._component_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"]) From 13e93a70f3601d6134673636fa71dcae1d6a7071 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 18:13:16 +0200 Subject: [PATCH 92/93] test(model): migrate fixtures to equal representation arity --- .../integration/bindings/test_name_binding_runtime.py | 4 +--- .../python/integration/io/test_time_history_checkpoint.py | 3 +-- .../native_loader/test_normalized_program_execution.py | 3 +-- .../test_prepared_krylov_method_component.py | 3 +-- .../native_loader/test_prepared_nullspace_component.py | 3 +-- .../test_prepared_preconditioner_component.py | 3 +-- .../unit/codegen/test_hierarchy_scoped_solve_emit.py | 2 +- tests/python/unit/descriptors/test_lib_descriptors.py | 8 +++++++- tests/python/unit/time/test_time_control_flow.py | 3 +-- tests/python/unit/time/test_time_control_flow_b.py | 3 +-- tests/python/unit/time/test_time_divergence.py | 3 +-- tests/python/unit/time/test_time_gmres.py | 3 +-- tests/python/unit/time/test_time_history.py | 3 +-- tests/python/unit/time/test_time_local_newton.py | 6 ++---- tests/python/unit/time/test_time_local_solve_run.py | 2 +- tests/python/unit/time/test_time_multiblock.py | 4 +--- tests/python/unit/time/test_time_multielliptic.py | 2 +- tests/python/unit/time/test_time_solve_linear.py | 6 ++---- tests/python/unit/time/test_time_std_rk.py | 3 +-- tests/python/unit/time/test_time_where.py | 3 +-- 20 files changed, 28 insertions(+), 42 deletions(-) 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/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/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/unit/codegen/test_hierarchy_scoped_solve_emit.py b/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py index 77eff465c..48381cf91 100644 --- a/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py +++ b/tests/python/unit/codegen/test_hierarchy_scoped_solve_emit.py @@ -29,7 +29,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], diff --git a/tests/python/unit/descriptors/test_lib_descriptors.py b/tests/python/unit/descriptors/test_lib_descriptors.py index 8febab47e..d95a464d6 100644 --- a/tests/python/unit/descriptors/test_lib_descriptors.py +++ b/tests/python/unit/descriptors/test_lib_descriptors.py @@ -59,7 +59,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 +67,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/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..8245b63e8 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]) 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 b689221a0..ab4328cdf 100644 --- a/tests/python/unit/time/test_time_multiblock.py +++ b/tests/python/unit/time/test_time_multiblock.py @@ -86,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]) diff --git a/tests/python/unit/time/test_time_multielliptic.py b/tests/python/unit/time/test_time_multielliptic.py index 8652a6742..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]) 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]) From ffe4d920e9751f268334d9f2439764e19bd48119 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 18:13:24 +0200 Subject: [PATCH 93/93] test(amr): qualify partition restart cells with active level --- tests/cpp/integration/amr/test_temporal_partition_restart.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cpp/integration/amr/test_temporal_partition_restart.cpp b/tests/cpp/integration/amr/test_temporal_partition_restart.cpp index 841b834ee..a96a6893d 100644 --- a/tests/cpp/integration/amr/test_temporal_partition_restart.cpp +++ b/tests/cpp/integration/amr/test_temporal_partition_restart.cpp @@ -127,6 +127,9 @@ TEST(test_temporal_partition_restart, AmrProgramAcceptedState accepted = deserialize_amr_program_accepted_state(system.program_accepted_state()); accepted.temporal_partition = cell_local_state(system.engine()->topology_epoch()); + // This fixture materializes one active level. Keep the three canonical cells and their distinct + // rungs, but qualify every record with that real hierarchy instead of an inactive synthetic level. + accepted.temporal_partition.cells.back().level = 0; const std::vector cell_local_image = serialize_amr_program_accepted_state(accepted); system.restore_checkpoint_accepted_state(cell_local_image);