From 2e4ef83a4aac51c70138232eb02164d2f1807b52 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 06:47:10 +0200 Subject: [PATCH 1/4] codegen: include AMR authorities in lowering coverage --- python/pops/codegen/_amr_lowering_coverage.py | 135 ++++++++++++++++++ python/pops/codegen/_phases.py | 17 ++- 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 python/pops/codegen/_amr_lowering_coverage.py diff --git a/python/pops/codegen/_amr_lowering_coverage.py b/python/pops/codegen/_amr_lowering_coverage.py new file mode 100644 index 000000000..ec50408db --- /dev/null +++ b/python/pops/codegen/_amr_lowering_coverage.py @@ -0,0 +1,135 @@ +"""Exact AMR authoring-to-runtime rows for the global lowering coverage report.""" +from __future__ import annotations + +from typing import Any + +from pops.codegen.lowering_coverage import LoweringCoverageReport, LoweringCoverageRow +from pops.identity import make_identity + + +def amr_lowering_coverage( + *, + resolved_hierarchy: Any, + transfer: Any, + bootstrap: Any, + execution: Any, +) -> LoweringCoverageReport: + """Project the resolved AMR authorities onto their executable runtime routes.""" + + from pops.amr.authoring import AMRExecution + from pops.mesh._amr._bootstrap_contracts import BootstrapPlan + from pops.mesh._amr._transfer_contracts import ResolvedAMRTransfer + from pops.mesh._amr.hierarchy_resolution import ResolvedHierarchy + + if type(resolved_hierarchy) is not ResolvedHierarchy: + raise TypeError("AMR lowering coverage requires an exact ResolvedHierarchy") + if type(transfer) is not ResolvedAMRTransfer: + raise TypeError("AMR lowering coverage requires an exact ResolvedAMRTransfer") + if type(bootstrap) is not BootstrapPlan: + raise TypeError("AMR lowering coverage requires an exact BootstrapPlan") + if type(execution) is not AMRExecution: + raise TypeError("AMR lowering coverage requires an exact AMRExecution") + + hierarchy_identity = resolved_hierarchy.identity.token + transfer_identity = transfer.identity.token + bootstrap_identity = bootstrap.identity.token + execution_identity = make_identity("amr-execution", execution.to_data()).token + tagging = bootstrap.tagging + tagging_target = "amr-runtime-tagging:%s" % tagging.qualified_id + + rows = [ + LoweringCoverageRow( + source="amr-hierarchy:%s" % hierarchy_identity, + disposition="lowered", + targets=("amr-runtime-hierarchy:%s" % hierarchy_identity,), + ), + LoweringCoverageRow( + source="amr-regrid:%s" % resolved_hierarchy.plan.regrid.identity.token, + disposition="lowered", + targets=("amr-runtime-regrid:%s" % hierarchy_identity,), + ), + LoweringCoverageRow( + source="amr-tagging-graph:%s" % tagging.qualified_id, + disposition="lowered", + targets=(tagging_target,), + ), + LoweringCoverageRow( + source="amr-tagging-hysteresis:%s" % tagging.qualified_id, + disposition="lowered", + targets=("%s:hysteresis" % tagging_target,), + ), + LoweringCoverageRow( + source="amr-tagging-conflict-policy:%s" % tagging.qualified_id, + disposition="lowered", + targets=("%s:conflict-policy" % tagging_target,), + ), + LoweringCoverageRow( + source="amr-transfer-plan:%s" % transfer_identity, + disposition="lowered", + targets=("amr-runtime-transfer:%s" % transfer_identity,), + ), + LoweringCoverageRow( + source="amr-execution:%s" % execution_identity, + disposition="lowered", + targets=("amr-runtime-execution:%s" % execution.mode,), + ), + LoweringCoverageRow( + source="amr-bootstrap:%s" % bootstrap_identity, + disposition="lowered", + targets=("amr-runtime-bootstrap:%s" % bootstrap_identity,), + ), + ] + registrations = { + registration.node_type: registration + for registration in tagging.registrations + } + + def append_predicate(node: Any, path: str) -> None: + registration = registrations[node.node_type] + rows.append(LoweringCoverageRow( + source="amr-tagging-predicate:%s:%s:%s" + % (tagging.qualified_id, path, node.node_type), + disposition="lowered", + targets=(registration.lowering.qualified_id,), + )) + for index, child in enumerate(node.operands()): + append_predicate(child, "%s/%d" % (path, index)) + + append_predicate(tagging.graph.refine, "refine") + if tagging.graph.coarsen is not None: + append_predicate(tagging.graph.coarsen, "coarsen") + rows.extend( + LoweringCoverageRow( + source="amr-transfer-entry:%s" % entry.identity.token, + disposition="lowered", + targets=( + "amr-runtime-transfer-operation:%s:%s" + % ( + entry.native_materialization.to_data()["action"], + entry.key.operation.name, + ), + ), + ) + for entry in transfer.entries + ) + rows.extend( + LoweringCoverageRow( + source="amr-subcycling:%s:%d-%d" + % (execution_identity, relation.parent_level, relation.child_level), + disposition="lowered", + targets=( + "amr-runtime-clock-relation:%d-%d:%d/%d" + % ( + relation.parent_level, + relation.child_level, + relation.temporal_ratio.numerator, + relation.temporal_ratio.denominator, + ), + ), + ) + for relation in execution.relations + ) + return LoweringCoverageReport(rows) + + +__all__ = ["amr_lowering_coverage"] diff --git a/python/pops/codegen/_phases.py b/python/pops/codegen/_phases.py index ed3c18187..249290c8d 100644 --- a/python/pops/codegen/_phases.py +++ b/python/pops/codegen/_phases.py @@ -295,6 +295,21 @@ def resolve_amr_handle(value: Any) -> Any: ) from pops.output._restart_provider import RestartAuthority restart_authority = RestartAuthority.from_consumer_graph(consumer_graph) + lowering_coverage = layout_lowering_coverage(layout_plan) + if bootstrap_plan is not None: + from pops.codegen._amr_lowering_coverage import amr_lowering_coverage + from pops.codegen.lowering_coverage import LoweringCoverageReport + + amr_coverage = amr_lowering_coverage( + resolved_hierarchy=resolved_hierarchy, + transfer=amr_transfer, + bootstrap=bootstrap_plan, + execution=amr_execution, + ) + lowering_coverage = LoweringCoverageReport(( + *lowering_coverage.rows, + *amr_coverage.rows, + )) return ResolvedSimulationPlan( snapshot=snapshot, target=target, backend=backend_token, layout=detached_layout, layout_plan=layout_plan, @@ -312,7 +327,7 @@ def resolve_amr_handle(value: Any) -> Any: capabilities={"resolution": evidence, "layout_plan": layout_plan.capability_evidence(), "amr_bootstrap": amr_capabilities}, - lowering_coverage=layout_lowering_coverage(layout_plan), compile_options=options, + lowering_coverage=lowering_coverage, compile_options=options, component_inputs=tuple(components), resolved_hierarchy=resolved_hierarchy, amr_transfer=amr_transfer, initial_condition_plan=initial_condition_plan, bootstrap_plan=bootstrap_plan, From 3fe44ac5f58dfabdf467720642800e43a942321a Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 06:47:30 +0200 Subject: [PATCH 2/4] examples: exercise fail-closed IMEX rollback --- docs/design/final-advection-imex-amr.md | 22 +++- .../EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py | 119 +++++++++++++++++- examples/final/README.md | 4 +- 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/docs/design/final-advection-imex-amr.md b/docs/design/final-advection-imex-amr.md index 5eb070960..7d086927f 100644 --- a/docs/design/final-advection-imex-amr.md +++ b/docs/design/final-advection-imex-amr.md @@ -28,8 +28,12 @@ property of this graph and tableau, not a repeated `order=2` option. Every fallible public solve returns an unreadable `SolveOutcome`. The example consumes every field solve with `RejectAttempt()`. A failed solve therefore raises the typed native rejection signal before a field, -state, diagnostic or output can read a partial result. Local affine elimination remains a value -operation because it has no iterative outcome to classify. +state, diagnostic or output can read a partial result. The executable acceptance also compiles a +separate negative case whose explicitly widened parameter domain makes the second IMEX diagonal +system exactly singular. It compares state, solved fields, hierarchy topology, Program +cache/history/clock/ledger registries and consumer cursors before and after the rejected attempt, +then requires that its output directory contain no file. The normal physical case retains the +strictly positive relaxation-rate domain. `Model.field_operator(...)` declares the physical equation and its RHS providers. The sole callable time-Program authority is the `FieldHandle` returned by `Case.field(operator, discretization)`: both @@ -64,11 +68,16 @@ The adaptive layout owns: - conservative state prolongation, restriction, coarse/fine fill and time interpolation; - elliptic recomputation after regrid instead of interpolating a stale solved field. +Resolution adds each hierarchy, regrid, tagging predicate, hysteresis/conflict policy, transfer +entry, bootstrap authority and subcycling relation to the global `LoweringCoverageReport`. Every row +names a concrete runtime target; the report is therefore a machine-readable lowering gate rather +than an `inspect()` narrative inferred after compilation. + The acceptance target intentionally requests a regrid on every accepted macro-step. The first snapshot may still expose zero completed regrids: cadence is a due condition, not proof that a non-empty tag set rebuilt the hierarchy. `simulation.amr.explain_regrid()` publishes the native `regrid_count` and `topology_epoch`; after the continuation step the example requires both values to -have increased, and requires the uninterrupted and restarted instances to report identical values. +remain monotone, and requires the uninterrupted and restarted instances to report identical values. `regrid_count` advances only after the native regrid completes, while `topology_epoch` identifies the installed hierarchy topology. A scheduled or no-op regrid is therefore never accepted as completed runtime evidence. @@ -108,7 +117,8 @@ qualified conservative state and solved-field route, patch topology, Program/con consumer cursors bit-for-bit. The snapshot also carries the live completed-regrid count and topology epoch, so checkpoint restore, uninterrupted/restarted continuation and manual/factory parity must preserve exactly the same AMR generation evidence. It then advances the uninterrupted and restarted -instances once more, requires a real counter/epoch increase, verifies the accepted multi-level flux +instances once more, requires monotone counter/epoch evidence, verifies the accepted multi-level flux ledger plus reflux-then-average-down trace, and repeats the complete comparison before exercising the -preset parity run. A printed success therefore follows real I/O, a completed regrid, restart, -continuation and manual/factory checks; it is not a demonstration placeholder. +preset parity run. A printed success therefore follows a real rejected-attempt rollback, real I/O, +an executed regrid cadence window, restart, continuation and manual/factory checks; it is not a +demonstration placeholder. diff --git a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py index 9a65f119b..646588c47 100644 --- a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py +++ b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py @@ -148,6 +148,7 @@ class IMEXRuntimeSnapshot: regrid_count: int topology_epoch: int program_hash: str + program_transaction_state: str consumer_graph_identity: str consumer_cursors: dict[str, Any] @@ -160,6 +161,15 @@ class IMEXAMRProgramEvidence: synchronization_phases: tuple[str, ...] +@dataclass(frozen=True, slots=True) +class IMEXRejectedAttemptEvidence: + """Exact proof that one consumed SolveOutcome rolled back without publication.""" + + error: str + before: IMEXRuntimeSnapshot + after: IMEXRuntimeSnapshot + + @dataclass(frozen=True, slots=True) class IMEXExecutionEvidence: """Artifacts plus exact pre/post-restart and continuation snapshots.""" @@ -287,7 +297,10 @@ def _preset_imex_program(core: IMEXAMRAuthoring, *, solve_action: Any) -> Progra def build_authoring( - *, use_preset: bool = False, field_solver: Any | None = None, + *, + use_preset: bool = False, + field_solver: Any | None = None, + relaxation_domain: Any | None = None, ) -> IMEXAMRAuthoring: domain = Rectangle( "unit_square", @@ -310,7 +323,11 @@ def build_authoring( # the incoming subspace; a static boundary table must not silently pretend to support them. velocity_x = model.param(RuntimeParam("a_x", default=1.0, domain=Positive())) velocity_y = model.param(RuntimeParam("a_y", default=0.25, domain=Positive())) - relaxation_rate = model.param(RuntimeParam("lambda", default=50.0, domain=Positive())) + relaxation_rate = model.param(RuntimeParam( + "lambda", + default=50.0, + domain=Positive() if relaxation_domain is None else relaxation_domain, + )) inlet_value = model.param(RuntimeParam("u_in", default=0.0, domain=Interval(-10.0, 10.0))) a_x = model.value(velocity_x) a_y = model.value(velocity_y) @@ -538,10 +555,15 @@ def build_consumers(core: IMEXAMRAuthoring, *, output_mode: Any = None) -> Any: def build_final_case( *, use_preset: bool = False, field_solver: Any | None = None, + relaxation_domain: Any | None = None, initial_background: float = 0.05, initial_amplitude: float = 0.95, output_mode: Any = None, ) -> FinalIMEXAMRCase: - core = build_authoring(use_preset=use_preset, field_solver=field_solver) + core = build_authoring( + use_preset=use_preset, + field_solver=field_solver, + relaxation_domain=relaxation_domain, + ) core.numerics.boundaries.add(build_boundaries(core)) core.case.numerics(core.numerics, block=core.tracer) core.case.initials.add(build_initial( @@ -551,12 +573,17 @@ def build_final_case( return FinalIMEXAMRCase(core, build_layout(core)) -def build_bind_params(core: IMEXAMRAuthoring, *, inlet_value: float = 0.0) -> dict[Any, float]: +def build_bind_params( + core: IMEXAMRAuthoring, + *, + inlet_value: float = 0.0, + relaxation_rate: float = 50.0, +) -> dict[Any, float]: resolve = core.case.resolve return { resolve(core.velocity_x): 1.0, resolve(core.velocity_y): 0.25, - resolve(core.relaxation_rate): 50.0, + resolve(core.relaxation_rate): relaxation_rate, resolve(core.inlet_value): inlet_value, resolve(core.refine_value): 0.70, resolve(core.coarsen_value): 0.25, @@ -566,16 +593,35 @@ def build_bind_params(core: IMEXAMRAuthoring, *, inlet_value: float = 0.0) -> di def compile_final_case( *, use_preset: bool = False, + relaxation_domain: Any | None = None, ) -> tuple[FinalIMEXAMRCase, Any, Any]: """Compile one exact manual or preset-authored target through the public lifecycle.""" target = build_final_case( - use_preset=use_preset, output_mode=_native_output_mode() + use_preset=use_preset, + relaxation_domain=relaxation_domain, + output_mode=_native_output_mode(), ) resolved = pops.resolve(pops.validate(target.authoring.case), layout=target.layout) return target, resolved, pops.compile(resolved) +def _program_transaction_state(simulation: Any) -> str: + """Canonicalize every rollback-sensitive Program registry without field arrays.""" + + report = simulation.program_report().to_dict() + return json.dumps({ + "cache": report["cache"], + "clocks": report["clocks"], + "diagnostics": report["diagnostics"], + "flux_ledger": report["flux_ledger"], + "histories": report["histories"], + "level_relations": report["level_relations"], + "synchronization": report["synchronization"], + "temporal": report["temporal"], + }, sort_keys=True, separators=(",", ":")) + + def _snapshot(simulation: Any) -> IMEXRuntimeSnapshot: """Capture state, solved fields, hierarchy, clocks, identities and consumer cursors.""" @@ -623,6 +669,7 @@ def _snapshot(simulation: Any) -> IMEXRuntimeSnapshot: regrid_count=int(regrid.regrid_count), topology_epoch=int(regrid.topology_epoch), program_hash=str(simulation.installed_program_hash()), + program_transaction_state=_program_transaction_state(simulation), consumer_graph_identity=simulation.consumer_graph.identity.token, consumer_cursors=simulation.consumer_cursors.to_data(), ) @@ -710,6 +757,10 @@ def _require_same_snapshot( "regrid_count": (left.regrid_count, right.regrid_count), "topology_epoch": (left.topology_epoch, right.topology_epoch), "program_hash": (left.program_hash, right.program_hash), + "program_transaction_state": ( + left.program_transaction_state, + right.program_transaction_state, + ), "consumer_graph_identity": ( left.consumer_graph_identity, right.consumer_graph_identity, @@ -792,6 +843,54 @@ def _reopen_scientific_outputs(root: Path) -> tuple[Path, Path, str, str]: ) +def run_rejected_attempt_rollback(output_dir: Any) -> IMEXRejectedAttemptEvidence: + """Force one singular IMEX solve and prove complete rollback before publication.""" + + root = Path(output_dir) + if root.exists() and any(root.iterdir()): + raise ValueError("rejected-attempt proof requires an empty output directory") + target, _resolved, artifact = compile_final_case( + use_preset=False, + relaxation_domain=Interval(-1.0e6, 1.0e6), + ) + first_dt = float(target.authoring.run_controls["t_end"]) + diagonal = float(IMEX_CN_HEUN.implicit_A[1][1]) + singular_rate = -1.0 / (first_dt * diagonal) + simulation = _bind_artifact( + artifact, + params=build_bind_params( + target.authoring, + relaxation_rate=singular_rate, + ), + ) + before = _snapshot(simulation) + try: + pops.run( + simulation, + t_end=first_dt, + max_steps=1, + output_dir=root, + ) + except RuntimeError as error: + message = str(error) + if not message.startswith("step attempt rejected during "): + raise RuntimeError( + "negative IMEX proof failed for an unexpected reason: %s" % message + ) from error + else: + raise RuntimeError("singular IMEX solve unexpectedly accepted its macro-step") + + after = _snapshot(simulation) + _require_same_snapshot(before, after, where="rejected IMEX attempt") + leaked = tuple(path for path in root.rglob("*") if path.is_file()) + if leaked: + raise RuntimeError( + "rejected IMEX attempt published files: %s" + % ", ".join(str(path) for path in leaked) + ) + return IMEXRejectedAttemptEvidence(message, before, after) + + def run_manual_and_restart(output_dir: Any) -> IMEXExecutionEvidence: """Run the manual Program, reopen output, restart fresh, then continue bit-identically.""" @@ -916,6 +1015,7 @@ def main(argv: list[str] | None = None) -> None: args = parser.parse_args(argv) output_dir = args.output_dir.resolve() + rejected = run_rejected_attempt_rollback(output_dir / "rejected") evidence = run_manual_and_restart(output_dir / "manual") preset = run_preset_parity(output_dir / "preset", evidence.accepted) restart_equal = _snapshots_bit_identical(evidence.accepted, evidence.restored) @@ -940,6 +1040,9 @@ def main(argv: list[str] | None = None) -> None: print("bit-identical restart: %s" % restart_equal) print("bit-identical continuation: %s" % continuation_equal) print("manual/pops.lib.time.IMEX parity: %s" % preset_equal) + print("rejected-attempt rollback: %s" % _snapshots_bit_identical( + rejected.before, rejected.after, + )) print( "regrid count: %d -> %d (topology epoch %d -> %d)" % ( @@ -957,6 +1060,10 @@ def main(argv: list[str] | None = None) -> None: "flux_ledger_levels": list(evidence.program_evidence.flux_ledger_levels), "levels": evidence.level_count, "manual_preset_bit_identical": preset_equal, + "rejected_attempt_error": rejected.error, + "rejected_attempt_rollback": _snapshots_bit_identical( + rejected.before, rejected.after, + ), "program_hash": preset.program_hash, "regrid_count": evidence.accepted.regrid_count, "regrid_count_after_continuation": evidence.restarted.regrid_count, diff --git a/examples/final/README.md b/examples/final/README.md index 1b097d9b3..5d65ed600 100644 --- a/examples/final/README.md +++ b/examples/final/README.md @@ -6,8 +6,8 @@ concern and no fallback to an older or lower-level API. [`EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py`](EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py) extends the same public lifecycle with an explicit additive IMEX tableau, typed field solves, -two-level subcycled AMR, conservative transfers and accepted-state consumers. Its matching -contract note is +two-level subcycled AMR, conservative transfers, globally reported AMR lowering coverage, an +executed rejected-attempt rollback proof and accepted-state consumers. Its matching contract note is [`docs/design/final-advection-imex-amr.md`](../../docs/design/final-advection-imex-amr.md). [`EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py`](EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py) From e49ab161e49f668bd88ee22538a1ab5e056986cc Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 06:51:00 +0200 Subject: [PATCH 3/4] tests: prove AMR coverage and rejected IMEX isolation --- .../final/test_imex_amr_final_example.py | 93 ++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/tests/python/examples/final/test_imex_amr_final_example.py b/tests/python/examples/final/test_imex_amr_final_example.py index aacf8f3a4..51e33f722 100644 --- a/tests/python/examples/final/test_imex_amr_final_example.py +++ b/tests/python/examples/final/test_imex_amr_final_example.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import importlib.util import json import os from pathlib import Path @@ -14,6 +15,15 @@ EXAMPLE = ROOT / "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py" +def _load_example(): + spec = importlib.util.spec_from_file_location("pops_final_imex_amr", EXAMPLE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> None: environment = dict(os.environ) environment["POPS_INCLUDE"] = str(ROOT / "include") @@ -33,6 +43,7 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non assert "bit-identical restart: True" in completed.stdout assert "bit-identical continuation: True" in completed.stdout assert "manual/pops.lib.time.IMEX parity: True" in completed.stdout + assert "rejected-attempt rollback: True" in completed.stdout assert "regrid count:" in completed.stdout report_line, = [line for line in completed.stdout.splitlines() if line.startswith("report: ")] report = json.loads(report_line.removeprefix("report: ")) @@ -40,6 +51,10 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non assert report["checkpoint_restart_bit_identical"] is True assert report["continuation_bit_identical"] is True assert report["manual_preset_bit_identical"] is True + assert report["rejected_attempt_rollback"] is True + assert report["rejected_attempt_error"].startswith( + "step attempt rejected during " + ) assert report["levels"] == 2 assert report["regrid_count"] >= 0 assert report["topology_epoch"] >= 0 @@ -53,6 +68,7 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non from pops.output import read_hdf5, read_npz, read_paraview output = tmp_path / "published" + assert not tuple((output / "rejected").rglob("*")) readers = {".h5": read_hdf5, ".npz": read_npz, ".vtu": read_paraview} for suffix, reader in readers.items(): # Scientific writers use the stable ``consumer__clock__step`` stem. Checkpoints are also @@ -64,6 +80,79 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non assert tuple(output.rglob("manual_restart*.npz")) +def test_resolved_amr_lowering_report_covers_every_executed_authority() -> None: + import pops + + example = _load_example() + target = example.build_final_case() + resolved = pops.resolve( + pops.validate(target.authoring.case), + layout=target.layout, + ) + coverage = resolved.lowering_coverage + amr_rows = tuple( + row for row in coverage.rows if row.source.startswith("amr-") + ) + assert amr_rows + assert all(row.disposition == "lowered" and row.targets for row in amr_rows) + + predicate_sources = { + row.source.rsplit(":", 1)[-1] + for row in amr_rows + if row.source.startswith("amr-tagging-predicate:") + } + assert predicate_sources == {"above", "any_of", "below", "gradient_above"} + source_families = { + row.source.split(":", 1)[0] + for row in amr_rows + } + assert { + "amr-bootstrap", + "amr-execution", + "amr-hierarchy", + "amr-regrid", + "amr-subcycling", + "amr-tagging-conflict-policy", + "amr-tagging-graph", + "amr-tagging-hysteresis", + "amr-tagging-predicate", + "amr-transfer-entry", + "amr-transfer-plan", + } <= source_families + transfer_targets = { + target + for row in amr_rows + for target in row.targets + if target.startswith("amr-runtime-transfer-operation:") + } + assert transfer_targets == { + "amr-runtime-transfer-operation:apply_transfer_provider:coarse_fine_fill", + "amr-runtime-transfer-operation:apply_transfer_provider:prolongation", + "amr-runtime-transfer-operation:apply_transfer_provider:restriction", + "amr-runtime-transfer-operation:apply_transfer_provider:temporal_interpolation", + "amr-runtime-transfer-operation:recompute:coarse_fine_fill", + } + assert any( + target == "amr-runtime-clock-relation:0-1:2/1" + for row in amr_rows + for target in row.targets + ) + + tagging = resolved.bootstrap_plan.tagging.inspect()["graph"] + assert tagging["refine"]["node_type"] == "any_of" + assert { + child["node_type"] for child in tagging["refine"]["children"] + } == {"above", "gradient_above"} + assert tagging["coarsen"]["node_type"] == "below" + assert tagging["hysteresis"] == { + "schema_version": 1, + "hysteresis_type": "min_cycles", + "min_cycles": 0, + "equality": "hold", + } + assert tagging["conflict_policy"] == "refine_wins" + + def test_normative_example_uses_only_the_final_root_lifecycle() -> None: source = EXAMPLE.read_text(encoding="utf-8") tree = ast.parse(source) @@ -95,5 +184,5 @@ def test_normative_example_uses_only_the_final_root_lifecycle() -> None: and node.func.value.id == "pops" and node.func.attr == "run" ] - # Accepted manual step, uninterrupted continuation, restarted continuation and preset parity. - assert len(root_run) == 4 + # Rejected proof, accepted manual step, both continuations and preset parity. + assert len(root_run) == 5 From 81f879afdf202309d564860e16a5da3d32d6c580 Mon Sep 17 00:00:00 2001 From: desp0042 Date: Sun, 2 Aug 2026 01:33:16 +0200 Subject: [PATCH 4/4] examples: persist IMEX AMR tagging state --- docs/design/final-advection-imex-amr.md | 26 +++++++++++-------- .../EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py | 22 +++++++++++++--- examples/final/README.md | 3 ++- python/pops/codegen/_amr_lowering_coverage.py | 6 ++++- .../final/test_imex_amr_final_example.py | 23 +++++++++++++++- 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/docs/design/final-advection-imex-amr.md b/docs/design/final-advection-imex-amr.md index 7d086927f..021e01201 100644 --- a/docs/design/final-advection-imex-amr.md +++ b/docs/design/final-advection-imex-amr.md @@ -30,10 +30,10 @@ Every fallible public solve returns an unreadable `SolveOutcome`. The example co `RejectAttempt()`. A failed solve therefore raises the typed native rejection signal before a field, state, diagnostic or output can read a partial result. The executable acceptance also compiles a separate negative case whose explicitly widened parameter domain makes the second IMEX diagonal -system exactly singular. It compares state, solved fields, hierarchy topology, Program -cache/history/clock/ledger registries and consumer cursors before and after the rejected attempt, -then requires that its output directory contain no file. The normal physical case retains the -strictly positive relaxation-rate domain. +system exactly singular. It compares state, solved fields, hierarchy topology, the canonical opaque +Program accepted-state image, Program cache/history/clock/ledger registries and consumer cursors +before and after the rejected attempt, then requires that its output directory contain no file. The +normal physical case retains the strictly positive relaxation-rate domain. `Model.field_operator(...)` declares the physical equation and its RHS providers. The sole callable time-Program authority is the `FieldHandle` returned by `Case.field(operator, discretization)`: both @@ -47,6 +47,7 @@ The AMR Program driver owns the accepted-state boundary. A hierarchy attempt sta - level state and clocks; - coarse/fine flux ledgers and reflux contributions; - history rings and their flux publications; +- persistent AMR tagging hysteresis state; - regrid-dependent synchronization state; - field materializations and consumer schedule cursors. @@ -64,14 +65,15 @@ The adaptive layout owns: subcycled execution; this is the installed provider's executable composite-field envelope; - strict above/below refinement and coarsening predicates; - a discrete gradient predicate resolved against the selected FV stencil; -- explicit hysteresis/equality/conflict semantics; +- two-cycle persistent hysteresis plus explicit equality/conflict semantics; - conservative state prolongation, restriction, coarse/fine fill and time interpolation; - elliptic recomputation after regrid instead of interpolating a stale solved field. Resolution adds each hierarchy, regrid, tagging predicate, hysteresis/conflict policy, transfer -entry, bootstrap authority and subcycling relation to the global `LoweringCoverageReport`. Every row -names a concrete runtime target; the report is therefore a machine-readable lowering gate rather -than an `inspect()` narrative inferred after compilation. +entry, bootstrap authority and subcycling relation to the global `LoweringCoverageReport`. The +non-zero hysteresis row names its Program accepted-state persistence route as well as the native +tagger. Every row therefore names a concrete runtime target; the report is a machine-readable +lowering gate rather than an `inspect()` narrative inferred after compilation. The acceptance target intentionally requests a regrid on every accepted macro-step. The first snapshot may still expose zero completed regrids: cadence is a due condition, not proof that a @@ -114,9 +116,11 @@ python examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py --output-dir /tm The command reopens the emitted HDF5 and ParaView files, retains a real accepted-state checkpoint and restarts a fresh bound simulation from it. It compares time, macro-step, every AMR level of every qualified conservative state and solved-field route, patch topology, Program/consumer identities and -consumer cursors bit-for-bit. The snapshot also carries the live completed-regrid count and topology -epoch, so checkpoint restore, uninterrupted/restarted continuation and manual/factory parity must -preserve exactly the same AMR generation evidence. It then advances the uninterrupted and restarted +consumer cursors bit-for-bit. It also compares the complete opaque Program accepted-state bytes, +which include the persistent tagging history without duplicating its native codec in Python. The +snapshot carries the live completed-regrid count and topology epoch, so checkpoint restore, +uninterrupted/restarted continuation and manual/factory parity must preserve exactly the same AMR +generation evidence. It then advances the uninterrupted and restarted instances once more, requires monotone counter/epoch evidence, verifies the accepted multi-level flux ledger plus reflux-then-average-down trace, and repeats the complete comparison before exercising the preset parity run. A printed success therefore follows a real rejected-attempt rollback, real I/O, diff --git a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py index 646588c47..27cab81cc 100644 --- a/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py +++ b/examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py @@ -98,6 +98,7 @@ def _bind_artifact(artifact: Any, **inputs: Any) -> Any: implicit_c=(Fraction(0), Fraction(1)), name="cn-heun-imex", ) +HYSTERESIS_MIN_CYCLES = 2 @dataclass(frozen=True, slots=True) @@ -148,6 +149,7 @@ class IMEXRuntimeSnapshot: regrid_count: int topology_epoch: int program_hash: str + program_accepted_state: bytes program_transaction_state: str consumer_graph_identity: str consumer_cursors: dict[str, Any] @@ -500,9 +502,13 @@ def build_layout(core: IMEXAMRAuthoring) -> Any: Coarsen(value < core.case.value(core.coarsen_value)), Buffer(cells=2), ), - # Equality is explicit. A non-zero temporal dwell requires a checkpointed per-cell tagging - # state provider; this example does not pretend that an in-memory counter is restart-safe. - hysteresis=Hysteresis(min_cycles=0, equality=EqualityPolicy.HOLD), + # Keep one full tagging cycle between opposite decisions. The native Program accepted-state + # image owns this sparse, topology-independent history, so rejection and strict restart + # restore the same hysteresis authority instead of resetting an in-memory Python counter. + hysteresis=Hysteresis( + min_cycles=HYSTERESIS_MIN_CYCLES, + equality=EqualityPolicy.HOLD, + ), conflict_policy=ConflictPolicy.REFINE_WINS, ) transfer = AMRTransfer() @@ -640,6 +646,9 @@ def _snapshot(simulation: Any) -> IMEXRuntimeSnapshot: } if any(count <= 0 for count in field_level_counts.values()): raise RuntimeError("IMEX acceptance installed an empty diagnostic-field hierarchy") + program_accepted_state = bytes(simulation.program_accepted_state()) + if not program_accepted_state: + raise RuntimeError("IMEX acceptance installed no canonical Program accepted-state image") regrid = simulation.amr.explain_regrid() return IMEXRuntimeSnapshot( time=float(simulation.time()), @@ -669,6 +678,7 @@ def _snapshot(simulation: Any) -> IMEXRuntimeSnapshot: regrid_count=int(regrid.regrid_count), topology_epoch=int(regrid.topology_epoch), program_hash=str(simulation.installed_program_hash()), + program_accepted_state=program_accepted_state, program_transaction_state=_program_transaction_state(simulation), consumer_graph_identity=simulation.consumer_graph.identity.token, consumer_cursors=simulation.consumer_cursors.to_data(), @@ -757,6 +767,10 @@ def _require_same_snapshot( "regrid_count": (left.regrid_count, right.regrid_count), "topology_epoch": (left.topology_epoch, right.topology_epoch), "program_hash": (left.program_hash, right.program_hash), + "program_accepted_state": ( + left.program_accepted_state, + right.program_accepted_state, + ), "program_transaction_state": ( left.program_transaction_state, right.program_transaction_state, @@ -1065,6 +1079,8 @@ def main(argv: list[str] | None = None) -> None: rejected.before, rejected.after, ), "program_hash": preset.program_hash, + "program_accepted_state_bytes": len(preset.program_accepted_state), + "tagging_hysteresis_min_cycles": HYSTERESIS_MIN_CYCLES, "regrid_count": evidence.accepted.regrid_count, "regrid_count_after_continuation": evidence.restarted.regrid_count, "runtime_steps": evidence.accepted.macro_step, diff --git a/examples/final/README.md b/examples/final/README.md index 5d65ed600..ee822e764 100644 --- a/examples/final/README.md +++ b/examples/final/README.md @@ -7,7 +7,8 @@ concern and no fallback to an older or lower-level API. [`EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py`](EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py) extends the same public lifecycle with an explicit additive IMEX tableau, typed field solves, two-level subcycled AMR, conservative transfers, globally reported AMR lowering coverage, an -executed rejected-attempt rollback proof and accepted-state consumers. Its matching contract note is +executed rejected-attempt rollback proof, persistent tagging hysteresis and accepted-state consumers. +Its matching contract note is [`docs/design/final-advection-imex-amr.md`](../../docs/design/final-advection-imex-amr.md). [`EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py`](EXEMPLE_SPEC_FINALE_15_MOMENTS_HYQMOM.py) diff --git a/python/pops/codegen/_amr_lowering_coverage.py b/python/pops/codegen/_amr_lowering_coverage.py index ec50408db..4b0227bfa 100644 --- a/python/pops/codegen/_amr_lowering_coverage.py +++ b/python/pops/codegen/_amr_lowering_coverage.py @@ -36,6 +36,10 @@ def amr_lowering_coverage( execution_identity = make_identity("amr-execution", execution.to_data()).token tagging = bootstrap.tagging tagging_target = "amr-runtime-tagging:%s" % tagging.qualified_id + hysteresis_targets = ["%s:hysteresis" % tagging_target] + if tagging.graph.hysteresis.min_cycles > 0: + hysteresis_targets.append( + "amr-runtime-program-accepted-state:tagging_hysteresis_state") rows = [ LoweringCoverageRow( @@ -56,7 +60,7 @@ def amr_lowering_coverage( LoweringCoverageRow( source="amr-tagging-hysteresis:%s" % tagging.qualified_id, disposition="lowered", - targets=("%s:hysteresis" % tagging_target,), + targets=tuple(hysteresis_targets), ), LoweringCoverageRow( source="amr-tagging-conflict-policy:%s" % tagging.qualified_id, diff --git a/tests/python/examples/final/test_imex_amr_final_example.py b/tests/python/examples/final/test_imex_amr_final_example.py index 51e33f722..7658dbb03 100644 --- a/tests/python/examples/final/test_imex_amr_final_example.py +++ b/tests/python/examples/final/test_imex_amr_final_example.py @@ -60,6 +60,8 @@ def test_example_runs_and_every_scientific_format_reopens(tmp_path: Path) -> Non assert report["topology_epoch"] >= 0 assert report["regrid_count_after_continuation"] >= report["regrid_count"] assert report["topology_epoch_after_continuation"] >= report["topology_epoch"] + assert report["program_accepted_state_bytes"] > 0 + assert report["tagging_hysteresis_min_cycles"] == 2 assert report["flux_ledger_levels"] == [0, 1] assert report["synchronization_phases"] == ["reflux", "average_down"] assert report["runtime_steps"] == 1 @@ -137,6 +139,14 @@ def test_resolved_amr_lowering_report_covers_every_executed_authority() -> None: for row in amr_rows for target in row.targets ) + hysteresis_row, = ( + row for row in amr_rows + if row.source.startswith("amr-tagging-hysteresis:") + ) + assert ( + "amr-runtime-program-accepted-state:tagging_hysteresis_state" + in hysteresis_row.targets + ) tagging = resolved.bootstrap_plan.tagging.inspect()["graph"] assert tagging["refine"]["node_type"] == "any_of" @@ -147,7 +157,7 @@ def test_resolved_amr_lowering_report_covers_every_executed_authority() -> None: assert tagging["hysteresis"] == { "schema_version": 1, "hysteresis_type": "min_cycles", - "min_cycles": 0, + "min_cycles": 2, "equality": "hold", } assert tagging["conflict_policy"] == "refine_wins" @@ -164,6 +174,17 @@ def test_normative_example_uses_only_the_final_root_lifecycle() -> None: assert "pops.run(simulation," in source assert ".run(**" not in source assert "BindInputs" not in source + assert "simulation.program_accepted_state()" in source + for forbidden in ( + "ProgramContext", + "AmrProgramContext", + "SystemStepper", + "_executor", + "_begin_step_transaction", + "_commit_step_transaction", + "_rollback_step_transaction", + ): + assert forbidden not in source assert source.count("case.program(") == 1 assert source.count("case.consumers(") == 1