From 01169861c620d939e58f92b66631ccd77c6b43a2 Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Thu, 17 Sep 2026 10:23:43 -0400 Subject: [PATCH] Validate compact gravity application probe records --- ...GRAVITY_COMPACT_APPLICATIONS_2026-09-17.md | 2 +- experiments/check_numerical_strategies.py | 62 ++++++++++++++- .../check_numerical_strategies_README.md | 29 ++++++- .../test_check_numerical_strategies.py | 77 +++++++++++++++++++ 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/experiments/GRAVITY_COMPACT_APPLICATIONS_2026-09-17.md b/experiments/GRAVITY_COMPACT_APPLICATIONS_2026-09-17.md index 5951634..fb98c02 100644 --- a/experiments/GRAVITY_COMPACT_APPLICATIONS_2026-09-17.md +++ b/experiments/GRAVITY_COMPACT_APPLICATIONS_2026-09-17.md @@ -20,7 +20,7 @@ The direct inverse capability recognizes the standard normalized physical precon `tests/solver/gravity_compact_applications.cpp` compares compact and explicit root-reference actions for nontrivial directions, nonuniform row/state factors, and both standard and optional-central-border typed forms. It checks factor refresh, stale root and child inverse rejection, unknown inverse fallback, and collective fallback when only rank zero supplies a noncanonical map. Existing generic tests retain empty-local-rank and true-defect failure coverage. -`plateau_gravity_application_diagnostics.hpp` supplies `plateau_diagnostics::ProbeGravityApplications(subsystem, outputDirectory)`. Call after preparing the full normalized operator, refreshing its inverse, and preparing/refreshing the subsystem. It writes `gravity_applications.csv` with three deterministic nontrivial global directions, selected paths, relative/absolute compact-versus-full errors, and separate application times. It neither prepares a candidate nor changes accepted state. The reference policy also allows direct solve comparisons outside the probe. +`plateau_gravity_application_diagnostics.hpp` supplies `plateau_diagnostics::ProbeGravityApplications(subsystem, outputDirectory)`. Call after preparing the full normalized operator, refreshing its inverse, and preparing/refreshing the subsystem. It writes `gravity_applications.csv` with three deterministic nontrivial global directions, selected paths, relative/absolute compact-versus-full errors, and separate application times. It neither prepares a candidate nor changes accepted state. The reference policy also allows direct solve comparisons outside the probe. `check_numerical_strategies.py` automatically checks this CSV when present; `--require applications` explicitly requires it. Its default-fixture operator/inverse comparison uses `error <= 2e-12 * max(1, reference_norm)` and rejects nonfinite measurements. Fallback paths are reported without failing selection. Recommended bounded real checks: run the helper once on the frozen n=1.5 fixture and once on a fixture with the central-density border; repeat the smaller viable fixture on two ranks. Require operator relative error near floating-point roundoff, then inspect inverse equivalence and timings separately. Run both compact and reference inner solves at the same true-defect tolerance before interpreting speed or iteration changes. The frozen strategy probe's existing inner report already carries operator/inverse application times; expose those alongside `UsesCompactOperator()` and `UsesCompactPreconditioner()` when recording campaign results. diff --git a/experiments/check_numerical_strategies.py b/experiments/check_numerical_strategies.py index 4ac8151..938877b 100644 --- a/experiments/check_numerical_strategies.py +++ b/experiments/check_numerical_strategies.py @@ -15,6 +15,7 @@ import sys EPSILON = sys.float_info.epsilon +APPLICATION_AGREEMENT_TOLERANCE = 2.0e-12 SIGN_STATUSES = {"certified_nonnegative", "unresolved", "negative", "non_finite"} @@ -139,6 +140,57 @@ class Assessment: ("accepted_physical_difference", "accepted_normalized_difference", "accepted_residual_difference", "restored_correction_difference"), ("restored_residual_difference",)) + def applications(self): + fields = ("direction", "compact_operator", "compact_inverse", + "operator_norm", "operator_error", "operator_relative_error", + "compact_operator_seconds", "root_operator_seconds", + "inverse_norm", "inverse_error", "inverse_relative_error", + "compact_inverse_seconds", "root_inverse_seconds") + rows = read_rows(self.folder / "gravity_applications.csv", fields) + outcome = {"recorded_directions": len(rows), "measurements": [], + "relative_tolerance": APPLICATION_AGREEMENT_TOLERANCE, + "absolute_floor": APPLICATION_AGREEMENT_TOLERANCE, + "inverse_equivalence_assumption": "Default independent material/gravity recipe; not a universal requirement for custom compact inverses"} + self.outcomes["gravity_applications"] = outcome + if not rows: + self.incomplete("gravity_applications_available", reason="No application directions recorded") + return + directions = set() + paths = set() + for row in rows: + direction = int(row["direction"]) + if direction < 0 or direction in directions: + raise ValueError("Gravity application direction indices must be unique and nonnegative") + directions.add(direction) + compact_operator = flag(row, "compact_operator") + compact_inverse = flag(row, "compact_inverse") + paths.add((compact_operator, compact_inverse)) + measurement = {"direction": direction, + "operator_path": "compact" if compact_operator else "root_restriction", + "inverse_path": "compact" if compact_inverse else "root_restriction"} + for kind in ("operator", "inverse"): + prefix = f"application_{direction}_{kind}" + scale, error, relative = (number(row, f"{kind}_{suffix}") for suffix in ("norm", "error", "relative_error")) + actual_seconds, reference_seconds = (number(row, f"{path}_{kind}_seconds") for path in ("compact", "root")) + self.check(f"{prefix}_finite_nonnegative", all(nonnegative(value) for value in + (scale, error, relative, actual_seconds, reference_seconds))) + # Match the real-FEM fixture's error/max(1, reference norm) + # comparison. Near a null action this is an absolute floor; + # a large reported relative error must not replace this gate. + tolerance = APPLICATION_AGREEMENT_TOLERANCE * max(1.0, scale) + self.check(f"{prefix}_same_action", nonnegative(scale) and nonnegative(error) and error <= tolerance, + error=error, reference_norm=scale, allowed=tolerance) + expected_relative = error / scale if scale > 0.0 else (0.0 if error == 0.0 else math.inf) + self.check(f"{prefix}_relative_error_arithmetic", nonnegative(relative) and + math.isclose(relative, expected_relative, rel_tol=1e-12, abs_tol=1e-30)) + measurement[kind] = {"reference_norm": scale, "error": error, "relative_error": relative, + "allowed": tolerance, "selected_seconds": actual_seconds, + "root_seconds": reference_seconds} + outcome["measurements"].append(measurement) + self.check("gravity_application_paths_consistent", len(paths) == 1) + outcome["compact_operator_directions"] = sum(flag(row, "compact_operator") for row in rows) + outcome["compact_inverse_directions"] = sum(flag(row, "compact_inverse") for row in rows) + def gravity(self): summary = metrics(self.folder / "numerical_strategy_summary.csv") self.restore(summary, ("accepted_state_change", "accepted_residual_change", "accepted_physical_change"), @@ -277,14 +329,20 @@ def assess(folder, required=(), expect_negative=False, restoration_tolerance=0.0 sections.add("polynomial") if (folder / "numerical_strategy_summary.csv").exists(): sections.add("gravity") + if (folder / "gravity_applications.csv").exists(): + sections.add("applications") if not sections: assessment.incomplete("probe_artifacts_available", reason="No recognized frozen strategy outputs") for section in sorted(sections): try: if section == "polynomial": assessment.polynomial(expect_negative) - else: + elif section == "gravity": assessment.gravity() + elif section == "applications": + assessment.applications() + else: + raise ValueError(f"Unknown required probe: {section}") except FileNotFoundError as error: assessment.incomplete(f"{section}_artifacts_complete", error=str(error)) except KeyError as error: @@ -302,7 +360,7 @@ def assess(folder, required=(), expect_negative=False, restoration_tolerance=0.0 def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("result_folder", type=Path) - parser.add_argument("--require", choices=("polynomial", "gravity"), action="append", default=[]) + parser.add_argument("--require", choices=("polynomial", "gravity", "applications"), action="append", default=[]) parser.add_argument("--expect-negative-base", action="store_true") parser.add_argument("--restoration-tolerance", type=float, default=0.0) args = parser.parse_args() diff --git a/experiments/check_numerical_strategies_README.md b/experiments/check_numerical_strategies_README.md index d46fe5d..f4edcb9 100644 --- a/experiments/check_numerical_strategies_README.md +++ b/experiments/check_numerical_strategies_README.md @@ -4,13 +4,16 @@ Run the checker on each completed frozen-probe directory: ```sh python3 experiments/check_numerical_strategies.py RESULT_FOLDER --require gravity +python3 experiments/check_numerical_strategies.py RESULT_FOLDER --require applications python3 experiments/check_numerical_strategies.py RESULT_FOLDER --require polynomial python3 experiments/check_numerical_strategies.py LATE_ROTATING_FOLDER --require polynomial --expect-negative-base python3 experiments/test_check_numerical_strategies.py ``` -Both sections are discovered automatically when their CSV files exist. Repeating -`--require` demands both sections. Exit 0 means the requested checks passed; +Polynomial, gravity-strategy, and gravity-application sections are discovered +automatically when their CSV files exist. Repeating `--require` demands each +named section. Older campaigns without `gravity_applications.csv` remain valid +unless `--require applications` is supplied. Exit 0 means the requested checks passed; 3 means an invariant or numerical expectation failed; 2 means evidence is incomplete. An honestly reported failed inner solve, unavailable trial, or unresolved certificate must not turn into a passing verification. Expected @@ -40,6 +43,26 @@ evidence rather than an automatic claim of a bad Jacobian: the retained-residual amplification of the remaining gravity error has not been bounded. The recorded roundoff scales are heuristics, not rigorous forward-error bounds. +When `gravity_applications.csv` is present, every recorded direction must satisfy +compact-versus-root normalized operator agreement and the default fixture's +inverse agreement. The gate matches the real-FEM test: +`error <= 2e-12 * max(1, reference_norm)`, a relative tolerance with an absolute +floor for small actions. Norms, absolute/relative errors, and both application +times must be finite and nonnegative; relative-error columns must also agree +with the absolute measurements. The reported compact/fallback flags must be +valid and consistent across this one prepared subsystem's directions. Fallback +is a valid reported outcome, including for custom recipes. An empty set of +directions or a missing explicitly required file is incomplete evidence. + +Inverse equality is an expectation for the default independent material/gravity +recipe used by these fixtures, not a mathematical requirement on every possible +custom compact preconditioner. A custom inverse deliberately different from the +root restriction needs its own comparison expectation; this checker must not +be cited as accepting that difference. Operator equality always remains required +because both paths must apply the same equations and normalization. The JSON +retains the selected paths and separate application times without imposing a +speedup threshold. + This script deliberately does not duplicate physical acceptance. For each end-to-end model, run `check_plateau_physics.py` on its endpoint. That separate gate requires recorded strict nonlinear convergence and all eight defined virial @@ -54,7 +77,7 @@ Use one numerical process at a time and preserve all capped failures as results. | Layer | Cases | Bound / acceptance | |---|---|---| | Algebra and lifecycle | Gravity subsystem, Schur reduction, polynomial certificate tests; one and two MPI ranks | 2 minutes per test group; true-defect, stale-state, empty-local, rollback, type-contract coverage | -| Python checker | Synthetic passing, false-convergence, mutation, missing-data, negative-base, and NaN records | 30 seconds; standard-library unit tests | +| Python checker | Synthetic passing, false-convergence, mutation, missing-data, negative-base, compact/fallback agreement, and NaN records | 30 seconds; standard-library unit tests | | Positivity frozen states | n=1 J=0.1 captured iteration 2 and iteration 11; a certified seed/control | 10 minutes per probe; iteration 11 must be negative and reject repair with `invalid_base`; no claim that the earlier base is positive before checking it | | Gravity curvature | n=1.5 J=0 captured iteration 1 | 15 minutes; alpha = 1, 0.1, 0.01, 0.001; gate fresh remainder cancellation and small-step order | | Reduced gravity | Same n=1.5 state, plus constrained n=3 saved seed direction | 15 minutes per probe; completion and three central-difference pairs; n=3 direction remains an approximate diagnostic correction | diff --git a/experiments/test_check_numerical_strategies.py b/experiments/test_check_numerical_strategies.py index 2cda822..6b7b775 100644 --- a/experiments/test_check_numerical_strategies.py +++ b/experiments/test_check_numerical_strategies.py @@ -80,6 +80,17 @@ def polynomial_fixture(folder): return certificates, repair, trials +def application_fixture(folder): + rows = [{"direction": index, "compact_operator": 1, "compact_inverse": 1, + "operator_norm": 5.0, "operator_error": 5e-15, "operator_relative_error": 1e-15, + "compact_operator_seconds": .001, "root_operator_seconds": .2, + "inverse_norm": 0.25, "inverse_error": 2.5e-16, "inverse_relative_error": 1e-15, + "compact_inverse_seconds": .004, "root_inverse_seconds": .01} + for index in range(3)] + write_rows(folder, "gravity_applications.csv", rows) + return rows + + class NumericalStrategyChecks(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() @@ -88,6 +99,72 @@ class NumericalStrategyChecks(unittest.TestCase): def tearDown(self): self.temporary.cleanup() + def test_application_evidence_is_discovered_and_passes(self): + application_fixture(self.folder) + report, code = assess(self.folder) + self.assertEqual(code, 0, report) + self.assertEqual(report["outcomes"]["gravity_applications"]["recorded_directions"], 3) + self.assertEqual(report["outcomes"]["gravity_applications"]["compact_operator_directions"], 3) + + def test_application_fallback_is_a_valid_outcome(self): + rows = application_fixture(self.folder) + for row in rows: + row["compact_operator"], row["compact_inverse"] = 0, 0 + write_rows(self.folder, "gravity_applications.csv", rows) + report, code = assess(self.folder, required=("applications",)) + self.assertEqual(code, 0, report) + outcome = report["outcomes"]["gravity_applications"] + self.assertEqual(outcome["compact_operator_directions"], 0) + self.assertEqual(outcome["compact_inverse_directions"], 0) + self.assertEqual(outcome["measurements"][0]["operator_path"], "root_restriction") + + def test_wrong_operator_or_default_inverse_application_fails(self): + for kind in ("operator", "inverse"): + with self.subTest(kind=kind): + rows = application_fixture(self.folder) + rows[0][f"{kind}_error"] = 1e-5 + rows[0][f"{kind}_relative_error"] = 1e-5 / rows[0][f"{kind}_norm"] + write_rows(self.folder, "gravity_applications.csv", rows) + report, code = assess(self.folder) + self.assertEqual(code, 3, report) + self.assertIn(f"application_0_{kind}_same_action", + [check["name"] for check in report["checks"] if check["status"] == "fail"]) + + def test_application_nan_timing_and_flags_cannot_pass(self): + for field, value in (("operator_error", float("nan")), ("inverse_relative_error", float("nan")), + ("root_operator_seconds", float("inf")), ("compact_inverse_seconds", -1), + ("compact_operator", "true")): + with self.subTest(field=field): + rows = application_fixture(self.folder) + rows[0][field] = value + write_rows(self.folder, "gravity_applications.csv", rows) + report, code = assess(self.folder) + self.assertEqual(code, 3, report) + json.dumps(report, allow_nan=False) + + def test_application_relative_error_must_match_absolute_measurement(self): + rows = application_fixture(self.folder) + rows[0]["operator_relative_error"] = 1e-6 + write_rows(self.folder, "gravity_applications.csv", rows) + report, code = assess(self.folder) + self.assertEqual(code, 3, report) + + def test_required_application_missing_or_empty_is_incomplete(self): + self.assertEqual(assess(self.folder, required=("applications",))[1], 2) + rows = application_fixture(self.folder) + write_rows(self.folder, "gravity_applications.csv", [], fields=list(rows[0])) + self.assertEqual(assess(self.folder, required=("applications",))[1], 2) + + def test_application_absolute_floor_handles_small_actions(self): + rows = application_fixture(self.folder) + for row in rows: + row["operator_norm"] = 1e-8 + row["operator_error"] = 1e-13 + row["operator_relative_error"] = 1e-5 + write_rows(self.folder, "gravity_applications.csv", rows) + report, code = assess(self.folder) + self.assertEqual(code, 0, report) + def test_complete_gravity_evidence_passes(self): gravity_fixture(self.folder) report, code = assess(self.folder, required=("gravity",))