test: final source recheck for V1 jet CV and V3 Taylor-Green runs
Both runners now re-derive the recorded source identity after the run (source_provenance.final_source_check) and only then mark the receipt complete. A changed source, or a recheck that fails, rewrites the receipt as invalid and raises: - V1: in-run receipts carry run_status=INCOMPLETE; finish_receipt sets COMPLETE or INVALID_SOURCE_CHANGED (+ source_recheck/source_after). - V3: completed stays False, invalid=SOURCE_CHANGED_DURING_RUN, no screening_pass; the assessor requires source_recheck UNCHANGED (frozen mode still accepts receipts from before the field existed). Interrupted runs keep INCOMPLETE / completed=False. Host tests cover unchanged, changed and failed rechecks and incomplete runs. No threshold or solver change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8cae58cdf1
commit
df0f586331
7 files changed
+159
-15
No files matched your search
@@ -196,5 +196,40 @@ class Host(unittest.TestCase):
|
|||||||
self.assertTrue(record["src_sha256"])
|
self.assertTrue(record["src_sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_source_recheck_completes_or_invalidates_receipt(self):
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import run_jet_circle_cv as v
|
||||||
|
recorded = dict(source_mode="current_checkout", checkout_revision="a" * 40,
|
||||||
|
checkout_dirty=False, src_tree="b" * 40)
|
||||||
|
payload = dict(status="PILOT_DIAGNOSTIC_ONLY", run_status="INCOMPLETE", windows=[1, 2])
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = Path(d) / "re100_s1.json"
|
||||||
|
path.write_text(json.dumps(payload))
|
||||||
|
done = v.finish_receipt(path, payload, recorded, lambda: dict(recorded))
|
||||||
|
self.assertEqual(json.loads(path.read_text()), done)
|
||||||
|
self.assertEqual((done["run_status"], done["source_recheck"]), ("COMPLETE", "UNCHANGED"))
|
||||||
|
for recheck, after in ((lambda: dict(recorded, checkout_dirty=True), True),
|
||||||
|
(lambda: v.verify(Path(d)), None)):
|
||||||
|
path.write_text(json.dumps(payload))
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Source changed during the run"):
|
||||||
|
v.finish_receipt(path, payload, recorded, recheck)
|
||||||
|
bad = json.loads(path.read_text())
|
||||||
|
self.assertEqual((bad["run_status"], bad["source_recheck"]),
|
||||||
|
("INVALID_SOURCE_CHANGED", "CHANGED"))
|
||||||
|
self.assertEqual(bad["source_after"] is None, after is None)
|
||||||
|
self.assertEqual(bad["windows"], [1, 2])
|
||||||
|
self.assertFalse(Path(str(path) + ".tmp").exists())
|
||||||
|
|
||||||
|
def test_receipts_written_during_the_run_are_incomplete(self):
|
||||||
|
"""Static check (run_case needs CUDA): the in-run payload is INCOMPLETE and only
|
||||||
|
main() -> finish_receipt can mark it COMPLETE."""
|
||||||
|
import inspect
|
||||||
|
import run_jet_circle_cv as v
|
||||||
|
self.assertIn('run_status="INCOMPLETE"', inspect.getsource(v.run_case))
|
||||||
|
self.assertNotIn("COMPLETE\"", inspect.getsource(v.run_case).replace("INCOMPLETE\"", ""))
|
||||||
|
self.assertIn("finish_receipt(", inspect.getsource(v.main))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -111,3 +111,20 @@ def test_historical_files_reports_unreadable_blob_as_history_unavailable(tmp_pat
|
|||||||
with pytest.raises(HistoryUnavailable, match="blob not in this clone"):
|
with pytest.raises(HistoryUnavailable, match="blob not in this clone"):
|
||||||
source_provenance.historical_files(ROOT, ORIGIN, ["src/a.py"], tmp_path)
|
source_provenance.historical_files(ROOT, ORIGIN, ["src/a.py"], tmp_path)
|
||||||
assert not (tmp_path / "src/a.py").exists()
|
assert not (tmp_path / "src/a.py").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_source_check_unchanged_changed_and_failed_recheck():
|
||||||
|
from source_provenance import final_source_check
|
||||||
|
|
||||||
|
recorded = dict(revision="a" * 40, dirty=False)
|
||||||
|
assert final_source_check(recorded, lambda: dict(recorded)) == dict(source_recheck="UNCHANGED")
|
||||||
|
changed = final_source_check(recorded, lambda: dict(recorded, dirty=True))
|
||||||
|
assert changed == dict(source_recheck="CHANGED", source_after=dict(recorded, dirty=True),
|
||||||
|
source_recheck_error=None)
|
||||||
|
|
||||||
|
def fails():
|
||||||
|
raise RuntimeError("Source hash mismatch: src/x.py")
|
||||||
|
|
||||||
|
failed = final_source_check(recorded, fails)
|
||||||
|
assert (failed["source_recheck"], failed["source_after"]) == ("CHANGED", None)
|
||||||
|
assert "Source hash mismatch" in failed["source_recheck_error"]
|
||||||
@@ -244,6 +244,37 @@ def current_checkout(repo):
|
|||||||
for f in files})
|
for f in files})
|
||||||
|
|
||||||
|
|
||||||
|
def source_identity(repo, current):
|
||||||
|
"""Recorded source of a run; recomputed after the run by finish_receipt."""
|
||||||
|
if current:
|
||||||
|
provenance = dict(**checkout_provenance(repo), **current_checkout(repo))
|
||||||
|
if provenance["checkout_dirty"]:
|
||||||
|
raise RuntimeError("Current-checkout runs require a clean checkout")
|
||||||
|
return provenance
|
||||||
|
source = verify(repo)
|
||||||
|
return dict(source_experiment=source["experiment"], source_mode="pinned",
|
||||||
|
**checkout_provenance(repo))
|
||||||
|
|
||||||
|
|
||||||
|
def finish_receipt(path, payload, provenance, recheck):
|
||||||
|
"""Mark the receipt COMPLETE only if the source is unchanged; otherwise INVALID.
|
||||||
|
|
||||||
|
Receipts written during the run carry run_status="INCOMPLETE", so an interrupted
|
||||||
|
run never leaves a complete-looking receipt.
|
||||||
|
"""
|
||||||
|
from source_provenance import final_source_check
|
||||||
|
fields = final_source_check(provenance, recheck)
|
||||||
|
unchanged = fields["source_recheck"] == "UNCHANGED"
|
||||||
|
payload = dict(payload, **fields,
|
||||||
|
run_status="COMPLETE" if unchanged else "INVALID_SOURCE_CHANGED")
|
||||||
|
temporary = Path(str(path) + ".tmp")
|
||||||
|
temporary.write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
temporary.replace(path)
|
||||||
|
if not unchanged:
|
||||||
|
raise RuntimeError("Source changed during the run; receipt marked invalid")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def run_case(c, ctx, output, provenance, continuation=False):
|
def run_case(c, ctx, output, provenance, continuation=False):
|
||||||
from CelerisLab.body.geometry.circle import CircleGeometry
|
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||||
from CelerisLab.body.objects import SimObject
|
from CelerisLab.body.objects import SimObject
|
||||||
@@ -556,6 +587,7 @@ def run_case(c, ctx, output, provenance, continuation=False):
|
|||||||
case=c,
|
case=c,
|
||||||
**{"source_revision": PIN, **provenance},
|
**{"source_revision": PIN, **provenance},
|
||||||
status="PILOT_DIAGNOSTIC_ONLY",
|
status="PILOT_DIAGNOSTIC_ONLY",
|
||||||
|
run_status="INCOMPLETE", # final source recheck in main() completes it
|
||||||
windows=rows,
|
windows=rows,
|
||||||
physics_validation_pass=False,
|
physics_validation_pass=False,
|
||||||
precision="FP32 evolution, FP64 host diagnostics",
|
precision="FP32 evolution, FP64 host diagnostics",
|
||||||
@@ -638,16 +670,7 @@ def main():
|
|||||||
):
|
):
|
||||||
p.error("Root must select one verified idle GPU via CUDA_VISIBLE_DEVICES")
|
p.error("Root must select one verified idle GPU via CUDA_VISIBLE_DEVICES")
|
||||||
repo = a.repo.resolve()
|
repo = a.repo.resolve()
|
||||||
if a.current_checkout:
|
provenance = source_identity(repo, a.current_checkout)
|
||||||
provenance = dict(**checkout_provenance(repo), **current_checkout(repo))
|
|
||||||
if provenance["checkout_dirty"]:
|
|
||||||
raise RuntimeError("Current-checkout runs require a clean checkout")
|
|
||||||
else:
|
|
||||||
source = verify(repo)
|
|
||||||
provenance = dict(
|
|
||||||
source_experiment=source["experiment"], source_mode="pinned",
|
|
||||||
**checkout_provenance(repo)
|
|
||||||
)
|
|
||||||
sys.path.insert(0, str(repo / "src"))
|
sys.path.insert(0, str(repo / "src"))
|
||||||
import CelerisLab
|
import CelerisLab
|
||||||
|
|
||||||
@@ -663,7 +686,9 @@ def main():
|
|||||||
free, total = cuda.mem_get_info()
|
free, total = cuda.mem_get_info()
|
||||||
if free < max(1024, 2 * plan["estimated_peak_device_mib"]) * 1024**2:
|
if free < max(1024, 2 * plan["estimated_peak_device_mib"]) * 1024**2:
|
||||||
raise RuntimeError("Insufficient free device memory")
|
raise RuntimeError("Insufficient free device memory")
|
||||||
run_case(c, ctx, a.output, provenance, continuation=a.continuation)
|
payload = run_case(c, ctx, a.output, provenance, continuation=a.continuation)
|
||||||
|
finish_receipt(a.output / f"re{c['re_d']}_s{c['scale']}.json", payload, provenance,
|
||||||
|
lambda: source_identity(repo, a.current_checkout))
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
dict(status="COMPLETE_DIAGNOSTIC_ONLY", physics_validation_pass=False)
|
dict(status="COMPLETE_DIAGNOSTIC_ONLY", physics_validation_pass=False)
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ def checkout_provenance(repo):
|
|||||||
return dict(checkout_revision=revision, checkout_dirty=bool(dirty))
|
return dict(checkout_revision=revision, checkout_dirty=bool(dirty))
|
||||||
|
|
||||||
|
|
||||||
|
def final_source_check(recorded, recheck):
|
||||||
|
"""Re-derive the source identity after a run and compare it with the recorded one.
|
||||||
|
|
||||||
|
Returns receipt fields: source_recheck "UNCHANGED", or "CHANGED" with source_after
|
||||||
|
(None if the recheck itself failed) and the recheck error. Callers mark a receipt
|
||||||
|
complete only for "UNCHANGED"; a changed source invalidates the run.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
after = recheck()
|
||||||
|
except Exception as error: # a failed recheck cannot confirm the source
|
||||||
|
return dict(source_recheck="CHANGED", source_after=None,
|
||||||
|
source_recheck_error=f"{type(error).__name__}: {error}")
|
||||||
|
if after == recorded:
|
||||||
|
return dict(source_recheck="UNCHANGED")
|
||||||
|
return dict(source_recheck="CHANGED", source_after=after, source_recheck_error=None)
|
||||||
|
|
||||||
|
|
||||||
class HistoryUnavailable(RuntimeError):
|
class HistoryUnavailable(RuntimeError):
|
||||||
"""The clone cannot show the recorded commit (no git, shallow clone, installed copy)."""
|
"""The clone cannot show the recorded commit (no git, shallow clone, installed copy)."""
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ def assess(records, expected_revision=None, current=False):
|
|||||||
checks[label + " reference"] = (r.get("numerical_revision") == NUMERICAL_REVISION
|
checks[label + " reference"] = (r.get("numerical_revision") == NUMERICAL_REVISION
|
||||||
and "source_mode" not in r
|
and "source_mode" not in r
|
||||||
and r.get("reference_harness") == REFERENCE_HARNESS)
|
and r.get("reference_harness") == REFERENCE_HARNESS)
|
||||||
|
# Final source recheck (run_case); receipts from before it existed carry no field
|
||||||
|
# and are accepted only in the frozen mode.
|
||||||
|
recheck = r.get("source_recheck") if current else r.get("source_recheck", "UNCHANGED")
|
||||||
|
checks[label + " source_recheck"] = recheck == "UNCHANGED" and "invalid" not in r
|
||||||
checks[label + " numerics"] = r.get("numerics") == NUMERICS and r.get("initialization") == INITIALIZATION
|
checks[label + " numerics"] = r.get("numerics") == NUMERICS and r.get("initialization") == INITIALIZATION
|
||||||
runtime = r.get("runtime_source", {})
|
runtime = r.get("runtime_source", {})
|
||||||
package = Path(runtime.get("checkout", "")) / "src/CelerisLab"
|
package = Path(runtime.get("checkout", "")) / "src/CelerisLab"
|
||||||
|
|||||||
@@ -319,6 +319,17 @@ def main():
|
|||||||
),
|
),
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
from tests.validation.source_provenance import final_source_check
|
||||||
|
recheck = final_source_check(
|
||||||
|
dict(source=source, runtime_source=runtime),
|
||||||
|
lambda: dict(source=verify_source(repo, args.expected_revision,
|
||||||
|
current=args.current_checkout),
|
||||||
|
runtime_source=runtime_source(repo)))
|
||||||
|
report.update(recheck)
|
||||||
|
if recheck["source_recheck"] != "UNCHANGED":
|
||||||
|
report.update(invalid="SOURCE_CHANGED_DURING_RUN") # completed stays False
|
||||||
|
save()
|
||||||
|
raise RuntimeError("Source changed during the run; receipt marked invalid")
|
||||||
report.update(
|
report.update(
|
||||||
completed=True,
|
completed=True,
|
||||||
seconds=time.monotonic() - started,
|
seconds=time.monotonic() - started,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -322,6 +322,7 @@ class ProjectedPairsTests(unittest.TestCase):
|
|||||||
source_mode="current_checkout",
|
source_mode="current_checkout",
|
||||||
source_capture=run_case.CURRENT_CAPTURE)
|
source_capture=run_case.CURRENT_CAPTURE)
|
||||||
r["numerical_revision"], r["source_mode"] = None, "current_checkout"
|
r["numerical_revision"], r["source_mode"] = None, "current_checkout"
|
||||||
|
r["source_recheck"] = "UNCHANGED"
|
||||||
ok = assess(current, MOCK_REVISION, current=True)
|
ok = assess(current, MOCK_REVISION, current=True)
|
||||||
self.assertTrue(ok["checks"]["clean_current_checkout_source"])
|
self.assertTrue(ok["checks"]["clean_current_checkout_source"])
|
||||||
self.assertTrue(all(v for k, v in ok["checks"].items() if k.endswith(" reference")))
|
self.assertTrue(all(v for k, v in ok["checks"].items() if k.endswith(" reference")))
|
||||||
@@ -333,6 +334,23 @@ class ProjectedPairsTests(unittest.TestCase):
|
|||||||
self.assertFalse(assess(current, None, current=True)["checks"]["clean_current_checkout_source"])
|
self.assertFalse(assess(current, None, current=True)["checks"]["clean_current_checkout_source"])
|
||||||
self.assertTrue(assess(frozen, MOCK_REVISION)["checks"]["clean_a135252_numerical_source"])
|
self.assertTrue(assess(frozen, MOCK_REVISION)["checks"]["clean_a135252_numerical_source"])
|
||||||
|
|
||||||
|
def test_assessor_requires_unchanged_final_source(self):
|
||||||
|
frozen = records()
|
||||||
|
label = lambda r: f"{r['n']}/{r['depth']}/{r['explosion']} source_recheck"
|
||||||
|
self.assertTrue(all(assess(frozen, MOCK_REVISION)["checks"][label(r)] for r in frozen))
|
||||||
|
for mutate in (dict(source_recheck="CHANGED"), dict(invalid="SOURCE_CHANGED_DURING_RUN")):
|
||||||
|
bad = deepcopy(frozen)
|
||||||
|
bad[0].update(mutate)
|
||||||
|
out = assess(bad, MOCK_REVISION)
|
||||||
|
self.assertFalse(out["checks"][label(bad[0])])
|
||||||
|
self.assertFalse(out["passed"])
|
||||||
|
current = deepcopy(frozen)
|
||||||
|
for r in current:
|
||||||
|
r["source"] = dict(r["source"], source_mode="current_checkout")
|
||||||
|
r["numerical_revision"], r["source_mode"] = None, "current_checkout"
|
||||||
|
# Current-checkout receipts are new: a missing recheck is not accepted.
|
||||||
|
self.assertFalse(assess(current, MOCK_REVISION, current=True)["checks"][label(current[0])])
|
||||||
|
|
||||||
def test_explicit_opt_in_selector_and_dry_run_without_cuda(self):
|
def test_explicit_opt_in_selector_and_dry_run_without_cuda(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
output = Path(tmp) / "case.json"
|
output = Path(tmp) / "case.json"
|
||||||
@@ -393,7 +411,8 @@ class ProjectedPairsTests(unittest.TestCase):
|
|||||||
# Replace the GPU context and steppers before import. No CUDA module or flow is used.
|
# Replace the GPU context and steppers before import. No CUDA module or flow is used.
|
||||||
for early, depth, fail in ((False, 1, None), (False, 2, None), (True, 2, None),
|
for early, depth, fail in ((False, 1, None), (False, 2, None), (True, 2, None),
|
||||||
(True, 2, "resource"), (True, 2, "nonfinite"),
|
(True, 2, "resource"), (True, 2, "nonfinite"),
|
||||||
(True, 2, "probe_nonfinite")):
|
(True, 2, "probe_nonfinite"),
|
||||||
|
(False, 2, "changed"), (False, 1, "recheck_error")):
|
||||||
calls, selectors = [], []
|
calls, selectors = [], []
|
||||||
|
|
||||||
class FakeRun:
|
class FakeRun:
|
||||||
@@ -448,13 +467,29 @@ class ProjectedPairsTests(unittest.TestCase):
|
|||||||
patch.object(run_case, "runtime_identity", return_value=deepcopy(RUNTIME)), \
|
patch.object(run_case, "runtime_identity", return_value=deepcopy(RUNTIME)), \
|
||||||
patch.object(run_case, "build_capture", return_value=nullcontext()), \
|
patch.object(run_case, "build_capture", return_value=nullcontext()), \
|
||||||
patch.object(run_case, "metrics", side_effect=metric), redirect_stdout(io.StringIO()):
|
patch.object(run_case, "metrics", side_effect=metric), redirect_stdout(io.StringIO()):
|
||||||
if fail:
|
if fail in ("changed", "recheck_error"):
|
||||||
|
verify.side_effect = [source(), dict(source(), source_tree_sha256="d" * 64)
|
||||||
|
if fail == "changed" else RuntimeError("Wrong numerical source fingerprint")]
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Source changed during the run"):
|
||||||
|
run_case.main()
|
||||||
|
elif fail:
|
||||||
with self.assertRaises(MemoryError if fail == "resource" else FloatingPointError):
|
with self.assertRaises(MemoryError if fail == "resource" else FloatingPointError):
|
||||||
run_case.main()
|
run_case.main()
|
||||||
else:
|
else:
|
||||||
self.assertEqual(run_case.main(), 0)
|
self.assertEqual(run_case.main(), 0)
|
||||||
receipt = json.loads(output.read_text())
|
receipt = json.loads(output.read_text())
|
||||||
verify.assert_called_once_with(REPO, MOCK_REVISION, current=False)
|
rechecked = fail in (None, "changed", "recheck_error")
|
||||||
|
self.assertEqual(verify.call_args_list,
|
||||||
|
[call(REPO, MOCK_REVISION, current=False)] * (2 if rechecked else 1))
|
||||||
|
if fail in ("changed", "recheck_error"):
|
||||||
|
# Full trajectory, but invalid: never a complete-looking receipt.
|
||||||
|
self.assertEqual(receipt["source_recheck"], "CHANGED")
|
||||||
|
self.assertEqual(receipt["invalid"], "SOURCE_CHANGED_DURING_RUN")
|
||||||
|
self.assertFalse(receipt["completed"])
|
||||||
|
self.assertNotIn("screening_pass", receipt)
|
||||||
|
self.assertEqual(receipt["source_after"] is None, fail == "recheck_error")
|
||||||
|
continue
|
||||||
|
self.assertEqual(receipt.get("source_recheck"), None if fail else "UNCHANGED")
|
||||||
self.assertEqual(selectors, ["projected_linear" if early else "constant"] if depth == 2 else [])
|
self.assertEqual(selectors, ["projected_linear" if early else "constant"] if depth == 2 else [])
|
||||||
self.assertEqual(receipt["source"], source())
|
self.assertEqual(receipt["source"], source())
|
||||||
self.assertEqual(receipt["runtime_identity"], RUNTIME)
|
self.assertEqual(receipt["runtime_identity"], RUNTIME)
|
||||||
|
|||||||
Reference in new issue
Block a user