From f337652683750fc90dc8035c036116b0a5261248 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 00:28:46 +0000 Subject: [PATCH] test: shear-wave row validation, controlled-pass semantics, bounds - shear_wave: validate_rows before screens (exact scheduled steps/t*, no duplicates, all numeric/list fields finite, nonnegative error fields, rho_min>0; signed amplitude/phase/interval errors keep their sign); INVALID_RUN for invalid refined rows; own_screens_pass vs controlled_pass so a CONTROL_SCREEN_FAIL set never reports a controlled pass; empty or duplicate refined schemes rejected; pressure-budget redundancy stated. - run_case: SHEAR_WAVE_SHA256 repinned to the updated module. - tests: divergence/advection bounds use a(t) and the derived h^2 term; invalid-row mutations; signed errors allowed; controlled_pass checks. Co-Authored-By: Claude Opus 5.5 --- docs/validation/taylor_green.md | 22 +++-- tests/validation/taylor_green/run_case.py | 2 +- tests/validation/taylor_green/shear_wave.py | 96 +++++++++++++++---- .../taylor_green/test_shear_wave.py | 44 ++++++++- 4 files changed, 132 insertions(+), 32 deletions(-) diff --git a/docs/validation/taylor_green.md b/docs/validation/taylor_green.md index 5454106..8589c23 100644 --- a/docs/validation/taylor_green.md +++ b/docs/validation/taylor_green.md @@ -243,15 +243,25 @@ Run set: uniform N, uniform 2N, and refined depth-2 runs at root N. - velocity_l2 <= 0.05, mass <= 1e-5, spurious_pressure_scaled <= 0.02. - Budgets keep the `assess.py` form, `refined <= max(2*control_N, floor)`: - floors are 0.002 for rate and residual, 0.02 for pressure; - - because the pressure ceiling and floor are both 0.02, the comparative - pressure budget adds no constraint unless the root control exceeds 0.01; + - the comparative pressure budget is always redundant: + `max(2*control, 0.02)` is never below the 0.02 absolute ceiling; - raw ratios to both the root and finest controls, including raw pressure rms, are always reported. +- Every record is validated before any screen is applied: + - exact scheduled steps and t*, with no duplicates; + - every numeric field finite, including list fields and density; + - error quantities nonnegative and rho_min > 0; + - signed quantities (amplitudes, `amplitude_error`, `phase_drift`, + per-interval rate errors) keep their sign. - Outcomes are kept distinct: - - INVALID_CONTROL: a control is missing, incomplete or nonfinite, or the - records come from mixed sources; - - CONTROL_SCREEN_FAIL: complete, finite controls that fail a screen. - Refined results are still computed and retained. + - INVALID_CONTROL: a control or refined run is missing, a control is + incomplete or invalid, or the records come from mixed sources; + - INVALID_RUN: a refined record fails validation; + - CONTROL_SCREEN_FAIL: complete, valid controls fail their own screens. + Each refined run's `own_screens_pass` is still reported, but + `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). - Screens are never retuned. ### Limitations diff --git a/tests/validation/taylor_green/run_case.py b/tests/validation/taylor_green/run_case.py index d94eec6..b82c46a 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 = "49ab589d15d964d38ed90a49758e159c67a516191c7501c19fe2732e19742395" +SHEAR_WAVE_SHA256 = "4597cf1870c1a69b4525c231825a58cf601b3fe22efd2aecf8fca76601c972e8" 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 1e2a5e9..07d40a8 100644 --- a/tests/validation/taylor_green/shear_wave.py +++ b/tests/validation/taylor_green/shear_wave.py @@ -23,8 +23,8 @@ INITIALIZATION = ( # Per-row screens. phase_drift_abs, residual_l2 and the run-level rate screen # use eps(n) and are preregistered diagnostic choices, not error bounds. LIMITS = dict(velocity_l2=0.05, spurious_pressure_scaled=0.02, mass_relative=1e-5) -# With equal absolute ceiling and floor (0.02) the comparative pressure budget -# adds no constraint unless the root control exceeds 0.01; raw ratios are kept. +# The comparative pressure budget max(2*control, 0.02) is never below the 0.02 +# absolute ceiling, so it is always redundant; raw control ratios are kept. FLOORS = dict(rate_error=0.002, residual_l2=0.002, spurious_pressure_scaled=0.02) BASELINE_FACTOR = 2.0 @@ -175,13 +175,51 @@ def fit_rate(rows, length, viscosity): ) +NONNEGATIVE = ("tstar", "mass", "mass_relative", "amplitude_exact", "modal_amplitude", + "phase_drift_abs", "velocity_l2", "residual_l2", "longitudinal_l2", + "spurious_pressure_rms", "spurious_pressure_scaled", + "spurious_pressure_max_scaled", "rho_min", "rho_max", "max_speed") +SIGNED = ("a_hat", "b_hat", "amplitude_ratio", "amplitude_error", "phase_drift") + + +def _number(value): + return (isinstance(value, (int, float)) and not isinstance(value, bool) + and math.isfinite(value)) + + +def validate_rows(record): + """Exact schedule, finite numbers (incl. lists) and nonnegative error fields. + + Signed quantities (amplitudes, amplitude/phase errors) keep their sign. + Raises ValueError so callers can report an invalid run before any screen. + """ + n, rows = record["n"], record["rows"] + steps = [round(tstar * n / U) for tstar in TIMES] + if not isinstance(rows, list) or len(rows) != len(steps): + raise ValueError("rows do not match the scheduled samples") + for row, step in zip(rows, steps): + if type(row.get("step")) is not int or row["step"] != step: + raise ValueError(f"unscheduled or duplicate step {row.get('step')!r}") + if not _number(row.get("tstar")) or abs(row["tstar"] - step * U / n) > 3e-17: + raise ValueError(f"wrong sample time at step {step}") + if not all(_number(row.get(key)) for key in (*NONNEGATIVE, *SIGNED)): + raise ValueError(f"missing or nonfinite diagnostic at step {step}") + if any(row[key] < 0 for key in NONNEGATIVE) or row["rho_min"] <= 0: + raise ValueError(f"negative error quantity or density at step {step}") + levels = row.get("spurious_pressure_scaled_by_level") + if (not isinstance(levels, list) or len(levels) != record["depth"] + or not all(_number(v) and v >= 0 for v in levels)): + raise ValueError(f"invalid per-level pressure at step {step}") + + def summarize(record): + validate_rows(record) rows = record["rows"] n = record["n"] out = fit_rate(rows, n, record["viscosity"]) out.update( eps=eps(n), - complete=record["completed"] and len(rows) == len(TIMES), + complete=record["completed"] is True, residual_l2=max(r["residual_l2"] for r in rows), phase_drift_abs=max(r["phase_drift_abs"] for r in rows), spurious_pressure_scaled=max(r["spurious_pressure_scaled"] for r in rows), @@ -190,7 +228,10 @@ def summarize(record): velocity_l2=max(r["velocity_l2"] for r in rows), mass_relative=max(r["mass_relative"] for r in rows), ) - out["absolute_pass"] = out["complete"] and all( + if 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[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 @@ -199,36 +240,46 @@ def summarize(record): def _usable(record): try: out = summarize(record) - except (KeyError, ValueError): + except (KeyError, TypeError, ValueError): return None - finite = all(math.isfinite(v) for v in out.values() if isinstance(v, float)) - return out if out["complete"] and finite else None + return out if out["complete"] else None def assess(base_control, fine_control, refined): """Uniform root-spacing and finest-spacing controls versus refined runs. - INVALID_CONTROL: missing, incomplete, nonfinite or mixed-source controls. - CONTROL_SCREEN_FAIL: complete finite controls that fail a screen; refined - results are still computed and kept. + INVALID_CONTROL: missing, incomplete, invalid-row or mixed-source controls. + INVALID_RUN: a refined record has invalid rows (schedule, NaN, sign). + CONTROL_SCREEN_FAIL: complete valid controls that fail their own screens; + refined own-screen results are kept, but no controlled comparison passes. + PASS/FAIL: every refined run's controlled_pass (own screens and budgets). """ - if base_control is None or fine_control is None: - return dict(status="INVALID_CONTROL", reason="missing control") + if base_control is None or fine_control is None or not refined: + return dict(status="INVALID_CONTROL", reason="missing control or refined run") n = base_control["n"] if (base_control["depth"], fine_control["depth"], fine_control["n"]) != (1, 1, 2 * n): raise ValueError("controls must be uniform N and uniform 2N") if any(r["depth"] != 2 or r["n"] != n for r in refined): raise ValueError("refined runs must be depth 2 at root N") + if len({r.get("explosion") for r in refined}) != len(refined): + raise ValueError("duplicate refined explosion scheme") sources = {repr(r.get("source")) for r in [base_control, fine_control, *refined]} base, fine = _usable(base_control), _usable(fine_control) if base is None or fine is None or len(sources) != 1: return dict(status="INVALID_CONTROL", - reason="incomplete/nonfinite control or mixed source") - report = dict(base_control=base, fine_control=fine, refined={}, status="PASS") - if not (base["absolute_pass"] and fine["absolute_pass"]): - report["status"] = "CONTROL_SCREEN_FAIL" + reason="incomplete/invalid control or mixed source") + controls_pass = base["own_screens_pass"] and fine["own_screens_pass"] + report = dict(base_control=base, fine_control=fine, refined={}, + controls_pass=controls_pass) + statuses = [] for r in refined: - s = summarize(r) + try: + s = summarize(r) + except (KeyError, TypeError, ValueError) as error: + report["refined"][r["explosion"]] = dict(valid=False, reason=str(error)) + statuses.append("INVALID_RUN") + continue + s["valid"] = True s["budget"] = { key: max(BASELINE_FACTOR * base[key], floor) for key, floor in FLOORS.items() } @@ -238,8 +289,13 @@ def assess(base_control, fine_control, refined): for key in (*FLOORS, "spurious_pressure_rms")} for name, c in (("root", base), ("finest", fine)) } - s["pass"] = s["absolute_pass"] and s["budget_pass"] + s["controlled_pass"] = controls_pass and s["own_screens_pass"] and s["budget_pass"] report["refined"][r["explosion"]] = s - if report["status"] == "PASS" and not s["pass"]: - report["status"] = "FAIL" + statuses.append("PASS" if s["controlled_pass"] else "FAIL") + if "INVALID_RUN" in statuses: + report["status"] = "INVALID_RUN" + elif not controls_pass: + report["status"] = "CONTROL_SCREEN_FAIL" + else: + report["status"] = "PASS" if all(v == "PASS" for v in statuses) else "FAIL" return report diff --git a/tests/validation/taylor_green/test_shear_wave.py b/tests/validation/taylor_green/test_shear_wave.py index 99ad6fa..01ebacf 100644 --- a/tests/validation/taylor_green/test_shear_wave.py +++ b/tests/validation/taylor_green/test_shear_wave.py @@ -85,11 +85,13 @@ class ConventionTests(unittest.TestCase): # Central differences do not cancel exactly: each derivative carries # -(k_j h)^2/6 relative truncation, so div_h = -A cos(theta) h^2/6 sum_j E_j k_j^3 # + O(h^4) (nonzero for an oblique k). Bound it with that leading term plus roundoff. - trunc = U * h * h / 6 * float(np.abs(sw.E * k**3).sum()) * 1.01 + a = U * math.exp(-sw.decay_rate(N, NU) * t) + trunc = a * h * h / 6 * float(np.abs(sw.E * k**3).sum()) * 1.01 # Argument rounding: eps*max|x|/h*U*|k| ~ 2.2e-16*21.6/1e-3*0.03*0.44 ~ 6e-14. roundoff = 1e-13 self.assertLess(np.max(np.abs(dx[0] + dy[1])), trunc + roundoff) - self.assertLess(np.max(np.abs(z[0] * dx + z[1] * dy)), U * trunc + roundoff) + # Discrete advection: same truncation scaled by |u| <= a. + self.assertLess(np.max(np.abs(z[0] * dx + z[1] * dy)), a * trunc + roundoff) # Halving h reduces the divergence truncation about fourfold. hh = h / 2 div2 = ((f(x + hh, y, t) - f(x - hh, y, t))[0] + (f(x, y + hh, t) - f(x, y - hh, t))[1]) / (2 * hh) @@ -206,6 +208,8 @@ class MetricTests(unittest.TestCase): def record(n, depth, rows, explosion="constant", completed=True, source="s"): + for row in rows: + row["spurious_pressure_scaled_by_level"] = [row["spurious_pressure_scaled"]] * depth return dict(n=n, depth=depth, viscosity=U * n / sw.RE, rows=rows, completed=completed, explosion=explosion, source=source) @@ -215,7 +219,11 @@ def rows_for(n, rate_error=0.0, residual=1e-4, pressure=1e-3, phase=0.0): out = [] for tstar in TIMES: step = round(tstar * n / U) - out.append(dict(tstar=tstar, step=step, a_hat=U * math.exp(-rate * step), b_hat=0.0, + amp = U * math.exp(-rate * step) + out.append(dict(tstar=step * U / n, step=step, a_hat=amp, b_hat=0.0, + amplitude_ratio=1.0, modal_amplitude=amp, amplitude_exact=amp, + phase_drift=phase, mass=float(n * n), rho_min=0.999, rho_max=1.001, + max_speed=amp, longitudinal_l2=0.0, spurious_pressure_max_scaled=pressure, amplitude_error=0.0, residual_l2=residual, phase_drift_abs=phase, spurious_pressure_scaled=pressure, spurious_pressure_rms=pressure * U * U / 4, @@ -245,7 +253,9 @@ class AssessTests(unittest.TestCase): failing = record(128, 1, rows_for(128, residual=0.5)) # complete, > eps(128) report = sw.assess(failing, fine, [refined]) self.assertEqual(report["status"], "CONTROL_SCREEN_FAIL") - self.assertIn("constant", report["refined"]) # refined results preserved + own = report["refined"]["constant"] + self.assertTrue(own["own_screens_pass"]) # refined own screens kept + self.assertFalse(own["controlled_pass"]) # but no controlled comparison passes good = record(128, 1, rows_for(128)) self.assertEqual(sw.assess(None, fine, [refined])["status"], "INVALID_CONTROL") incomplete = record(128, 1, rows_for(128), completed=False) @@ -253,6 +263,29 @@ class AssessTests(unittest.TestCase): mixed = record(256, 1, rows_for(256), source="other") self.assertEqual(sw.assess(good, mixed, [refined])["status"], "INVALID_CONTROL") + def test_invalid_rows_are_rejected_before_screens(self): + base = record(128, 1, rows_for(128)) + fine = record(256, 1, rows_for(256)) + for mutate in ( + lambda rows: rows[2].update(rho_min=float("nan")), + lambda rows: rows[2].update(spurious_pressure_scaled_by_level=[0.0, float("nan")]), + lambda rows: rows[2].update(step=rows[1]["step"], tstar=rows[1]["tstar"]), + lambda rows: rows[2].update(residual_l2=-1e-6), + lambda rows: rows[2].pop("longitudinal_l2"), + ): + refined = record(128, 2, rows_for(128)) + mutate(refined["rows"]) + self.assertEqual(sw.assess(base, fine, [refined])["status"], "INVALID_RUN") + # Legitimate signs stay allowed: negative amplitude and phase errors. + refined = record(128, 2, rows_for(128)) + for row in refined["rows"]: + row.update(amplitude_error=-1e-3, phase_drift=-1e-4, phase_drift_abs=1e-4) + report = sw.assess(base, fine, [refined]) + self.assertEqual(report["status"], "PASS") + self.assertEqual(sw.assess(base, fine, [])["status"], "INVALID_CONTROL") + with self.assertRaises(ValueError): + sw.assess(base, fine, [record(128, 2, rows_for(128)), record(128, 2, rows_for(128))]) + def test_wrong_shapes(self): base = record(128, 1, rows_for(128)) fine = record(256, 1, rows_for(256)) @@ -307,7 +340,8 @@ def receipt(n, depth, explosion="constant"): steps = run_case.sample_steps(n)[0] rows = rows_for(n) for row, step in zip(rows, steps): - row.update(step=step, tstar=step * U / n) + row.update(step=step, tstar=step * U / n, + spurious_pressure_scaled_by_level=[row["spurious_pressure_scaled"]] * depth) cells = [n * n] if depth == 1 else [8704, 30720] hops = [16 * n * 2**level for level in range(depth)] builds = []