Files
MeanField/experiments/compare_shape_order_runs.py

313 lines
19 KiB
Python

#!/usr/bin/env python3
"""Summarize completed Q3/Q4 six-iteration-budget controls; standard library only.
Usage: compare_shape_order_runs.py Q3_ENDPOINT Q4_ENDPOINT --output FRESH.json
Each endpoint must have its sibling ENDPOINT_run.json completion/hash record.
The parent campaign provenance.json supplies the recorded mesh/source identities.
Exit 0 means comparable complete artifacts, including bounded solver failures;
exit 2 means missing, inconsistent or unsuitable inputs. No physical pass is implied.
"""
import argparse
import csv
import hashlib
import io
import json
import math
from pathlib import Path
import statistics
import sys
PHYSICAL = (
"mass", "mass_absolute_error", "volume", "minimum_mapping_determinant",
"enthalpy_pressure_integral", "enthalpy_virial_error", "enthalpy_virial_signed",
"enthalpy_force_virial_error", "enthalpy_force_virial_signed",
"eos_density_mismatch_rms", "eos_density_mismatch_rms_scaled",
"eos_density_mismatch_maximum", "eos_density_mismatch_maximum_scaled",
"minimum_density", "maximum_density", "negative_density_samples",
"negative_density_volume_fraction", "negative_density_mass_fraction",
"negative_density_mass_magnitude", "minimum_enthalpy", "negative_enthalpy_samples",
"bernoulli_raw_rms", "bernoulli_raw_rms_scaled", "hydrostatic_gradient_rms",
"gravity_gradient_vs_broken_potential_gradient_rms",
"pressure_from_density_defined_everywhere", "pressure_from_enthalpy_defined_everywhere",
"virial_error", "force_virial_error", "complete_volume_measurement",
"stellar_samples", "valid_stellar_samples", "invalid_stellar_samples",
"invalid_density_eos_samples", "invalid_enthalpy_eos_samples",
"normalized_unbordered_residual", "central_border", "polytropic_index", "polytropic_constant",
)
CONTROLS = (
"n", "K", "rho_c", "J", "M", "R_reference", "central_phase", "max_newton",
"restart", "max_linear", "linear_tol", "atol", "rtol", "strategy",
"gravity_tolerance", "gravity_iterations", "gravity_restart", "weak_shape_final",
"context_preconditioner_variant", "context_gravity_schur_sweeps",
"context_gravity_schur_relaxation",
)
WARNINGS = [
"Across-order normalized residual norms and coefficient spectra are not physical accuracy measures. "
"Only within-variant residual reductions are compared.",
"Q3 and Q4 have different surface sample/parameter spaces; raw nodal norms and unweighted harmonic "
"fits are not directly comparable surface integrals. Surface-mode audits are not consumed here.",
"q18 denotes the physical diagnostic integration-rule order, not a field order or converged quadrature limit. "
"P(h) energy/force virials are reported separately from density-based virials, which may be undefined.",
"q14 direction norms describe each endpoint's independently computed Newton direction, not the same "
"vector across orders. Density/potential gradients are broken element-local derivatives; jumps are excluded.",
"A failed endpoint verified_gate remains a failed linear observation; its direction norms do not describe "
"a direction satisfying the requested Newton forcing tolerance.",
"The affine representation check projects reference physical coordinates into the displacement space; "
"it does not certify that the production r^2 radial lift is affine.",
"Six configured iterations are a bounded comparison, not a convergence certificate. "
"Use check_plateau_physics.py for the existing solver/virial milestone gate.",
]
def require(condition, message):
if not condition:
raise ValueError(message)
def number(value, name, finite=True):
result = float(value)
require(not finite or math.isfinite(result), f"Nonfinite {name}")
return result
def integer(value, name):
result = number(value, name)
require(result.is_integer(), f"Noninteger {name}")
return int(result)
def close(a, b, name):
require(math.isclose(a, b, rel_tol=1e-10, abs_tol=1e-18), f"Inconsistent {name}: {a} vs {b}")
def pairs(rows, key="metric"):
result = {}
for row in rows:
name = row[key]
require(name and name not in result, f"Empty or duplicate key: {name!r}")
result[name] = row["value"]
return result
class Inputs:
def __init__(self, directory):
self.directory = directory.resolve()
self.hashes = {}
def text(self, path):
path = path.resolve()
data = path.read_bytes()
self.hashes[str(path)] = {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
return data.decode("utf-8")
def csv(self, name, columns):
reader = csv.DictReader(io.StringIO(self.text(self.directory / name)))
require(reader.fieldnames is not None and set(columns).issubset(reader.fieldnames),
f"Missing columns in {name}: {columns}")
require(len(reader.fieldnames) == len(set(reader.fieldnames)), f"Duplicate columns in {name}")
rows = list(reader)
require(all(None not in row and all(v is not None for v in row.values()) for row in rows),
f"Truncated or malformed row in {name}")
return rows
def physical(source, name, metadata):
rows = source.csv(name, ("quadrature", "metric", "value"))
selected = pairs([r for r in rows if integer(r["quadrature"], name) == 18])
missing = set(PHYSICAL) - selected.keys()
require(not missing, f"Missing q18 metrics in {name}: {sorted(missing)}")
values = {k: number(selected[k], k, finite=False) for k in PHYSICAL}
close(values["polytropic_index"], 1.5, name + " polytropic index")
close(values["polytropic_constant"], number(metadata["K"], "K"), name + " polytropic constant")
require(values["central_border"] == 0, f"Unexpected central border in {name}")
require(math.isfinite(values["normalized_unbordered_residual"]) and
values["normalized_unbordered_residual"] >= 0, f"Invalid residual in {name}")
# Undefined density-based quantities are observations, not silently zeroed errors.
unavailable = {k: str(v) for k, v in values.items() if not math.isfinite(v)}
return {"quadrature_order": 18, "metrics": {k: v if math.isfinite(v) else None for k, v in values.items()},
"unavailable_metrics": unavailable}
def summarize(directory, displacement_order):
source = Inputs(directory)
metadata = pairs([dict(zip(("metric", "value"), line.split("=", 1)))
for line in source.text(directory / "metadata.txt").splitlines() if line.strip()])
require(set(CONTROLS).issubset(metadata), "Missing run configuration metadata")
require(number(metadata["n"], "n") == 1.5 and number(metadata["J"], "J") == 0,
"Expected n=1.5, J=0")
require(integer(metadata["central_phase"], "central_phase") == 0, "Central phase must be off")
require(integer(metadata["max_newton"], "max_newton") == 6, "Expected a six-iteration budget")
require(metadata.get("converged") in ("0", "1"), "Missing completed solver outcome")
accepted = integer(metadata["accepted_steps"], "accepted_steps")
require(0 <= accepted <= 6, "Accepted-step count outside configured budget")
require(metadata["converged"] == "1" or metadata.get("failure"), "Missing solver-failure explanation")
runner_path = directory.parent / (directory.name + "_run.json")
runner = json.loads(source.text(runner_path))
require(runner.get("finished_utc") and runner.get("exit_code") in (0, 3),
"Runner incomplete, interrupted, or exited without a normal solver outcome")
require((runner["exit_code"] == 0) == (metadata["converged"] == "1"), "Runner/solver outcome mismatch")
provenance = json.loads(source.text(directory.parent / "provenance.json"))
provenance = {k: provenance[k] for k in ("commit", "branch", "source", "mesh", "mesh_sha256",
"binary_sha256", "runtime_environment")}
require(all(isinstance(provenance[k], str) and len(provenance[k]) == 64
for k in ("mesh_sha256", "binary_sha256")), "Missing recorded mesh/binary hashes")
fe = pairs(source.csv("weak_shape_physical_metadata.csv", ("key", "value")), key="key")
expected = {"density": 2, "enthalpy": 3, "potential": 2, "gravity_flux": 2,
"displacement": displacement_order}
for field, order in expected.items():
require(integer(fe[field + "_family_order"], field) == order, f"Unexpected {field} order")
for key, collection in {"reference_mesh_geometry_collection": "H1_3D_P4",
"displacement_collection": f"H1_3D_P{displacement_order}",
"surface_deformation_collection": f"H1_3D_P{displacement_order}"}.items():
require(fe[key] == collection, f"Unexpected {key}: {fe[key]}")
weak = {k: number(v, k, finite=False) for k, v in
pairs(source.csv("weak_shape_summary.csv", ("metric", "value"))).items()}
for key in ("accepted_buffers_bitwise_identical", "normalization_factors_bitwise_identical",
"direction_buffers_bitwise_identical"):
require(weak[key] == 1, f"Endpoint probe changed {key}")
require(weak["probe_exception"] == 0 and weak["accepted_commits"] == 0, "Incomplete or mutating endpoint probe")
layout = {kind: {} for kind in ("value", "residual")}
for row in source.csv("weak_shape_layout.csv", ("kind", "block", "offset", "size")):
kind, block = row["kind"], row["block"]
require(kind in layout and block not in layout[kind], "Invalid/duplicate layout block")
size = integer(row["size"], "block size")
require(size >= 0 and integer(row["offset"], "block offset") == sum(layout[kind].values()),
"Noncontiguous or negative block layout")
layout[kind][block] = size
require(all(sum(blocks.values()) == weak["state_size"] for blocks in layout.values()), "Layout size mismatch")
require(layout["value"]["surface_deformation"] == layout["residual"]["surface_shape_balance"],
"Surface parameter/equation count mismatch")
initial = physical(source, "initial_physical.csv", metadata)
final = physical(source, "final_physical.csv", metadata)
initial_f = initial["metrics"]["normalized_unbordered_residual"]
final_f = final["metrics"]["normalized_unbordered_residual"]
require(initial_f > 0, "Initial residual must be positive for this comparison")
close(final_f, weak["base_norm"], "final physical/probe residual")
history = source.csv("newton.csv", ("iteration", "accepted", "F", "alpha", "trials", "linear_status",
"linear_iterations", "linear_true", "seconds", "linear_seconds",
"prepare_seconds", "refresh_seconds", "linear_rhs_norm"))
steps = []
for i, row in enumerate(history):
require(integer(row["iteration"], "iteration") == i and i < 6, "Invalid iteration sequence")
step = {k: integer(row[k], k) for k in ("iteration", "accepted", "trials", "linear_status", "linear_iterations")}
require(step["accepted"] in (0, 1) and step["trials"] >= 0 and step["linear_iterations"] >= 0,
"Invalid iteration count/flag")
step.update({k: number(row[k], k) for k in ("F", "alpha", "seconds", "linear_seconds",
"prepare_seconds", "refresh_seconds")})
require(all(step[k] >= 0 for k in ("F", "seconds", "linear_seconds", "prepare_seconds", "refresh_seconds")),
"Negative residual/timing")
relative = number(row["linear_true"], "linear_true", finite=False)
step["linear_relative_true_defect"] = relative if math.isfinite(relative) else None
step["linear_relative_true_defect_unavailable"] = None if math.isfinite(relative) else str(relative)
if step["accepted"]:
require(0 < step["alpha"] <= 1 and step["linear_status"] == 0 and math.isfinite(relative),
"Accepted step lacks a successful finite linear report")
require(relative <= number(metadata["linear_tol"], "linear_tol") * (1 + 1e-10) + 1e-15,
"Accepted step exceeds the recorded linear tolerance")
else:
require(i == len(history) - 1, "Nonfinal rejected iteration")
steps.append(step)
require(sum(s["accepted"] for s in steps) == accepted, "History/metadata accepted count mismatch")
if steps:
close(steps[-1]["F"], final_f, "last recorded/final residual")
rhs = number(history[0]["linear_rhs_norm"], "first linear RHS", finite=False)
if math.isfinite(rhs):
close(rhs, initial_f, "initial residual/first linear RHS")
else:
close(initial_f, final_f, "zero-iteration endpoint residual")
direction = {}
physical_rows = source.csv("weak_shape_physical_summary.csv", ("quadrature", "field", "metric", "value"))
for row in physical_rows:
if integer(row["quadrature"], "direction quadrature") == 14:
field = direction.setdefault(row["field"], {})
require(row["metric"] not in field, "Duplicate q14 direction metric")
field[row["metric"]] = number(row["value"], "q14 direction metric")
for field in ("density", "specific_enthalpy", "gravity_potential"):
require(all(k in direction.get(field, {}) for k in
("pullback_l2_norm", "transport_l2_norm", "eulerian_l2_norm")), f"Missing q14 {field} norms")
require(all(k in direction.get("affine", {}) for k in ("relative_value_l2_error", "relative_gradient_l2_error")),
"Missing q14 affine errors")
squared = direction["geometry"]["direction_displacement_squared_integral"]
require(squared >= 0, "Negative displacement squared integral")
direction["geometry"]["direction_displacement_l2_norm"] = math.sqrt(squared)
for path, digest in source.hashes.items():
if Path(path).parent == source.directory:
require(runner["output_hashes"].get(Path(path).name) == digest["sha256"],
f"Runner hash missing/mismatched: {path}")
times = [s["seconds"] for s in steps]
endpoint = {k: weak[k] for k in ("state_size", "linear_status", "linear_iterations", "linear_seconds",
"verified_gate", "verified_relative_defect", "total_probe_seconds")}
endpoint_unavailable = {k: str(v) for k, v in endpoint.items() if not math.isfinite(v)}
return {"directory": str(source.directory), "input_files": source.hashes,
"recorded_campaign_provenance": provenance,
"controls": {k: metadata[k] for k in CONTROLS}, "orders": expected,
"finite_element_metadata": fe, "block_sizes": layout,
"outcome": {"runner_completed": True, "runner_exit_code": runner["exit_code"],
"converged": metadata["converged"] == "1", "failure": metadata.get("failure"),
"accepted_updates": accepted, "completed_six_updates": accepted == 6},
"newton": {"recorded_iterations": len(steps), "steps": steps,
"accepted_alphas": [s["alpha"] for s in steps if s["accepted"]],
"krylov_iterations_total": sum(s["linear_iterations"] for s in steps),
"iteration_seconds_total": sum(times),
"iteration_seconds_mean": statistics.mean(times) if times else None,
"iteration_seconds_median": statistics.median(times) if times else None,
"linear_seconds_total": sum(s["linear_seconds"] for s in steps),
"preparation_seconds_total": sum(s["prepare_seconds"] for s in steps),
"refresh_seconds_total": sum(s["refresh_seconds"] for s in steps),
"timing_scope": "AfterIteration records only; excludes initialization, final physical measurement "
"and endpoint weak-shape probe. Components are not asserted to partition wall time.",
"initial_normalized_residual": initial_f, "final_normalized_residual": final_f,
"final_over_initial_normalized_residual": final_f / initial_f},
"initial_physical_q18": initial, "final_physical_q18": final,
"endpoint_probe": {k: v if math.isfinite(v) else None for k, v in endpoint.items()},
"endpoint_probe_unavailable_metrics": endpoint_unavailable,
"endpoint_direction_q14": direction}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("q3", type=Path)
parser.add_argument("q4", type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
try:
require(args.q3.resolve() != args.q4.resolve(), "Q3 and Q4 must be distinct endpoint directories")
require(not args.output.exists(), "Output must be fresh")
require(not any(args.output.resolve().is_relative_to(p.resolve()) for p in (args.q3, args.q4)),
"Write the comparison outside the immutable endpoint directories")
q3, q4 = summarize(args.q3.resolve(), 3), summarize(args.q4.resolve(), 4)
require(q3["controls"] == q4["controls"], "Physics/solver controls differ beyond displacement order")
require(q3["recorded_campaign_provenance"]["mesh_sha256"] ==
q4["recorded_campaign_provenance"]["mesh_sha256"], "Recorded reference meshes differ")
require(q3["recorded_campaign_provenance"]["runtime_environment"] ==
q4["recorded_campaign_provenance"]["runtime_environment"], "Recorded runtime environments differ")
result = {"schema_version": 1, "artifact_comparison_complete": True,
"scope": "Recorded endpoint comparison; no FEM rerun or physical acceptance certificate",
"comparison": {"matched_maximum_newton_iterations": 6,
"equal_accepted_counts": q3["outcome"]["accepted_updates"] ==
q4["outcome"]["accepted_updates"],
"both_completed_six_updates": q3["outcome"]["completed_six_updates"] and
q4["outcome"]["completed_six_updates"]},
"warnings": WARNINGS, "q3": q3, "q4": q4}
# Deliberately omit an across-order normalized-residual ratio.
text = json.dumps(result, indent=2, allow_nan=False) + "\n"
with args.output.open("x") as stream:
stream.write(text)
print(f"Wrote {args.output}; accepted updates: Q3={q3['outcome']['accepted_updates']}, "
f"Q4={q4['outcome']['accepted_updates']}. Not a physical-validation certificate.")
return 0
except (OSError, ValueError, KeyError, TypeError) as error:
print(f"Comparison incomplete or unsuitable: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())