diff --git a/docs/validation/taylor_green.md b/docs/validation/taylor_green.md index 8589c23..9a4c222 100644 --- a/docs/validation/taylor_green.md +++ b/docs/validation/taylor_green.md @@ -262,6 +262,11 @@ Run set: uniform N, uniform 2N, and refined depth-2 runs at root N. `controlled_pass` is false, so it is never a controlled-comparison pass; - PASS/FAIL: every refined run's `controlled_pass` (own screens and budgets against valid, passing controls). + - Finite modal extinction (late R = 0) is a physical failure, not + malformed data. The run gets `rate_fit_valid=false` with null fit + quantities, and its other metrics are kept. Nothing is clipped and no + rate is invented. The result is CONTROL_SCREEN_FAIL for a control, or + FAIL for a refined run. - Screens are never retuned. ### Limitations diff --git a/tests/validation/taylor_green/run_case.py b/tests/validation/taylor_green/run_case.py index b82c46a..6f0369e 100644 --- a/tests/validation/taylor_green/run_case.py +++ b/tests/validation/taylor_green/run_case.py @@ -18,7 +18,7 @@ SOURCE_SHA256 = "a5dbfd804befce868067d79a23e5fd47dce7f505b3f9b1bb2145fb57cb5f217 BUILD_CAPTURE_SHA256 = "3dbaa6547cb0a4cb1541054f3b64ad8b6e18cc70527ac6166e56b944e9bd464a" REFERENCE_HARNESS = "11901a7f77557a901e017a171eeae1758d584fa5" INITIALIZATION = "physical FP64 equilibrium G at actual cell centers; rho=1+3p" -SHEAR_WAVE_SHA256 = "4597cf1870c1a69b4525c231825a58cf601b3fe22efd2aecf8fca76601c972e8" +SHEAR_WAVE_SHA256 = "81d5c8ee60864372887cddf9239580c82fb6750d956038f1e3ee9de61aa4e877" def flow_functions(flow="taylor_green"): diff --git a/tests/validation/taylor_green/shear_wave.py b/tests/validation/taylor_green/shear_wave.py index 07d40a8..ba3f669 100644 --- a/tests/validation/taylor_green/shear_wave.py +++ b/tests/validation/taylor_green/shear_wave.py @@ -155,15 +155,21 @@ def fit_rate(rows, length, viscosity): """ late = [r for r in rows if r["tstar"] >= FIT_FROM_TSTAR - 1e-12] amp = np.array([math.hypot(r["a_hat"], r["b_hat"]) for r in late]) - if len(late) < 3 or not (np.isfinite(amp).all() and (amp > 0).all()): - raise ValueError("need >= 3 late samples with finite positive R") + if len(late) < 3 or not np.isfinite(amp).all(): + raise ValueError("need >= 3 late samples with finite R") + exact_rate = decay_rate(length, viscosity) + to_tstar = length / U # root steps per unit t* + if not (amp > 0).all(): + # Finite modal extinction: a physical failure, not malformed data. No + # clipping and no invented rate; fit quantities are unavailable. + return dict(rate_fit_valid=False, rate=None, exact_rate=exact_rate, + rate_tstar=None, exact_rate_tstar=exact_rate * to_tstar, + rate_error=None, interval_rate_errors=None, log_fit_residual_max=None) t = np.array([r["step"] for r in late], dtype=float) (slope, intercept) = np.polyfit(t, np.log(amp), 1) residual = np.log(amp) - (slope * t + intercept) - exact_rate = decay_rate(length, viscosity) - to_tstar = length / U # root steps per unit t* return dict( - rate=float(-slope), exact_rate=exact_rate, + rate_fit_valid=True, rate=float(-slope), exact_rate=exact_rate, rate_tstar=float(-slope * to_tstar), exact_rate_tstar=exact_rate * to_tstar, # = 5 (2 pi)^2 / Re rate_error=abs(-slope / exact_rate - 1), @@ -228,10 +234,11 @@ def summarize(record): velocity_l2=max(r["velocity_l2"] for r in rows), mass_relative=max(r["mass_relative"] for r in rows), ) - if not all(_number(v) for v in (out["rate"], out["rate_error"], *out["interval_rate_errors"], - out["log_fit_residual_max"])): + if out["rate_fit_valid"] and not all( + _number(v) for v in (out["rate"], out["rate_error"], *out["interval_rate_errors"], + out["log_fit_residual_max"])): raise ValueError("nonfinite rate fit") - out["own_screens_pass"] = out["complete"] and all( + out["own_screens_pass"] = out["complete"] and out["rate_fit_valid"] and all( out[key] <= out["eps"] for key in ("rate_error", "phase_drift_abs", "residual_l2") ) and all(out[key] <= limit for key, limit in LIMITS.items()) return out @@ -280,12 +287,16 @@ def assess(base_control, fine_control, refined): statuses.append("INVALID_RUN") continue s["valid"] = True + # rate_error is None when a modal fit is unavailable; never a pass. s["budget"] = { - key: max(BASELINE_FACTOR * base[key], floor) for key, floor in FLOORS.items() + key: (None if base[key] is None else max(BASELINE_FACTOR * base[key], floor)) + for key, floor in FLOORS.items() } - s["budget_pass"] = all(s[key] <= s["budget"][key] for key in FLOORS) + s["budget_pass"] = all( + s[key] is not None and s["budget"][key] is not None and s[key] <= s["budget"][key] + for key in FLOORS) s["control_ratios"] = { - name: {key: (s[key] / c[key] if c[key] > 0 else None) + name: {key: (s[key] / c[key] if s[key] is not None and c[key] else None) for key in (*FLOORS, "spurious_pressure_rms")} for name, c in (("root", base), ("finest", fine)) } diff --git a/tests/validation/taylor_green/test_projected_pairs.py b/tests/validation/taylor_green/test_projected_pairs.py index 50b9f8b..2148478 100644 --- a/tests/validation/taylor_green/test_projected_pairs.py +++ b/tests/validation/taylor_green/test_projected_pairs.py @@ -319,7 +319,7 @@ class ProjectedPairsTests(unittest.TestCase): patch.object(run_case, "runtime_identity", side_effect=AssertionError("CUDA reached")), \ redirect_stdout(stdout): self.assertEqual(run_case.main(), 0) - verify.assert_called_once_with(REPO, MOCK_REVISION) + verify.assert_called_once_with(REPO, MOCK_REVISION, "taylor_green") self.assertTrue(json.loads(stdout.getvalue())["dry_run"]) self.assertFalse(output.exists()) @@ -412,7 +412,7 @@ class ProjectedPairsTests(unittest.TestCase): else: self.assertEqual(run_case.main(), 0) receipt = json.loads(output.read_text()) - verify.assert_called_once_with(REPO, MOCK_REVISION) + verify.assert_called_once_with(REPO, MOCK_REVISION, "taylor_green") self.assertEqual(selectors, ["projected_linear" if early else "constant"] if depth == 2 else []) self.assertEqual(receipt["source"], source()) self.assertEqual(receipt["runtime_identity"], RUNTIME) diff --git a/tests/validation/taylor_green/test_shear_wave.py b/tests/validation/taylor_green/test_shear_wave.py index 01ebacf..bfe2331 100644 --- a/tests/validation/taylor_green/test_shear_wave.py +++ b/tests/validation/taylor_green/test_shear_wave.py @@ -4,6 +4,7 @@ Run from this directory with PYTHONPATH=/src (same as test_oracle.py). """ import hashlib +import json import math import unittest from pathlib import Path @@ -286,6 +287,32 @@ class AssessTests(unittest.TestCase): with self.assertRaises(ValueError): sw.assess(base, fine, [record(128, 2, rows_for(128)), record(128, 2, rows_for(128))]) + def test_modal_extinction_is_a_failure_not_malformed(self): + def extinct(rows): + for row in rows[1:]: + row.update(a_hat=0.0, b_hat=0.0, modal_amplitude=0.0, amplitude_ratio=0.0, + amplitude_error=-1.0) + return rows + base = record(128, 1, rows_for(128)) + fine = record(256, 1, rows_for(256)) + refined = record(128, 2, extinct(rows_for(128))) + report = sw.assess(base, fine, [refined]) + self.assertEqual(report["status"], "FAIL") + own = report["refined"]["constant"] + self.assertTrue(own["valid"]) + self.assertFalse(own["rate_fit_valid"]) + self.assertIsNone(own["rate_error"]) + self.assertIsNone(own["control_ratios"]["root"]["rate_error"]) + self.assertFalse(own["own_screens_pass"] or own["controlled_pass"] or own["budget_pass"]) + self.assertEqual(own["residual_l2"], 1e-4) # other finite metrics retained + json.loads(json.dumps(report, allow_nan=False)) + control = sw.assess(record(128, 1, extinct(rows_for(128))), fine, + [record(128, 2, rows_for(128))]) + self.assertEqual(control["status"], "CONTROL_SCREEN_FAIL") + self.assertIsNone(control["refined"]["constant"]["budget"]["rate_error"]) + self.assertFalse(control["refined"]["constant"]["controlled_pass"]) + json.loads(json.dumps(control, allow_nan=False)) + def test_wrong_shapes(self): base = record(128, 1, rows_for(128)) fine = record(256, 1, rows_for(256))