Skip to content

Commit 5f7fbb7

Browse files
fix: bind resumable runs to material content fingerprints (#2)
1 parent bee85b1 commit 5f7fbb7

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

‎run_archive.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,39 @@ def _sha256_file(path: Path) -> str:
202202
return digest.hexdigest()
203203

204204

205+
def _material_fingerprints(materials: Sequence[Path]) -> List[Dict[str, str]]:
206+
"""Return the immutable path/content identity for local source materials."""
207+
return [
208+
{
209+
"path": str(path),
210+
"sha256": _sha256_file(path),
211+
}
212+
for path in materials
213+
]
214+
215+
216+
def _validate_material_fingerprints(object_job: ObjectJob, row: Dict[str, object]) -> None:
217+
"""Reject resume when effective material inputs differ from the persisted run."""
218+
expected = _material_fingerprints(object_job.materials)
219+
stored = row.get("material_fingerprints")
220+
if stored is None:
221+
if expected:
222+
raise RunnerError(
223+
"state lacks material fingerprints for %s; cannot safely resume this material-backed run; "
224+
"use a new input filename" % object_job.name
225+
)
226+
return
227+
if not isinstance(stored, list) or len(stored) != len(expected):
228+
raise RunnerError("state material association is inconsistent for %s" % object_job.name)
229+
for stored_item, expected_item in zip(stored, expected):
230+
if not isinstance(stored_item, dict):
231+
raise RunnerError("state material fingerprints are invalid for %s" % object_job.name)
232+
if str(stored_item.get("path") or "") != expected_item["path"]:
233+
raise RunnerError("state material association is inconsistent for %s" % object_job.name)
234+
if str(stored_item.get("sha256") or "") != expected_item["sha256"]:
235+
raise RunnerError("material content changed after this run started: %s" % expected_item["path"])
236+
237+
205238
def _dedupe(items: Iterable[object]) -> List[str]:
206239
seen = set()
207240
result: List[str] = []
@@ -341,6 +374,7 @@ def _new_state(job: JobFile) -> Dict[str, object]:
341374
"session_id": "",
342375
"archive": str(item.archive_path),
343376
"archive_sha256": "",
377+
"material_fingerprints": _material_fingerprints(item.materials),
344378
"verification_submissions": 0,
345379
"last_error": "",
346380
}
@@ -383,6 +417,7 @@ def load_or_create_state(job: JobFile) -> Dict[str, object]:
383417
raise RunnerError("state project association is inconsistent for %s" % item.name)
384418
if str(row.get("archive") or "") != str(item.archive_path):
385419
raise RunnerError("state archive association is inconsistent for %s" % item.name)
420+
_validate_material_fingerprints(item, row)
386421
return state
387422

388423

@@ -795,6 +830,7 @@ def _session_metadata(
795830
"language": job.language,
796831
"output_path": str(object_job.archive_path),
797832
"source_materials": [str(path) for path in object_job.materials],
833+
"source_material_fingerprints": list(item_state.get("material_fingerprints") or []),
798834
"runtime_materials": [str(item.get("runtime_path") or "") for item in staged_materials],
799835
"status": status,
800836
"archive_sha256": str(item_state.get("archive_sha256") or ""),
@@ -847,6 +883,7 @@ def process_object(
847883
verbose: bool,
848884
) -> None:
849885
"""Run or resume exactly one queue item until accepted or exhausted."""
886+
_validate_material_fingerprints(object_job, item_state)
850887
shell_state = _open_or_create_session(app, object_job, item_state)
851888
item_state["status"] = "running"
852889
item_state["session_id"] = shell_state.session_id
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Regression coverage for immutable local-material provenance.
2+
3+
This module reuses the offline import harness from ``test_run_archive_offline``
4+
so the contract remains deterministic and requires no Moonshine runtime, API
5+
credentials, network access, or model calls.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import sys
12+
import tempfile
13+
import unittest
14+
from pathlib import Path
15+
16+
import test_run_archive_offline as harness
17+
18+
19+
class MaterialProvenanceRegressionTests(unittest.TestCase):
20+
@classmethod
21+
def setUpClass(cls):
22+
cls.runner, cls._module_patcher = harness._load_runner_module()
23+
24+
@classmethod
25+
def tearDownClass(cls):
26+
sys.modules.pop(harness.RUNNER_MODULE_NAME, None)
27+
cls._module_patcher.stop()
28+
29+
def setUp(self):
30+
self._temporary_directory = tempfile.TemporaryDirectory()
31+
self.addCleanup(self._temporary_directory.cleanup)
32+
self.temp_root = Path(self._temporary_directory.name)
33+
self.task_dir = self.temp_root / "Creative-Intelligence"
34+
self.task_dir.mkdir()
35+
self._original_task_dir = self.runner.TASK_DIR
36+
self.runner.TASK_DIR = self.task_dir
37+
self.addCleanup(setattr, self.runner, "TASK_DIR", self._original_task_dir)
38+
39+
def _material_job(self):
40+
inputs = self.temp_root / "inputs"
41+
inputs.mkdir()
42+
material = inputs / "notes.md"
43+
material.write_text("original source material\n", encoding="utf-8")
44+
queue_path = inputs / "queue.json"
45+
queue_path.write_text(
46+
json.dumps(
47+
{
48+
"format": self.runner.FORMAT_ID,
49+
"language": "en",
50+
"objects": [
51+
{
52+
"name": "Bochner formula",
53+
"materials": ["notes.md"],
54+
}
55+
],
56+
},
57+
ensure_ascii=False,
58+
indent=2,
59+
)
60+
+ "\n",
61+
encoding="utf-8",
62+
)
63+
return material, queue_path
64+
65+
def test_new_state_records_resolved_material_path_and_sha256(self):
66+
material, queue_path = self._material_job()
67+
job = self.runner.load_job(queue_path)
68+
69+
state = self.runner.load_or_create_state(job)
70+
71+
self.assertEqual(
72+
state["objects"][0]["material_fingerprints"],
73+
[
74+
{
75+
"path": str(material.resolve()),
76+
"sha256": self.runner._sha256_file(material),
77+
}
78+
],
79+
)
80+
self.assertEqual(
81+
self.runner.load_or_create_state(self.runner.load_job(queue_path)),
82+
state,
83+
)
84+
85+
def test_state_rejects_material_content_mutation_after_run_started(self):
86+
material, queue_path = self._material_job()
87+
first_job = self.runner.load_job(queue_path)
88+
state = self.runner.load_or_create_state(first_job)
89+
self.assertEqual(state["status"], "pending")
90+
self.assertTrue(first_job.state_path.exists())
91+
92+
material.write_text("mutated source material\n", encoding="utf-8")
93+
resumed_job = self.runner.load_job(queue_path)
94+
95+
with self.assertRaisesRegex(
96+
self.runner.RunnerError,
97+
"material content changed after this run started",
98+
):
99+
self.runner.load_or_create_state(resumed_job)
100+
101+
def test_material_backed_legacy_state_without_fingerprint_fails_closed(self):
102+
_, queue_path = self._material_job()
103+
job = self.runner.load_job(queue_path)
104+
state = self.runner.load_or_create_state(job)
105+
state["objects"][0].pop("material_fingerprints")
106+
harness._stub_write_json(job.state_path, state)
107+
108+
with self.assertRaisesRegex(
109+
self.runner.RunnerError,
110+
"state lacks material fingerprints",
111+
):
112+
self.runner.load_or_create_state(self.runner.load_job(queue_path))
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()

0 commit comments

Comments
 (0)