313 lines
19 KiB
Python
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())
|