Preserve completed weak-response study while retaining Q3 baseline

This commit is contained in:
2026-09-18 14:27:15 -04:00
parent 211be17190
commit 6ceda61bdd
3 changed files with 525 additions and 18 deletions

View File

@@ -9,7 +9,7 @@ that the Q3 displacement/Q4 reference geometry mismatch contributes to weak
response. Cancellation by itself is not evidence of a gauge or bad element orders. response. Cancellation by itself is not evidence of a gauge or bad element orders.
The baseline branch is `research/shape-field-weak-response-20260918`, based on The baseline branch is `research/shape-field-weak-response-20260918`, based on
`db854c0f7cdf5137e377273499f0ac5b6053b752`. The isolated order variant will be `db854c0f7cdf5137e377273499f0ac5b6053b752`. The isolated order variant is
`research/shape-field-displacement-q4-20260918`. Preserve original checkout, `research/shape-field-displacement-q4-20260918`. Preserve original checkout,
immutable executables, source revisions, mesh/input hashes and serial run records. immutable executables, source revisions, mesh/input hashes and serial run records.
@@ -62,7 +62,8 @@ comparison, not evidence of high-n/high-rotation convergence.
## Results ## Results
Baseline observations are recorded below. No production default has been changed on this baseline branch. The baseline branch retains Q3 displacement. The separate Q4 branch is an
isolated numerical experiment, not a recommendation to change the default.
### Reproducible baseline ### Reproducible baseline
@@ -91,7 +92,9 @@ symmetries, rank, and file binding on analytic fixtures.
The largest sampled singular response remains approximately 22.1–22.6. The SVD The largest sampled singular response remains approximately 22.1–22.6. The SVD
uses the six nonzero disjoint value-block pieces of each p, normalized by their uses the six nonzero disjoint value-block pieces of each p, normalized by their
coefficient norms. It is not a full-J spectrum or rank test. In particular, coefficient norms. The direction-dependent subspace changes between observations;
the comparison combines changes in the operator and the probe subspace. It is
not a full-J spectrum or rank test. In particular,
comparing the 12-step late state with six-step endpoints does not demonstrate a comparing the 12-step late state with six-step endpoints does not demonstrate a
causal benefit of the coupled correction: the matched six-step control has a causal benefit of the coupled correction: the matched six-step control has a
slightly stronger minimum response than the coupled endpoint. slightly stronger minimum response than the coupled endpoint.
@@ -105,16 +108,20 @@ Eulerian squared integrals is 1.69e-5, an integration sensitivity observation.
The radius observations expose a validation gap: the old late state has nodal The radius observations expose a validation gap: the old late state has nodal
radii 0.997674–1.010093, while its q18 P(h)-based virial error is 4.06909e-6. radii 0.997674–1.010093, while its q18 P(h)-based virial error is 4.06909e-6.
The fresh six-step control has q18 virial error 4.22590e-6 and reproduces the old The fresh six-step control has q18 virial error 4.22590e-6 and reproduces the old
six-step residual trajectory. Neither is converged or physically certified; six-step residual trajectory. Its sampled hydrostatic-gradient RMS increases
from 0.00152810 to 0.0106811 despite the lower residual and virial error.
Neither is converged or physically certified;
negative density samples remain. Virial success can coexist with appreciable negative density samples remain. Virial success can coexist with appreciable
nonrotating surface asphericity at these unfinished states. nonrotating surface asphericity at these unfinished states.
`q3_surface_modes_v1.json` contains unweighted fits, not surface-integral power. `q3_surface_modes_v1.json` contains unweighted fits, not surface-integral power.
The 12-step late direction is 99.9995107% cubic-invariant in sampled squared The 12-step late direction is 99.9995107% cubic-invariant in sampled squared
norm, but degree <=8 spherical harmonics leave 0.893679 of its nodal norm. norm, but degree <=8 spherical harmonics leave 0.893679 of its nodal norm.
Degree <=2 leaves 0.981646. Thus this is predominantly finer cubic-symmetric Degree <=2 leaves 0.981646. Thus most of the nodal direction is unresolved by
structure, not simple expansion, translation, or quadrupolar deformation. degree <=8 fits and is nearly cubic-invariant. This is consistent with
This is consistent with mesh/discretization coupling, without proving its cause. mesh/discretization coupling, without identifying its source: cubic symmetry
also exists in the bulk and gravity discretizations. These are not surface-area
spectra or evidence that the surface discretization alone causes the problem.
The affine control on the actual stellar mesh gives Q3 relative value error The affine control on the actual stellar mesh gives Q3 relative value error
9.01277e-5 and gradient error 1.23472e-3. This warrants the isolated Q4 comparison, 9.01277e-5 and gradient error 1.23472e-3. This warrants the isolated Q4 comparison,
@@ -148,13 +155,198 @@ This concerns that proposed replacement, not a proven deficiency of the current
mechanical Jacobian. Any boundary formulation requires a compatible trace/test mechanical Jacobian. Any boundary formulation requires a compatible trace/test
space and explicit rank/stability analysis. space and explicit rank/stability analysis.
An invertible transport coordinate/preconditioner change preserves roots and may An invertible linear/tangent coordinate or preconditioner change preserves the
improve linear algebra, but cannot itself change the exact Newton direction or exact mapped physical Newton correction at a fixed state. It can improve
remove a true weak physical response. Shape representation, step control, and coordinate-dependent singular values and inexact linear solves. A nonlinear
operator formulation remain distinct hypotheses. coordinate map or retraction can also change finite trial curvature; none of
these observations alone removes weak physical response. Shape representation,
step control, and operator formulation remain distinct hypotheses.
### Q4 intervention ### Q4 intervention
Pending on its isolated branch. Its rationale is to remove measured ambient Applied 2026-09-18 at approximately 17:45 UTC on
affine representation error and test the effect on the observed weak direction; `research/shape-field-displacement-q4-20260918`, branched from baseline `211be17`.
the additional surface modes and quadrature changes are explicit confounders. Changed `libmeanfield/interface/field/field_registry.cppm`,
`Displacement::vectorOrder`, from `3 + uniformPolynomialOrderIncrement` to
`4 + uniformPolynomialOrderIncrement`; configured uniform increment stays zero.
The dated source comment records the rationale. Eleven expectations and their
comments in six test files change to enforce the new displacement degree and
derived quadrature, while assertions for all other field orders stay fixed.
The purpose is to remove measured ambient affine representation error and test
its effect on the weak direction. The shared scalar surface increases from 866
to 1538 parameters, so this also adds shape modes. Derived pressure-force order
increases by one, gravity-force order by two, and geometry-inspection request
from 18 to 24, including the geometry-admissibility sampling; common physical
diagnostic quadratures remain 10/14 (weak-direction
observations) and 14/18 (state metrics). The surface metric, normalization and
preconditioner also change with their FE space. Equal relative linear tolerance
does not imply equal physical error or an identical line-search norm. The default
r-squared radial lift stays
unchanged and is not made globally affine by this ambient enrichment.
Use the separate `cmake-build-release-displacement-q4` directory with the same
LLVM 22.1.8, Release/IPO, MPI and library configuration. Rebuild all affected
modules and registered-space tests together. Q4 cases start from the analytic
seed; no coefficient snapshot is transferred between orders. The fixed
200-iteration linear budget and eta=0.03 remain unchanged. A 3600-second process
limit bounds each Q4 numerical case; no silent control relaxation is permitted.
### Completed Q3/Q4 comparison
Q4 source is `4be1a23c3ec2650af6a2a49528a8f5248cb001c3`. Its only production
change is the compile-time displacement degree at
`libmeanfield/interface/field/field_registry.cppm:163`; all instrumentation and
analysis remain in `experiments/`. No physics-facing API changed. Build and
dependency configuration values match the Q3 build. The Q4 regression campaign
passed 22 cases and 380,856 assertions: registry/quadrature contracts, field
spaces, curved geometry, surface/radial/domain Jacobians, Lane–Emden projection,
and prepared pressure/gravity force Jacobians. Cases, builds and full artifact
audits were serialized.
`q4_v1` contains the completed seed and live campaigns. The independent audit
`q4_audit_v1.json` passes 101,282 and 101,310 checks respectively, including raw
vector algebra, runner hashes, physical-integral identities, and exact buffer,
normalization, residual and Jacobian-action restoration. An artifact audit pass
does not turn a failed solve into a successful solve. The endpoint direction is
explicitly classified `finite_direction_failed_true_gate`.
The analytic seed has identical physical volume diagnostics in both variants.
Q4 reduces the ambient affine relative value error from 9.01277e-5 to
2.11629e-16 and gradient error from 1.23472e-3 to 3.24701e-15. Nevertheless,
the seed Newton direction's stellar displacement L2 grows from 0.00121967 to
0.0385749, a factor of 31.63. The seed linear solve grows from 26 to 67 iterations;
both meet the same independently checked eta=0.03 criterion. All three raw small
Q4 seed trials prepare and exhibit a quadratic residual remainder. This is not
evidence of a missing first-order derivative in the tested direction.
The fixed six-iteration-budget comparison gives:
| Measurement | Q3 displacement / surface | Q4 displacement / surface |
|---|---:|---:|
| Surface parameters | 866 | 1538 |
| Total root unknowns | 178073 | 178745 |
| Accepted updates | 6 | 3 |
| Termination | Nonlinear iteration limit | Fourth linear solve exhausted 200 iterations |
| First three accepted-step time | 269.31 s | 505.65 s |
| Residual after three updates / own initial residual | 0.056755 | 0.906188 |
| Final residual / own initial residual | 0.054710 | 0.906188 |
| All Newton-iteration time, including failed attempt | 547.34 s | 638.42 s |
| Outer Krylov iterations, including failed attempt | 354 | 492 |
| Endpoint probe time, excluded above | 93.07 s | 222.45 s |
| q18 P(h) potential-energy virial error | 4.22590e-6 | 8.89666e-5 |
| q18 P(h) force virial error | 2.71367e-6 | 8.27467e-6 |
| q18 mass error | 3.41685e-6 | 1.96178e-5 |
| q18 scaled EOS density mismatch RMS | 1.27948e-4 | 1.61156e-4 |
| q18 hydrostatic-gradient RMS | 0.0106811 | 0.325413 |
| q18 gravity versus broken potential-gradient RMS | 0.0121494 | 0.326881 |
| Negative density mass fraction | 2.89683e-5 | 4.19384e-5 |
These are differing stopped endpoints, not equal-accuracy or converged solutions.
The three-update comparison isolates the accepted-count difference in the table,
but all the previously listed order/space/quadrature/scaling confounders remain.
Iteration time excludes initialization, final physical measurements and the
diagnostic probe; total live-process times were 705.76 s and 944.98 s.
The stdlib helper `compare_shape_order_runs.py` binds the small comparison inputs
to runner hashes and reproduces `q3_q4_comparison_v1.json`.
Q3 accepts steps 1, 1/16, 1/16, 1/16, 1/32, 1/32. Q4 accepts approximately
0.1770, 0.04425, 0.04425. Its fourth linear solve and independently repeated cold
endpoint solve both stop after 200 iterations with relative full-J defect
0.0868564, above 0.03. The finite failed direction remains a directional
observation only. It is not used as an equally accurate Newton comparison.
Q4 also changes the observed physical character of the seed direction: the q14
Eulerian/pullback norm ratios for density, enthalpy and potential become
13.0825, 13.4289 and 13.2393. Thus the added deformation is not largely cancelled
by scalar transport, unlike the late Q3 direction. These are broken scalar
observations, not a complete distributional or RT transport analysis. The
largest q10/q14 relative change in Q4 endpoint Eulerian squared integrals is
4.35e-5; this is an integration sensitivity check, not discretization convergence.
`q4_surface_modes_v1.json` passes its independent angular analysis. At the stopped
Q4 endpoint, sampled radii span 0.927235–1.049839, or 12.2559% of their nodal
mean. The Q3 endpoint's observed span is 1.09849%. These are different nodal
sample sets, not common-point or full-surface extrema. In particular, zero
initial radius variation at Q4 nodes does not make the curved reference surface
an exact sphere: the same physical mesh sampled at Q3 nodes has a small nonzero
span. The Q4 seed direction's degree <=8 nodal fit leaves 0.982810 of its norm;
its sampled squared norm is 99.9999989% cubic-invariant. The corresponding failed
endpoint direction leaves 0.988475 of its norm. This remains fine angular
structure, without identifying which discretization component causes it.
The saved figure `surface_order_comparison_v2.png` uses a common color scale and
labels the different sample and accepted-update counts. Version 1 is retained
as an unused layout draft; version 2 fixes the caption/colorbar overlap.
### Conclusions and the next isolated hypotheses
**Do not promote the Q4-only change.** It repairs ambient affine representation
but worsens useful nonlinear progress, linear cost, interior gradient checks,
and potential-energy virial balance in this case. This rules out order matching
alone as a sufficient repair under the tested configuration. It does not show
that the Q3 representation error contributes nothing, nor that a compatible
enrichment of the other fields could not help.
The leading hypothesis is an unfavorable combination of surface freedom and
the resolving power of the coupled field equations. The observed directions
have strong fine angular structure, and adding surface freedom produced much
larger physical motion without useful residual reduction. Normalization and
preconditioning remain possible contributors; full-J residual accuracy does
not bound physical correction error for a weak operator. More Krylov work might
recover a linear solution, but the first three gate-passing Q4 directions already
gave poor accepted progress. No exact nullspace or discrete stability theorem
has been established for the present mechanical operator.
The next direct experiment should separate independent surface parameters from
ambient displacement interpolation. A fixed map `q=q0+B*a` with small smooth
bases of increasing degree can test this hypothesis around the same frozen
state. Inject physical variations and restrict the existing dual shape force
with `B^T`; the reduced blocks are the corresponding Galerkin restrictions.
Pull back the surface metric and preconditioning surrogate consistently, then
apply state/residual normalization. Do not transpose an already normalized value
basis and assume it is the physical dual restriction. A surface-mass-orthonormal
basis is a controlled choice. The deformation contract already separates
parameter count from boundary-displacement size; the default problem constructor,
PhysicalRiesz surface metric, and material-surface preconditioners still contain
nodal assumptions that must be changed together.
That reduced solve changes the approximation/test space. Its excluded original
shape forces and full original residual must remain visible. A frozen `q0`
retains its existing surface distortion; success there would justify a later
analytic-seed sequence with increasing surface resolution, not certify a smooth
physical root. Suggested first frozen bases use spherical degrees 2/4/6/8
(9/25/49/81 parameters). High-rotation work must demonstrate adequate shape
resolution rather than enforce a low-mode answer. This experiment has not been
implemented here.
Existing `experiments/README.md`, `gravity_completed_rigid_motion.cpp`, and the
surface-mode/reachability/frequency probes provide relevant mode construction
and gravity-completion infrastructure to reuse. The proposed addition is the
fully coupled restricted Newton solve with a consistent dual restriction and
excluded-force checks. Historical CSVs in the original checkout were not rerun
or incorporated into this controlled order comparison.
A separate extension test addresses the virial dilation mode. Q4 ambient fields
can represent the reference position X, but the production logical-radius-squared
extension E0 does not generate X from its surface trace b. At a retained logical
radius 1/8 vertex, lifting b gives only one eighth of the required affine value;
this remains true with Q4 interpolation. A fixed rank-one correction
`E(z)=E0(z)+(X-E0(b))*ell(z)`, with `ell(b)=1`, preserves the supplied trace and
gives `E(b)=X`. Its exact transpose is
`E0^T*f + ell^T*((X-E0(b))^T*f)`; its Jacobian is constant. With an interior
state displacement `u=E(z)`, direction `b+z` then generates current physical
dilation `X+u`. Reference-coordinate access, MPI weights, and adjoint virtual
work require explicit tests. Keep the fixed-infinity vacuum policy unchanged.
This would place the discrete dilation test in the mechanical force space; it
does not automatically enforce the potential-energy virial using the separate
mixed potential. The Q4 endpoint demonstrates why both independent virials are
needed. The rank-one lift also does not remove the observed fine angular modes,
so it is a separate structural control, not a presumed cure. It remains
unimplemented and must not be bundled with surface reduction in a causal test.
All reported states remain nonconverged and include negative density samples;
P(rho)-based virials are undefined there. The required 1e-5 virial milestone and
internal pointwise validation have not been achieved by this order intervention.
No claim about higher-n or rapidly rotating convergence follows from this study.

View File

@@ -0,0 +1,312 @@
#!/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())

View File

@@ -28,6 +28,8 @@ def main():
raise ValueError("This comparison requires n=1.5, J=0, and Q3 followed by Q4 displacement.") raise ValueError("This comparison requires n=1.5, J=0, and Q3 followed by Q4 displacement.")
with (directory / 'weak_shape_physical_surface_nodes.csv').open() as stream: with (directory / 'weak_shape_physical_surface_nodes.csv').open() as stream:
rows = list(csv.DictReader(stream)) rows = list(csv.DictReader(stream))
with (directory / 'newton.csv').open() as stream:
accepted = sum(int(row['accepted']) for row in csv.DictReader(stream))
points = np.array([[float(row['unit_radial_'+a]) for a in 'xyz'] for row in rows]) points = np.array([[float(row['unit_radial_'+a]) for a in 'xyz'] for row in rows])
radii = np.array([float(row['physical_radius']) for row in rows]) radii = np.array([float(row['physical_radius']) for row in rows])
if not np.isfinite(points).all() or not np.isfinite(radii).all() or np.any(radii <= 0): if not np.isfinite(points).all() or not np.isfinite(radii).all() or np.any(radii <= 0):
@@ -35,20 +37,21 @@ def main():
latitude = np.arcsin(np.clip(points[:, 2], -1, 1)) latitude = np.arcsin(np.clip(points[:, 2], -1, 1))
longitude = np.arctan2(points[:, 1], points[:, 0]) longitude = np.arctan2(points[:, 1], points[:, 0])
deviation = 100 * (radii / np.mean(radii) - 1) deviation = 100 * (radii / np.mean(radii) - 1)
data.append((longitude, latitude, deviation, len(rows))) data.append((longitude, latitude, deviation, len(rows), accepted))
limit = max(float(np.max(np.abs(row[2]))) for row in data) limit = max(float(np.max(np.abs(row[2]))) for row in data)
if limit == 0: if limit == 0:
limit = 1e-12 limit = 1e-12
fig, axes = plt.subplots(1, 2, figsize=(12, 5), subplot_kw={'projection': 'mollweide'}, layout='constrained') fig, axes = plt.subplots(1, 2, figsize=(12, 5), subplot_kw={'projection': 'mollweide'}, layout='constrained')
for axis, (longitude, latitude, deviation, count), title in zip(axes, data, ('Q3 displacement / surface', 'Q4 displacement / surface')): fig.get_layout_engine().set(rect=(0, .07, 1, .93))
for axis, (longitude, latitude, deviation, count, accepted), title in zip(axes, data, ('Q3 displacement / surface', 'Q4 displacement / surface')):
picture = axis.scatter(longitude, latitude, c=deviation, s=14, cmap='RdBu_r', vmin=-limit, vmax=limit, linewidths=0) picture = axis.scatter(longitude, latitude, c=deviation, s=14, cmap='RdBu_r', vmin=-limit, vmax=limit, linewidths=0)
axis.set_title(f'{title}\n{count} radial nodes') axis.set_title(f'{title}\n{accepted} accepted updates; {count} radial nodes')
axis.grid(True, alpha=.25) axis.grid(True, alpha=.25)
axis.tick_params(labelsize=8) axis.tick_params(labelsize=8)
fig.colorbar(picture, ax=list(axes), orientation='horizontal', shrink=.65, pad=.08, fig.colorbar(picture, ax=list(axes), orientation='horizontal', shrink=.65, pad=.08,
label='Radius relative to its unweighted nodal mean (%)') label='Radius relative to its unweighted nodal mean (%)')
fig.suptitle('Nonrotating n = 1.5: surface samples after the bounded order trial', fontsize=14) fig.suptitle('Nonrotating n = 1.5: surface samples after the bounded order trial', fontsize=14)
fig.text(.5, .01, 'Common color scale. Nodal observations only; neither endpoint is certified by this plot.', ha='center', fontsize=9) fig.text(.5, .01, 'Common color scale; different nodal sampling. Neither endpoint is certified by this plot.', ha='center', fontsize=9)
args.output.parent.mkdir(parents=True, exist_ok=True) args.output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(args.output, dpi=200) fig.savefig(args.output, dpi=200)
plt.close(fig) plt.close(fig)