From b73fa827ba2b00b3bb8f8543ef447fd41982f9ba Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 25 Oct 2026 01:17:27 -0400 Subject: [PATCH] Bind paper evaluator identity to transitive source helpers Refs #97. Bind the public evaluator static local source closure; preserve historical receipts and external qualification gates. --- MANIFEST.json | 19 ++++-- docs/paper_qualification_v1.md | 13 ++++ src/paper_qualification_v1.py | 2 +- src/parallel_source_v4/common.py | 15 ++++- tests/test_paper_evaluator_identity.py | 89 ++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 tests/test_paper_evaluator_identity.py diff --git a/MANIFEST.json b/MANIFEST.json index 359630a..002a805 100644 --- a/MANIFEST.json +++ b/MANIFEST.json @@ -3,7 +3,7 @@ "hash_algorithm": "SHA-256", "manifest_self_excluded": true, "source_only": true, - "file_count": 709, + "file_count": 710, "files": [ { "path": ".github/workflows/test.yml", @@ -747,8 +747,8 @@ }, { "path": "docs/paper_qualification_v1.md", - "bytes": 17392, - "sha256": "d45717c9b667356beb85f69b6f5765c0d3f4440cf1412d48b993c43e469c63cf" + "bytes": 18238, + "sha256": "4ee585115505b6adc0890c1c25e72431529fb66d4b1e1e7b35b296de8b41c40d" }, { "path": "docs/paper_relevance_packets.md", @@ -2312,8 +2312,8 @@ }, { "path": "src/paper_qualification_v1.py", - "bytes": 86186, - "sha256": "18438e75da7d230e23863f541ca681f393f55e7e84c8bbc9170ed397a7edb327" + "bytes": 86234, + "sha256": "8b17c4c41fe9f8dbe37cfba6395604fadd3c5231d497888376d0f902a2f545be" }, { "path": "src/paper_relevance_packets.py", @@ -2362,8 +2362,8 @@ }, { "path": "src/parallel_source_v4/common.py", - "bytes": 7156, - "sha256": "61dc7718ea930175da49b45de6a30a64a67b4c24deb68c6700c52f6668e77c1d" + "bytes": 8034, + "sha256": "799f09c06a706a98e90b5f6e150d86f76004fb8b1abff8ae78f425fbe7f0de2e" }, { "path": "src/parallel_source_v4/extraction.py", @@ -2590,6 +2590,11 @@ "bytes": 5407, "sha256": "72fdd0bb250e88f05b37b8260acf507bf07b128803737d58cae6aeac069bf4d4" }, + { + "path": "tests/test_paper_evaluator_identity.py", + "bytes": 4735, + "sha256": "d90950ce0cbe541329fb4ab7607930d8010686c185ed0f2ccaf3d72eeb2f012b" + }, { "path": "tests/test_paper_ingestion.py", "bytes": 3741, diff --git a/docs/paper_qualification_v1.md b/docs/paper_qualification_v1.md index 6deabb9..8bedfaa 100644 --- a/docs/paper_qualification_v1.md +++ b/docs/paper_qualification_v1.md @@ -40,6 +40,19 @@ cannot override these checks. The two primary bounds have at least 95% joint coverage under the preregistered binomial model. The existing exact binomial implementation is reused and its file is included in the evaluator identity. +The evaluator identity now expands the evaluator's own static local import +closure, including the qualification module's transitive helpers. Changing a +helper therefore changes the exact code map even when the evaluator entry file +is unchanged. Earlier code maps and receipts remain historical evidence; they +are not rebound to the new map. Evaluation requires a freshly approved +preregistration and execution identity matching the current source. + +This is a static source identity, not an isolated execution loader. It does not +establish that dynamic imports, module caches, bytecode or external verifier +releases execute those exact bytes. The separate external-verifier closure gate +in issue #97 remains HOLD until those boundaries and its real prerequisites are +verified. Statistical criteria and the evaluation record schema are unchanged. + Primary selection takes the first 400 eligible recent work IDs by the frozen `primary` rank. The next 600 form the recent screen; 600 historical works use `historical-screen`. All 1,200 screen outcomes must exist. Source-only tags select diff --git a/src/paper_qualification_v1.py b/src/paper_qualification_v1.py index 8d75937..f47ff7e 100644 --- a/src/paper_qualification_v1.py +++ b/src/paper_qualification_v1.py @@ -718,7 +718,7 @@ def _image_candidate(store, value, *, source, selected, original, reference, cod def evaluator_identity(): - code = method_hashes() + code = method_hashes(additional_roots=("src.paper_qualification_v1",)) path = Path(__file__).resolve() code[path.relative_to(path.parents[1]).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest() statistics = path.parents[1]/"trace_gc/qualification.py" diff --git a/src/parallel_source_v4/common.py b/src/parallel_source_v4/common.py index ab564ba..3b28ed1 100644 --- a/src/parallel_source_v4/common.py +++ b/src/parallel_source_v4/common.py @@ -69,16 +69,29 @@ def write_once(path: Path, value: object) -> str: return hashlib.sha256(payload).hexdigest() -def method_hashes(repo: Path = REPO) -> dict: +def method_hashes(repo: Path = REPO, *, additional_roots: tuple[str, ...] = ()) -> dict: """Hash local import closure, including the dynamically loaded frozen replay. Resolve imports statically so recording code never imports a worker/model or adopts its import-time data root. All v4 modules are seeds, including newly added metric modules. Function-local local imports are included as well. + Callers outside that default family must name their entry modules in + ``additional_roots`` so their own transitive imports are included. Explicit + roots must exist locally; unresolved ordinary imports may be dependencies + supplied by the separately recorded runtime. """ seeds = ["trace_gc.pdf_source_parallel_v4", "trace_gc.pdf_structure_parallel_v4", "src.abstract_validation_expanded.extraction"] seeds += ["src.parallel_source_v4."+p.stem for p in (repo/"src/parallel_source_v4").glob("*.py")] + if not isinstance(additional_roots, (tuple, list)): + raise ValueError("expected_local_module_roots") + for module in additional_roots: + if not isinstance(module, str) or not all(part.isidentifier() for part in module.split(".")): + raise ValueError("expected_local_module_root") + target = repo.joinpath(*module.split(".")) + if not target.with_suffix(".py").is_file() and not (target/"__init__.py").is_file(): + raise ValueError("missing_local_module_root:"+module) + seeds += list(additional_roots) pending, visited, snapshots = list(seeds), set(), {} while pending: module = pending.pop() diff --git a/tests/test_paper_evaluator_identity.py b/tests/test_paper_evaluator_identity.py new file mode 100644 index 0000000..38ccce6 --- /dev/null +++ b/tests/test_paper_evaluator_identity.py @@ -0,0 +1,89 @@ +"""Public source identity regressions; these are not empirical qualification.""" +import hashlib +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest.mock import patch + +from src import paper_qualification_v1 as evaluator +from src.parallel_source_v4.common import REPO, method_hashes + + +class AdditionalRootTests(unittest.TestCase): + def test_default_roots_do_not_adopt_unrequested_entrypoint(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + seed = root / "src/parallel_source_v4/metric.py" + seed.parent.mkdir(parents=True) + seed.write_text("VALUE = 1\n", encoding="utf-8") + extra = root / "authored_verifier.py" + extra.write_text("VALUE = 'HOLD'\n", encoding="utf-8") + before = method_hashes(root) + self.assertEqual(before, method_hashes(root, additional_roots=())) + extra.write_text("VALUE = 'PASS'\n", encoding="utf-8") + self.assertEqual(before, method_hashes(root)) + self.assertNotIn("authored_verifier.py", before) + selected = method_hashes(root, additional_roots=("authored_verifier",)) + self.assertEqual(set(selected) - set(before), {"authored_verifier.py"}) + + def test_explicit_roots_fail_closed_when_missing_or_invalid(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for module in ("absent.verifier", "../outside", "", "os.path"): + with self.subTest(module=module), self.assertRaises(ValueError): + method_hashes(root, additional_roots=(module,)) + + def test_scan_includes_packages_and_local_imports_without_execution(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + sources = { + "authored_verifier/__init__.py": "raise RuntimeError('must not import package')\n", + "authored_verifier/entry.py": ( + "from . import helper\n" + "def evaluate():\n from .nested import result\n" + "raise RuntimeError('must not execute verifier')\n"), + "authored_verifier/helper.py": "raise RuntimeError('must not import helper')\n", + "authored_verifier/nested.py": "raise RuntimeError('must not import nested helper')\n", + } + for relative, source in sources.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + hashes = method_hashes(root, additional_roots=("authored_verifier.entry",)) + self.assertEqual(set(hashes), set(sources)) + self.assertEqual(hashes, { + name: hashlib.sha256(source.encode()).hexdigest() for name, source in sources.items()}) + + +class EvaluatorClosureTests(unittest.TestCase): + def test_real_qualifier_helpers_are_bound_without_entrypoint_changes(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for directory in ("src", "trace_gc"): + shutil.copytree(REPO / directory, root / directory, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + entry = root / "src/paper_qualification_v1.py" + with patch.object(evaluator, "__file__", str(entry)), patch.object( + evaluator, "method_hashes", side_effect=lambda **kwargs: method_hashes(root, **kwargs)): + before = evaluator.evaluator_identity() + helpers = ("trace_gc/constraints.py", "trace_gc/plans.py", + "trace_gc/policy.py", "trace_gc/resolver.py") + self.assertTrue(set(helpers) <= set(before)) + self.assertIn("src/paper_qualification_v1.py", before) + for helper in helpers: + path = root / helper + original = path.read_bytes() + try: + path.write_bytes(original + b"\nraise RuntimeError('authored unexecuted mutation')\n") + after = evaluator.evaluator_identity() + self.assertEqual(before["src/paper_qualification_v1.py"], + after["src/paper_qualification_v1.py"]) + self.assertNotEqual(before[helper], after[helper]) + self.assertEqual({name for name in before if before[name] != after[name]}, {helper}) + finally: + path.write_bytes(original) + + +if __name__ == "__main__": + unittest.main()