From eb40406b16e1619f5d1a6e10970f1fed2dd4d012 Mon Sep 17 00:00:00 2001 From: lipluscodex <268560960+lipluscodex@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:11:11 +0900 Subject: [PATCH 1/2] fix: verify frozen sources at registered commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frozen artifact の hash 検証を current working tree から manifest 登録 commit または明示 source commit の exact blob bytes へ移しました。commit 存在、HEAD ancestor、manifest、path、hash を fail closed に検証し、既存の LF/CRLF portability は対象 protocol に限って維持します。 historical match、後続 repository evolution、manifest/registered blob tamper、未知 commit、欠落 path、非 ancestor commit の回帰を追加し、requirements、評価文書、Decision Structure の source-of-truth 境界を更新しました。Core と optional MCP を含む 231 tests および eval smoke が通過しています。 --- .github/workflows/ci.yml | 2 + docs/Decision-Structure.md | 1 + .../blind-llm-channel-selection-experiment.md | 2 +- ...e-backed-feedback-trajectory-experiment.md | 2 + docs/feedback-policy-comparison-evaluation.md | 2 + docs/historical-source-verification.md | 20 ++ docs/real-task-feedback-shadow-protocol-v3.md | 5 +- docs/requirements.md | 2 + ...ing-normalization-controlled-evaluation.md | 2 + src/neuron_graph_rag/blind_selection.py | 30 +-- .../canonical_gate_evaluation.py | 53 ++++-- src/neuron_graph_rag/corpus_integrity.py | 179 +++++++++++++++++- .../engine_feedback_trajectory.py | 77 ++++++-- .../feedback_policy_comparison_evaluation.py | 25 ++- .../fresh_native_feedback_evaluation.py | 64 +++++-- src/neuron_graph_rag/node_first_selection.py | 31 +-- src/neuron_graph_rag/real_task_shadow_v3.py | 29 +-- .../sibling_normalization_evaluation.py | 65 +++---- tests/test_blind_selection.py | 28 +++ tests/test_canonical_gate_evaluation.py | 37 +--- tests/test_corpus_integrity.py | 140 +++++++++++++- ...t_feedback_policy_comparison_evaluation.py | 12 +- tests/test_node_first_selection.py | 30 +++ tests/test_real_task_shadow.py | 9 +- tests/test_real_task_shadow_v2.py | 8 +- tests/test_real_task_shadow_v3.py | 4 +- 26 files changed, 676 insertions(+), 183 deletions(-) create mode 100644 docs/historical-source-verification.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4fdb4a..c885f8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: actions/setup-python@v7 with: python-version: "3.11" diff --git a/docs/Decision-Structure.md b/docs/Decision-Structure.md index 7b037d5..c6ebf49 100644 --- a/docs/Decision-Structure.md +++ b/docs/Decision-Structure.md @@ -51,6 +51,7 @@ edge target は Decision Structure node slug とする。外部資料は `Edges` - lowercase kebab-case の Decision Structure entry と `_Sidebar.md` は Wiki-only である。docs-to-Wiki synchronization は、`docs/` に対応物がないことを理由にこれらを create、overwrite、delete しない。 - 個別 Wiki entry は、その判断の current state の正本である。GitHub issue、pull request、commit、test output、fixture、gold、manifest、gate、result artifact は、それぞれの所有境界に従う根拠または契約であり、リンクしただけで Decision Structure entry にはならない。 - `requirements.md`、実験文書、fixture、gold、manifest、gate、result artifact は、既存の source-of-truth boundary を維持する。 +- frozen source / artifact hash の source of truth は、manifest 登録 commit または manifest が明示する source commit の exact blob bytes である。current working tree の同名 path は後続 evolution の現在状態であり、historical evidence ではない。 ## Lifecycle diff --git a/docs/blind-llm-channel-selection-experiment.md b/docs/blind-llm-channel-selection-experiment.md index 1ff12b2..15ad9b5 100644 --- a/docs/blind-llm-channel-selection-experiment.md +++ b/docs/blind-llm-channel-selection-experiment.md @@ -135,7 +135,7 @@ Development gateは不合格であり、`holdout_status=not_opened_invalid_judge PR #22の最初のLinux CI run `30731318651`では、Windowsでfreezeしたv1 text artifactのCRLF checkout byte hashと、GitHub ActionsのLF checkout byte hashが一致せず、`d1_liplus_channels.contamination.json`のfreeze auditで停止した。内容差ではなく、Gitのtext checkout変換だけが原因である。 -その後、mainから作成したfresh Windows worktreeでは、v2 judge prompt、development packet、judge artifactもLFの固定hashに対してCRLFでcheckoutされ、同じ内容のままmanifest読込とobserved artifact auditが停止した。verificationをv1だけに限定すると同じfrozen text契約をv2へ一貫して適用できないため、v1 / v2 frozen text-byte verificationを次の順序へ揃えた。 +その後、mainから作成したfresh Windows worktreeでは、v2 judge prompt、development packet、judge artifactもLFの固定hashに対してCRLFでcheckoutされ、同じ内容のままmanifest読込とobserved artifact auditが停止した。verificationをv1だけに限定すると同じfrozen text契約をv2へ一貫して適用できないため、v1 は manifest が明示する baseline commit、v2 は manifest 登録 commit の blob bytes を取得し、frozen text-byte verificationを次の順序へ揃えた。current working tree の同名 path は historical evidence に使わない。 1. checkout raw bytesのSHA-256を最初に照合する。 2. raw不一致時だけ、全改行がLF一種類ならCRLFへ、CRLF一種類ならLFへexact変換する。 diff --git a/docs/engine-backed-feedback-trajectory-experiment.md b/docs/engine-backed-feedback-trajectory-experiment.md index 5385858..f8da768 100644 --- a/docs/engine-backed-feedback-trajectory-experiment.md +++ b/docs/engine-backed-feedback-trajectory-experiment.md @@ -14,6 +14,8 @@ repository-native controlled corpus v3 に対し、実際の `NeuronGraphRAG` in - Audit: `tests/fixtures/engine_feedback_trajectory_v3.audit.json` - Manifest: `tests/fixtures/engine_feedback_trajectory_v3.manifest.json` +manifest artifact は manifest 登録 commit、source corpus は上記 source corpus commit の exact blob bytes を読む。各 commit の存在と current `HEAD` の ancestor 関係を検証し、current working tree の同名 fixture、source document、evaluator は historical evidence として扱わない。既存の raw-first LF / CRLF whole-file alternate だけを維持する。 + development は `signal-stability` と `boundary-recovery`、holdout は `evidence-continuity` を使う。node ID、document path、source URL、explicit-link edge は split 間で重複させない。edge は overview 文書に記載された同一 directory 内の相対 Markdown link だけから固定する。 result-free commit を push するまで development と holdout の runner を実行しない。exclusive output が存在しないことを audit に固定し、runner は既存 output の上書きを拒否する。 diff --git a/docs/feedback-policy-comparison-evaluation.md b/docs/feedback-policy-comparison-evaluation.md index 7cec4bb..a6401d4 100644 --- a/docs/feedback-policy-comparison-evaluation.md +++ b/docs/feedback-policy-comparison-evaluation.md @@ -10,6 +10,8 @@ protocol ID は `policycmp85-feedback-policy-comparison-v1` である。fixture、gold、query、cohort role、graph projection、engine config、event order、checkpoint、metric、gate array、result schema、exclusive writer、verifier、output path を observed result 不在の freeze commit に固定して push する。 +protocol artifact hash は manifest 登録 commit の exact blob bytes に対して照合し、検証後の fixture、gold、schedule、gate、schema、audit も同じ登録 blob から読む。current working tree の同名 evaluator、test、README、docs は後続 evolution であり、過去の protocol bytes として扱わない。source corpus は引き続き manifest の `source_commit` にある manifest と文書の exact bytes だけを読む。 + development は Amber を `confirmed-use`、Cobalt を `corrected-use` に割り当てる。全 hard gate 通過時だけ開く holdout は Quartz を `confirmed-use`、Willow を `corrected-use` に割り当てる。split 間では node、path、source URL、edge identity、query を共有しない。各 arm は同じ split の8文書、6 edge、query、event order を使う。 freeze 前の writer/verifier 検査は、登録 query、gold、node、output path と無関係な placeholder identity を temporary directory に exclusive create し、非アルファベット順 field の UTF-8 semantic round-trip を確認して削除する。登録 output は freeze 時に存在せず、writer は `O_EXCL` だけを使う。観測後の再実行、上書き、再集計、field reorder、query/gold/ratio/gate の変更を禁止する。 diff --git a/docs/historical-source-verification.md b/docs/historical-source-verification.md new file mode 100644 index 0000000..6a63060 --- /dev/null +++ b/docs/historical-source-verification.md @@ -0,0 +1,20 @@ +# Historical source verification + +## 目的 + +frozen evaluation の hash registry は、過去の protocol を固定した時点の bytes を証明する。後続の正当な repository evolution を拒否せず、現在の working tree を過去の evidence と取り違えない。 + +## Source-of-truth boundary + +- manifest 内の artifact hash は、その manifest の登録 commit にある `git show :` の exact bytes と照合する。登録 commit は current manifest path を最後に変更した commit とし、current manifest bytes 自体がその commit と一致することを先に検証する。 +- manifest が source commit、baseline commit、prior commit を明示する source registry は、その明示 commit の blob を照合する。 +- commit object が存在し、現在の `HEAD` の ancestor であることを必須とする。未知 commit、非 ancestor commit、欠落 path、hash 不一致、working-tree manifest 改変は fail closed にする。 +- 同名 path の current working-tree bytes は historical evidence ではない。検証後に evaluator が frozen fixture、gold、schedule、gate、source document を読む場合も、検証済み commit blob を使う。 + +## Newline contract + +既存 protocol が raw-first の LF / CRLF portability を明記している場合だけ、raw blob hash 不一致後に whole-file LF -> CRLF または CRLF -> LF の一回の exact 変換を許す。本文差、mixed newline、bare CR、その他の byte 差は許可しない。明記のない registry は exact raw bytes だけを受理する。 + +## 非変更範囲 + +この境界修正は frozen manifest、fixture、packet、snapshot、observed result、登録 hash、source commit、観測回数、metric、gate、解釈を変更・再実行・再集計しない。変更するのは hash verifier が bytes を取得する時点だけである。 diff --git a/docs/real-task-feedback-shadow-protocol-v3.md b/docs/real-task-feedback-shadow-protocol-v3.md index 007d85b..64d3bb6 100644 --- a/docs/real-task-feedback-shadow-protocol-v3.md +++ b/docs/real-task-feedback-shadow-protocol-v3.md @@ -35,7 +35,8 @@ local exact verifier は明示された SQLite snapshot を必要とする。liv repository lifecycle audit は repository 内 artifact だけを使い、snapshot replay を担当しない。次を検証する。 -- protocol / legacy frozen artifact hash +- v3 manifest 登録 commit にある protocol / legacy frozen artifact の exact blob hash +- 登録 commit の存在と current `HEAD` の ancestor 関係、および current manifest bytes と登録 manifest bytes の一致 - packet と aggregate の canonical JSON bytes - root slot の連番、correction chain の到達可能性、immutable correction field - effective packet 全体で共通の snapshot hash、capture config、search surface と、slot 順に厳密増加する capture timestamp @@ -59,3 +60,5 @@ python tools/run_real_task_shadow_v3.py audit-lifecycle --manifest tests/fixture ``` `probe` は実 MCP adapter search から placeholder packet、snapshot exact verification、two-arm replay、one-time result までを一時 directory 内で一巡し、registered output を repository に生成しない。`audit-lifecycle` は snapshot を使わず、freeze 後の repository lifecycle state を検証する。 + +同名 path の current working tree は repository evolution の現在状態であり、v1 / v2 / v3 の historical evidence ではない。lifecycle audit は manifest、packet、aggregate、gate、観測結果を変更せず、hash 取得元だけを登録 commit の blob に固定する。 diff --git a/docs/requirements.md b/docs/requirements.md index 39b19f4..3083748 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -98,6 +98,7 @@ 79. repository-native controlled corpus v3 の engine-backed trajectory experiment は、source commit、split / cluster identity、explicit-link edge、0 / 1 / 3 / 10 feedback schedule、query、used node、credited path、control / treatment、gate、manifest hash、exclusive output を観測前に固定する。control は relation trace と used node を記録して edge を変更せず、treatment だけが同じ schedule の relation trace ID を `record_success` に渡す。headroom は 0 から 10 で厳密改善し途中 checkpoint で退行せず、control case と ceiling case も退行せず、credited edge 以外が変化しない場合だけ development gate を通過する。development 全 gate 通過時だけ holdout を一度開き、観測後は evaluator、fixture、gold、schedule、manifest、gate、docs を変更しない。 80. sibling relation feedback normalization は、明示的に有効化された candidate config でのみ、relation trace の credited edge を強化し、その edge と同じ source から出る未 credit sibling だけを局所的に正規化できる。lexical trace、zero-hop、未関係 source、credited sibling は変更しない。candidate は synthetic isolation test と result-free development / holdout 相当の relation、direct、lexical、negative-control gate を通過するまで default にしない。 81. sibling normalization controlled evaluation は、評価対象 source commit / hash、相互に identity-disjoint な development / holdout cluster、明示 edge、baseline `0.0` / treatment `1.0`、query、used node、credited path、mutation scope、rollback、係数 / 時刻 schedule、hard gate、exclusive output を観測前に固定する。実 `NeuronGraphRAG` の `search_channels` relation trace ID を `record_success` に渡し、headroom strict improvement、ceiling・direct・lexical・directional-negative non-regression、path・mutation・atomicity・determinism の全 development gate 通過時だけ holdout を一度開く。観測後は protocol artifact と docs を変更せず、既定値と external D1 claim を変更しない。 +82. frozen evaluation の historical source hash は、manifest 登録 commit または manifest が明示する source / baseline / prior commit の exact blob bytes に対して検証する。commit object の存在、現在 `HEAD` の ancestor 関係、manifest bytes、登録 path、hash を fail closed に照合し、同名 path の current working tree を過去の evidence として扱わない。既存 protocol が明記する raw-first LF / CRLF whole-file alternate だけを維持し、本文差、mixed newline、bare CR、その他の byte 差を拒否する。 75. v3 implementation、prompt、manifest、query override、schema、集約、path audit、hash規則、gate、stop rule、testsをresult-free commitでpushした後、development stage / 4 case packet / 12 responses / resultを各一度だけ生成する。 76. development全12 gate通過時だけholdout stageを一度生成し、異なるfresh 12 judgesで同じgateを評価する。packet、response、resultの上書き、観測後の規則変更、実LLM品質値のCI再生成を拒否する。 @@ -132,3 +133,4 @@ - [Engine-backed feedback trajectory experiment](engine-backed-feedback-trajectory-experiment.md) が repository-native controlled corpus v3 上の 0 / 1 / 3 / 10 feedback trajectory、実 relation trace、credited-only mutation、result-free freeze、conditional holdout を定義する。 - [Sibling relation feedback normalization](sibling-relation-feedback-normalization.md) が opt-in candidate の局所 sibling 正規化、trace isolation、default 変更前の検証境界を定義する。 - [Sibling normalization controlled evaluation](sibling-normalization-controlled-evaluation.md) が repository-native corpus、result-free hash freeze、実 relation trace feedback、mutation / rollback gate、conditional holdout を定義する。 +- [Historical source verification](historical-source-verification.md) が frozen manifest / source registry の登録 commit、exact blob、ancestor、path、newline portability、fail-closed 境界を定義する。 diff --git a/docs/sibling-normalization-controlled-evaluation.md b/docs/sibling-normalization-controlled-evaluation.md index bde308e..25f8281 100644 --- a/docs/sibling-normalization-controlled-evaluation.md +++ b/docs/sibling-normalization-controlled-evaluation.md @@ -10,6 +10,8 @@ `sibling_normalization_controlled_v1` の fixture、gold、schedule、manifest、gate、evaluator、runner、test と本書を、observed output が存在しない状態で commit / push する。manifest は評価対象の engine source commit と SHA-256、登録 artifact の SHA-256、split / cluster identity、exclusive output path を固定する。 +評価対象 engine source は manifest の明示 commit、protocol artifact は manifest 登録 commit の exact blob bytes を読む。commit の存在と current `HEAD` の ancestor 関係、manifest bytes、path、hash を検証し、current working tree の同名 path は historical evidence として扱わない。 + development と holdout は node ID、cluster ID、query 語彙を共有しない。過去の feedback trajectory evaluator、fixture、gold、schedule、manifest、gate、observed result は選択入力にも実行入力にも使わない。 登録 runner は clean worktree かつ `HEAD == upstream` の freeze 後だけ実行できる。既存 output の上書きを拒否し、development が全 hard gate を通過した場合だけ holdout を一度開く。失敗結果も保存し、protocol 調整や再実行を行わない。 diff --git a/src/neuron_graph_rag/blind_selection.py b/src/neuron_graph_rag/blind_selection.py index 2883db4..2192bbe 100644 --- a/src/neuron_graph_rag/blind_selection.py +++ b/src/neuron_graph_rag/blind_selection.py @@ -8,6 +8,10 @@ from pathlib import Path from typing import Any, Iterable +from .corpus_integrity import ( + verify_historical_source_hashes, + verify_manifest_source_hashes, +) from .d1_fixture import load_fixture from .engine import EngineConfig, NeuronGraphRAG @@ -45,12 +49,15 @@ def read_blind_manifest(path: str | Path) -> dict[str, Any]: if len(manifest.get("gate", [])) != 12: raise ValueError("Blind selection manifest must freeze twelve gates") _audit_v1_hashes(manifest_path, manifest) - prompt_path = _repo_root(manifest_path) / manifest["judge_prompt"]["path"] - if not _matches_frozen_text_bytes( - prompt_path, str(manifest["judge_prompt"]["sha256"]) - ): - raise ValueError("Frozen judge prompt hash mismatch") - _validate_prompt(prompt_path.read_text(encoding="utf-8")) + root = _repo_root(manifest_path) + prompt_relative = str(manifest["judge_prompt"]["path"]) + prompt = verify_manifest_source_hashes( + root, + manifest_path, + {prompt_relative: str(manifest["judge_prompt"]["sha256"])}, + allow_text_newline_alternate=True, + ) + _validate_prompt(prompt.artifact_bytes[prompt_relative].decode("utf-8")) return manifest @@ -560,11 +567,12 @@ def _audit_v1_hashes(manifest_path: Path, manifest: dict[str, Any]) -> None: entries = manifest.get("frozen_v1_bytes") if not isinstance(entries, list) or not entries: raise ValueError("Blind manifest must freeze v1 byte hashes") - for entry in entries: - if not _matches_frozen_text_bytes( - root / entry["path"], str(entry["sha256"]) - ): - raise ValueError(f"Frozen v1 byte hash mismatch: {entry['path']}") + verify_historical_source_hashes( + root, + str(manifest["frozen_v1_baseline_commit"]), + {str(entry["path"]): str(entry["sha256"]) for entry in entries}, + allow_text_newline_alternate=True, + ) def _matches_frozen_text_bytes(path: Path, expected_sha256: str) -> bool: diff --git a/src/neuron_graph_rag/canonical_gate_evaluation.py b/src/neuron_graph_rag/canonical_gate_evaluation.py index 4f802c2..6418b1f 100644 --- a/src/neuron_graph_rag/canonical_gate_evaluation.py +++ b/src/neuron_graph_rag/canonical_gate_evaluation.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import os from collections.abc import Mapping, Sequence @@ -8,6 +7,7 @@ from pathlib import Path from typing import Any +from .corpus_integrity import HistoricalSourceSnapshot, verify_manifest_source_hashes from .evidence_feedback import EngineConfig, NeuronGraphRAG @@ -19,18 +19,34 @@ def read_json(path: Path) -> Any: raw = path.read_bytes() + return _read_json_bytes(raw, str(path)) + + +def _read_json_bytes(raw: bytes, source: str) -> Any: text = raw.decode("utf-8") if text.encode("utf-8") != raw: - raise ValueError(f"non-canonical UTF-8 artifact: {path}") + raise ValueError(f"non-canonical UTF-8 artifact: {source}") return json.loads(text) -def _sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() +def _registered_artifacts(manifest: Mapping[str, Any]) -> HistoricalSourceSnapshot: + return verify_manifest_source_hashes(ROOT, MANIFEST_PATH, manifest["artifacts"]) + + +def _registered_json( + registered: HistoricalSourceSnapshot, relative: str +) -> Any: + return _read_json_bytes( + registered.artifact_bytes[relative], + f"{registered.source_commit}:{relative}", + ) def canonical_gate_ids(gate_path: Path) -> list[str]: - payload = read_json(gate_path) + return _canonical_gate_ids(read_json(gate_path)) + + +def _canonical_gate_ids(payload: Mapping[str, Any]) -> list[str]: gates = payload.get("gates") if not isinstance(gates, list) or not gates: raise ValueError("registered gates must be a non-empty array") @@ -348,12 +364,14 @@ def _changed_edges(snapshot: Mapping[str, Any]) -> list[str]: def _artifact_preflight(manifest: Mapping[str, Any], stage: str) -> dict[str, Any]: checks: dict[str, bool] = {} + registered = _registered_artifacts(manifest) for relative, expected_hash in manifest["artifacts"].items(): - path = ROOT / relative - checks[f"hash:{relative}"] = path.is_file() and _sha256(path) == expected_hash - if path.is_file() and path.suffix in {".json", ".md", ".py"}: + checks[f"hash:{relative}"] = ( + registered.artifact_sha256[relative] == expected_hash + ) + if Path(relative).suffix in {".json", ".md", ".py"}: try: - path.read_bytes().decode("utf-8") + registered.artifact_bytes[relative].decode("utf-8") except UnicodeDecodeError: checks[f"utf8:{relative}"] = False else: @@ -370,9 +388,9 @@ def _artifact_preflight(manifest: Mapping[str, Any], stage: str) -> dict[str, An if other_output.exists(): development = read_json(other_output) checks["development-all-pass"] = development.get("all_pass") is True - audit = read_json(ROOT / manifest["audit"]) - fixture = read_json(ROOT / manifest["fixture"]) - registry = read_json(ROOT / manifest["identity_registry"]) + audit = _registered_json(registered, manifest["audit"]) + fixture = _registered_json(registered, manifest["fixture"]) + registry = _registered_json(registered, manifest["identity_registry"]) identity_checks = verify_identity_only_registry(fixture, registry) checks["identity-audit"] = ( audit.get("checks") == identity_checks @@ -517,10 +535,13 @@ def run_registered_stage(stage: str, *, root: Path = ROOT) -> Path: preflight = _artifact_preflight(manifest, stage) if not preflight["passed"]: raise RuntimeError(f"protocol preflight failed: {preflight['checks']}") - fixture = read_json(ROOT / manifest["fixture"]) - gold = read_json(ROOT / manifest["gold"]) - schedule = read_json(ROOT / manifest["schedule"]) - gate_ids = canonical_gate_ids(ROOT / manifest["gate"]) + registered = _registered_artifacts(manifest) + fixture = _registered_json(registered, manifest["fixture"]) + gold = _registered_json(registered, manifest["gold"]) + schedule = _registered_json(registered, manifest["schedule"]) + gate_ids = _canonical_gate_ids( + _registered_json(registered, manifest["gate"]) + ) cases = [item for item in fixture["cases"] if item["stage"] == stage] expected_targets = { item["case_id"]: item["target_node_id"] for item in gold["cases"] diff --git a/src/neuron_graph_rag/corpus_integrity.py b/src/neuron_graph_rag/corpus_integrity.py index 8b0233b..356f608 100644 --- a/src/neuron_graph_rag/corpus_integrity.py +++ b/src/neuron_graph_rag/corpus_integrity.py @@ -1,8 +1,10 @@ -"""Portable, raw-first integrity checks for future frozen source corpora.""" +"""Raw and repository-historical integrity checks for frozen sources.""" from __future__ import annotations import hashlib +import subprocess +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -19,6 +21,93 @@ class SourceHashVerification: alternate_sha256: str | None = None +@dataclass(frozen=True) +class HistoricalSourceSnapshot: + """Verified bytes read from one registered repository commit.""" + + source_commit: str + artifact_bytes: dict[str, bytes] + artifact_sha256: dict[str, str] + + +def registered_manifest_commit( + repository_root: str | Path, manifest_path: str | Path +) -> str: + """Resolve the commit that registered the current frozen manifest bytes. + + The manifest itself remains the immutable registry. Its most recent commit + is therefore the source boundary for the hashes it contains; later working + tree changes must not move that boundary. + """ + + root = Path(repository_root).resolve() + relative = _repository_relative(root, manifest_path) + completed = _git(root, "log", "-1", "--format=%H", "--", relative) + commit = completed.stdout.decode("ascii", errors="strict").strip() + if not commit: + raise ValueError(f"frozen manifest is not registered in git: {relative}") + _verify_commit_boundary(root, commit) + registered = _git_bytes(root, commit, relative) + current = (root / relative).read_bytes() + if current != registered: + raise ValueError(f"frozen manifest differs from registered commit: {relative}") + return commit + + +def verify_historical_source_hashes( + repository_root: str | Path, + source_commit: str, + expected_hashes: Mapping[str, str], + *, + allow_text_newline_alternate: bool = False, +) -> HistoricalSourceSnapshot: + """Verify registered blobs without consulting same-path working-tree bytes.""" + + root = Path(repository_root).resolve() + commit = str(source_commit) + _verify_commit_boundary(root, commit) + if not isinstance(expected_hashes, Mapping) or not expected_hashes: + raise ValueError("historical source hash registry must be non-empty") + + artifact_bytes: dict[str, bytes] = {} + artifact_sha256: dict[str, str] = {} + for raw_relative, raw_expected in expected_hashes.items(): + relative = _repository_relative(root, str(raw_relative)) + expected = str(raw_expected) + registered = _git_bytes(root, commit, relative) + verification = verify_source_bytes( + registered, + expected, + allow_text_newline_alternate=allow_text_newline_alternate, + ) + if not verification.accepted: + raise ValueError( + f"historical source hash mismatch at {commit}: {relative}" + ) + artifact_bytes[relative] = registered + artifact_sha256[relative] = expected + return HistoricalSourceSnapshot(commit, artifact_bytes, artifact_sha256) + + +def verify_manifest_source_hashes( + repository_root: str | Path, + manifest_path: str | Path, + expected_hashes: Mapping[str, str], + *, + allow_text_newline_alternate: bool = False, +) -> HistoricalSourceSnapshot: + """Verify a manifest registry against the commit that registered it.""" + + root = Path(repository_root).resolve() + commit = registered_manifest_commit(root, manifest_path) + return verify_historical_source_hashes( + root, + commit, + expected_hashes, + allow_text_newline_alternate=allow_text_newline_alternate, + ) + + def verify_source_sha256( path: str | Path, expected_sha256: str ) -> SourceHashVerification: @@ -29,9 +118,24 @@ def verify_source_sha256( This deliberately does not parse or canonicalize the source content. """ - raw = Path(path).read_bytes() + return verify_source_bytes( + Path(path).read_bytes(), + expected_sha256, + allow_text_newline_alternate=True, + ) + + +def verify_source_bytes( + raw: bytes, + expected_sha256: str, + *, + allow_text_newline_alternate: bool, +) -> SourceHashVerification: + """Verify bytes, optionally allowing one whole-file newline conversion.""" + raw_sha256 = _sha256(raw) - if raw_sha256 == expected_sha256: + expected = _prefixed_sha256(expected_sha256) + if raw_sha256 == expected: return SourceHashVerification( accepted=True, decision="raw_match", @@ -40,6 +144,14 @@ def verify_source_sha256( raw_sha256=raw_sha256, ) + if not allow_text_newline_alternate: + return _rejected( + "rejected_hash_mismatch", + "raw bytes differ from expected", + expected_sha256, + raw_sha256, + ) + if b"\r" not in raw: if b"\n" not in raw: return _rejected( @@ -49,7 +161,7 @@ def verify_source_sha256( raw_sha256, ) alternate_sha256 = _sha256(raw.replace(b"\n", b"\r\n")) - if alternate_sha256 == expected_sha256: + if alternate_sha256 == expected: return SourceHashVerification( accepted=True, decision="newline_equivalent_lf_to_crlf", @@ -83,7 +195,7 @@ def verify_source_sha256( ) alternate_sha256 = _sha256(raw.replace(b"\r\n", b"\n")) - if alternate_sha256 == expected_sha256: + if alternate_sha256 == expected: return SourceHashVerification( accepted=True, decision="newline_equivalent_crlf_to_lf", @@ -120,3 +232,60 @@ def _rejected( def _sha256(raw: bytes) -> str: return "sha256:" + hashlib.sha256(raw).hexdigest() + + +def _prefixed_sha256(value: str) -> str: + return value if value.startswith("sha256:") else f"sha256:{value}" + + +def _repository_relative(root: Path, path: str | Path) -> str: + candidate = Path(path) + resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve() + try: + relative = resolved.relative_to(root) + except ValueError as error: + raise ValueError(f"historical source path is outside repository: {path}") from error + if not relative.parts or ".git" in relative.parts: + raise ValueError(f"invalid historical source path: {path}") + return relative.as_posix() + + +def _verify_commit_boundary(root: Path, commit: str) -> None: + if not commit or any(character.isspace() for character in commit): + raise ValueError(f"invalid historical source commit: {commit!r}") + try: + _git(root, "cat-file", "-e", f"{commit}^{{commit}}") + except RuntimeError as error: + raise ValueError(f"historical source commit is unavailable: {commit}") from error + ancestor = subprocess.run( + ["git", "merge-base", "--is-ancestor", commit, "HEAD"], + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if ancestor.returncode != 0: + raise ValueError(f"historical source commit is not an ancestor of HEAD: {commit}") + + +def _git_bytes(root: Path, commit: str, relative: str) -> bytes: + try: + return _git(root, "show", f"{commit}:{relative}").stdout + except RuntimeError as error: + raise ValueError( + f"historical source path is missing at {commit}: {relative}" + ) from error + + +def _git(root: Path, *arguments: str) -> subprocess.CompletedProcess[bytes]: + completed = subprocess.run( + ["git", *arguments], + cwd=root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if completed.returncode != 0: + message = completed.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError(message or f"git {' '.join(arguments)} failed") + return completed diff --git a/src/neuron_graph_rag/engine_feedback_trajectory.py b/src/neuron_graph_rag/engine_feedback_trajectory.py index b5f07ec..fbbae5a 100644 --- a/src/neuron_graph_rag/engine_feedback_trajectory.py +++ b/src/neuron_graph_rag/engine_feedback_trajectory.py @@ -8,7 +8,12 @@ from pathlib import Path from typing import Any -from .corpus_integrity import verify_source_sha256 +from .corpus_integrity import ( + HistoricalSourceSnapshot, + verify_historical_source_hashes, + verify_manifest_source_hashes, + verify_source_bytes, +) from .engine import EngineConfig, NeuronGraphRAG @@ -419,15 +424,26 @@ def _gate_result( def _read_frozen_artifacts( manifest_path: Path, manifest: dict[str, Any] ) -> dict[str, dict[str, Any]]: - loaded: dict[str, dict[str, Any]] = {} + root = manifest_path.resolve().parents[2] + records: dict[str, tuple[str, str]] = {} for name in ("fixture", "gold", "schedule", "gate", "audit"): record = manifest.get("artifacts", {}).get(name) if not isinstance(record, dict): raise ValueError(f"Missing frozen artifact: {name}") artifact_path = manifest_path.parent / str(record.get("path", "")) - if _raw_sha256(artifact_path) != record.get("sha256"): - raise ValueError(f"Frozen artifact hash mismatch: {name}") - loaded[name] = _read_json(artifact_path) + relative = artifact_path.resolve().relative_to(root).as_posix() + records[name] = (relative, str(record.get("sha256", ""))) + registered = verify_manifest_source_hashes( + root, + manifest_path, + {relative: expected for relative, expected in records.values()}, + ) + loaded: dict[str, dict[str, Any]] = {} + for name, (relative, _) in records.items(): + loaded[name] = _read_json_bytes( + registered.artifact_bytes[relative], + f"{registered.source_commit}:{relative}", + ) return loaded @@ -537,10 +553,15 @@ def _verify_source_integrity( manifest_path: Path, fixture: dict[str, Any], stage: str ) -> dict[str, Any]: root = (manifest_path.parent / str(fixture["repository_root"])).resolve() + source = _registered_source_snapshot(root, fixture, stage) checks: list[dict[str, Any]] = [] for node in fixture["splits"][stage]["nodes"]: - path = root / str(node["document_path"]) - verification = verify_source_sha256(path, str(node["source_sha256"])) + relative = str(node["document_path"]) + verification = verify_source_bytes( + source.artifact_bytes[relative], + str(node["source_sha256"]), + allow_text_newline_alternate=True, + ) checks.append( { "node_id": node["node_id"], @@ -553,7 +574,9 @@ def _verify_source_integrity( "alternate_sha256": verification.alternate_sha256, } ) - explicit_links = _verify_explicit_links(root, fixture["splits"][stage]) + explicit_links = _verify_explicit_links( + fixture["splits"][stage], source.artifact_bytes + ) return { "passed": all(bool(check["accepted"]) for check in checks) and explicit_links["passed"], "source_commit": fixture["source_commit"], @@ -562,7 +585,9 @@ def _verify_source_integrity( } -def _verify_explicit_links(root: Path, split: dict[str, Any]) -> dict[str, Any]: +def _verify_explicit_links( + split: dict[str, Any], source_bytes: dict[str, bytes] +) -> dict[str, Any]: node_by_path = { Path(str(node["document_path"])).name: str(node["node_id"]) for node in split["nodes"] @@ -572,8 +597,13 @@ def _verify_explicit_links(root: Path, split: dict[str, Any]) -> dict[str, Any]: for cluster in split["clusters"]: overview_name = f"{cluster}-overview.md" overview_node = node_by_path[overview_name] - overview_path = root / "corpora" / "repository-native-controlled-v3" / overview_name - for target_name in RELATIVE_LINK.findall(overview_path.read_text(encoding="utf-8")): + overview_relative = next( + str(node["document_path"]) + for node in split["nodes"] + if Path(str(node["document_path"])).name == overview_name + ) + overview_text = source_bytes[overview_relative].decode("utf-8", errors="strict") + for target_name in RELATIVE_LINK.findall(overview_text): observed.add((overview_node, node_by_path[target_name], "explicit_link")) return { "passed": observed == expected, @@ -590,10 +620,12 @@ def _load_split( ) -> None: root = (manifest_path.parent / str(fixture["repository_root"])).resolve() split = fixture["splits"][stage] + source = _registered_source_snapshot(root, fixture, stage) for node in split["nodes"]: + relative = str(node["document_path"]) engine.add_document( str(node["node_id"]), - (root / str(node["document_path"])).read_text(encoding="utf-8"), + source.artifact_bytes[relative].decode("utf-8", errors="strict"), metadata={ "cluster": node["cluster"], "source_url": node["source_url"], @@ -611,6 +643,18 @@ def _load_split( ) +def _registered_source_snapshot( + root: Path, fixture: dict[str, Any], stage: str +) -> HistoricalSourceSnapshot: + return verify_historical_source_hashes( + root, + str(fixture["source_commit"]), + { + str(node["document_path"]): str(node["source_sha256"]) + for node in fixture["splits"][stage]["nodes"] + }, + allow_text_newline_alternate=True, + ) def _paths_from_hit(hit: Any) -> tuple[list[dict[str, Any]], list[tuple[tuple[str, str, str], ...]]]: raw = list(hit.explain()["paths"]) projected = [_project_path(path["steps"]) for path in raw] @@ -699,10 +743,13 @@ def _result_path( def _read_json(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as stream: - value = json.load(stream) + return _read_json_bytes(path.read_bytes(), str(path)) + + +def _read_json_bytes(raw: bytes, source: str) -> dict[str, Any]: + value = json.loads(raw.decode("utf-8", errors="strict")) if not isinstance(value, dict): - raise ValueError(f"Expected JSON object: {path}") + raise ValueError(f"Expected JSON object: {source}") return value diff --git a/src/neuron_graph_rag/feedback_policy_comparison_evaluation.py b/src/neuron_graph_rag/feedback_policy_comparison_evaluation.py index ec7496a..c4cba8e 100644 --- a/src/neuron_graph_rag/feedback_policy_comparison_evaluation.py +++ b/src/neuron_graph_rag/feedback_policy_comparison_evaluation.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +from .corpus_integrity import verify_manifest_source_hashes from .evidence_feedback import EngineConfig, NeuronGraphRAG from .feedback import FeedbackLedger from .models import SourceUseEvent @@ -24,12 +25,16 @@ def read_json(path: Path) -> Any: raw = path.read_bytes() + return _read_json_bytes(raw, str(path)) + + +def _read_json_bytes(raw: bytes, source: str) -> Any: text = raw.decode("utf-8", errors="strict") if text.encode("utf-8") != raw: - raise ValueError(f"non-canonical UTF-8 artifact: {path}") + raise ValueError(f"non-canonical UTF-8 artifact: {source}") payload = json.loads(text) if text != json.dumps(payload, ensure_ascii=False, indent=2) + "\n": - raise ValueError(f"non-canonical JSON artifact: {path}") + raise ValueError(f"non-canonical JSON artifact: {source}") return payload @@ -88,14 +93,18 @@ def _git_bytes(commit: str, path: str) -> bytes: def _load_protocol() -> tuple[dict[str, Any], dict[str, dict[str, Any]]]: manifest = read_json(MANIFEST_PATH) - for relative, expected in manifest["artifact_sha256"].items(): - path = ROOT / relative - if not path.is_file() or _sha256(path) != expected: - raise RuntimeError(f"frozen artifact hash mismatch: {relative}") + try: + registered = verify_manifest_source_hashes( + ROOT, MANIFEST_PATH, manifest["artifact_sha256"] + ) + except ValueError as error: + raise RuntimeError(str(error)) from error artifacts: dict[str, dict[str, Any]] = {} for name, relative in manifest["protocol_artifacts"].items(): - path = ROOT / relative - artifacts[name] = read_json(path) + artifacts[name] = _read_json_bytes( + registered.artifact_bytes[relative], + f"{registered.source_commit}:{relative}", + ) return manifest, artifacts diff --git a/src/neuron_graph_rag/fresh_native_feedback_evaluation.py b/src/neuron_graph_rag/fresh_native_feedback_evaluation.py index a0d006d..1b4c4e5 100644 --- a/src/neuron_graph_rag/fresh_native_feedback_evaluation.py +++ b/src/neuron_graph_rag/fresh_native_feedback_evaluation.py @@ -7,7 +7,12 @@ from pathlib import Path from typing import Any -from .corpus_integrity import verify_source_sha256 +from .corpus_integrity import ( + HistoricalSourceSnapshot, + verify_historical_source_hashes, + verify_manifest_source_hashes, + verify_source_bytes, +) from .engine import EngineConfig, NeuronGraphRAG @@ -98,15 +103,26 @@ def write_fresh_native_feedback_result(path: str | Path, result: dict[str, Any]) def _read_frozen_artifacts(manifest_path: Path, manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: - loaded: dict[str, dict[str, Any]] = {} + root = manifest_path.resolve().parents[2] + records: dict[str, tuple[str, str]] = {} for name in ("fixture", "gold", "schedule", "gate", "audit"): record = manifest["artifacts"].get(name) if not isinstance(record, dict): raise ValueError(f"Missing frozen {name} record") artifact = manifest_path.parent / str(record["path"]) - if _raw_sha256(artifact) != record["sha256"]: - raise ValueError(f"Frozen {name} hash mismatch") - loaded[name] = _read_json(artifact) + relative = artifact.resolve().relative_to(root).as_posix() + records[name] = (relative, str(record["sha256"])) + registered = verify_manifest_source_hashes( + root, + manifest_path, + {relative: expected for relative, expected in records.values()}, + ) + loaded: dict[str, dict[str, Any]] = {} + for name, (relative, _) in records.items(): + loaded[name] = _read_json_bytes( + registered.artifact_bytes[relative], + f"{registered.source_commit}:{relative}", + ) return loaded @@ -211,11 +227,15 @@ def _validate_gate(gate: dict[str, Any]) -> None: def _verify_source_integrity(manifest_path: Path, fixture: dict[str, Any]) -> dict[str, Any]: root = (manifest_path.parent / str(fixture["repository_root"])).resolve() + source = _registered_source_snapshot(root, manifest_path, fixture) checks: list[dict[str, str | bool | None]] = [] for stage in ("development", "holdout"): for node in fixture["splits"][stage]["nodes"]: - verification = verify_source_sha256( - root / str(node["document_path"]), str(node["source_sha256"]) + relative = str(node["document_path"]) + verification = verify_source_bytes( + source.artifact_bytes[relative], + str(node["source_sha256"]), + allow_text_newline_alternate=True, ) checks.append( { @@ -312,10 +332,12 @@ def _run_group( def _load_source_backed_split(engine: NeuronGraphRAG, manifest_path: Path, fixture: dict[str, Any], stage: str) -> None: root = (manifest_path.parent / str(fixture["repository_root"])).resolve() split = fixture["splits"][stage] + source = _registered_source_snapshot(root, manifest_path, fixture) for node in split["nodes"]: + relative = str(node["document_path"]) engine.add_document( str(node["node_id"]), - (root / str(node["document_path"])).read_text(encoding="utf-8"), + source.artifact_bytes[relative].decode("utf-8", errors="strict"), metadata={"source_url": node["source_url"], "document_path": node["document_path"]}, ) for edge in split["edges"]: @@ -325,6 +347,23 @@ def _load_source_backed_split(engine: NeuronGraphRAG, manifest_path: Path, fixtu ) +def _registered_source_snapshot( + root: Path, manifest_path: Path, fixture: dict[str, Any] +) -> HistoricalSourceSnapshot: + nodes = [ + node + for stage in ("development", "holdout") + for node in fixture["splits"][stage]["nodes"] + ] + return verify_manifest_source_hashes( + root, + manifest_path, + { + str(node["document_path"]): str(node["source_sha256"]) + for node in nodes + }, + allow_text_newline_alternate=True, + ) def _score_case(engine: NeuronGraphRAG, case: dict[str, Any], limit: int) -> dict[str, Any]: trace = engine.search_channels(case["query"], limit=limit, now=float(case["now"])) expected = str(case["expected_node_id"]) @@ -379,10 +418,13 @@ def _reinforced(edge: Any) -> dict[str, Any]: def _read_json(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as stream: - value = json.load(stream) + return _read_json_bytes(path.read_bytes(), str(path)) + + +def _read_json_bytes(raw: bytes, source: str) -> dict[str, Any]: + value = json.loads(raw.decode("utf-8", errors="strict")) if not isinstance(value, dict): - raise ValueError(f"Expected JSON object: {path}") + raise ValueError(f"Expected JSON object: {source}") return value diff --git a/src/neuron_graph_rag/node_first_selection.py b/src/neuron_graph_rag/node_first_selection.py index 3f72b14..00c8352 100644 --- a/src/neuron_graph_rag/node_first_selection.py +++ b/src/neuron_graph_rag/node_first_selection.py @@ -13,7 +13,10 @@ RESPONSE_KEYS, SELECTIONS, _make_packet_case, - _matches_frozen_text_bytes, +) +from .corpus_integrity import ( + verify_historical_source_hashes, + verify_manifest_source_hashes, ) from .engine import EngineConfig @@ -44,12 +47,15 @@ def read_node_first_manifest(path: str | Path) -> dict[str, Any]: if manifest.get("case_count") != 4 or manifest.get("judges_per_case") != 3: raise ValueError("Node-first protocol requires four cases and three judges") _audit_prior_artifacts(manifest_path, manifest) - prompt = _repo_root(manifest_path) / manifest["judge_prompt"]["path"] - if _canonical_checkout_sha256(prompt) != manifest["judge_prompt"][ - "canonical_sha256" - ]: - raise ValueError("Frozen node-first judge prompt hash mismatch") - _validate_prompt(prompt.read_text(encoding="utf-8")) + root = _repo_root(manifest_path) + prompt_relative = str(manifest["judge_prompt"]["path"]) + prompt = verify_manifest_source_hashes( + root, + manifest_path, + {prompt_relative: str(manifest["judge_prompt"]["canonical_sha256"])}, + allow_text_newline_alternate=True, + ) + _validate_prompt(prompt.artifact_bytes[prompt_relative].decode("utf-8")) return manifest @@ -696,11 +702,12 @@ def _audit_prior_artifacts(manifest_path: Path, manifest: dict[str, Any]) -> Non versions = {entry.get("version") for entry in entries} if versions != {"v1", "v2"}: raise ValueError("Node-first manifest must freeze v1 and v2 artifacts") - for entry in entries: - if not _matches_frozen_text_bytes( - root / entry["path"], str(entry["sha256"]) - ): - raise ValueError(f"Frozen prior artifact mismatch: {entry['path']}") + verify_historical_source_hashes( + root, + str(manifest["frozen_prior_commit"]), + {str(entry["path"]): str(entry["sha256"]) for entry in entries}, + allow_text_newline_alternate=True, + ) def _observed_paths(root: Path, manifest: dict[str, Any]) -> list[Path]: diff --git a/src/neuron_graph_rag/real_task_shadow_v3.py b/src/neuron_graph_rag/real_task_shadow_v3.py index 4618a3b..cb99f39 100644 --- a/src/neuron_graph_rag/real_task_shadow_v3.py +++ b/src/neuron_graph_rag/real_task_shadow_v3.py @@ -1,12 +1,12 @@ from __future__ import annotations -import hashlib import tempfile from pathlib import Path from typing import Any, Mapping, Sequence from . import real_task_shadow as v1 from . import real_task_shadow_v2 as v2 +from .corpus_integrity import verify_manifest_source_hashes from .evidence_feedback import EngineConfig @@ -323,27 +323,6 @@ def _relative_path(root: Path, relative: Any, name: str) -> Path: return resolved -def _verify_hashes(root: Path, values: Any, name: str) -> None: - hashes = v1._mapping(values, name) - if not hashes: - raise ValueError(f"{name} must not be empty") - for relative, expected in hashes.items(): - if not isinstance(expected, str) or len(expected) != 64: - raise ValueError(f"{name}.{relative} must be a sha256 hex digest") - try: - int(expected, 16) - except ValueError as error: - raise ValueError( - f"{name}.{relative} must be a sha256 hex digest" - ) from error - path = _relative_path(root, relative, f"{name}.{relative}") - if not path.is_file(): - raise ValueError(f"frozen artifact is missing: {relative}") - actual = hashlib.sha256(path.read_bytes()).hexdigest() - if actual != expected: - raise ValueError(f"frozen artifact hash mismatch: {relative}") - - def audit_repository_lifecycle( manifest_path: str | Path, *, @@ -388,8 +367,10 @@ def audit_repository_lifecycle( raise ValueError(f"lifecycle manifest changed the frozen {field}") root = Path(repository_root).resolve() output_root = root if registered_root is None else Path(registered_root).resolve() - _verify_hashes(root, manifest["artifact_sha256"], "artifact_sha256") - _verify_hashes(root, manifest["legacy_artifact_sha256"], "legacy_artifact_sha256") + verify_manifest_source_hashes(root, manifest_path, manifest["artifact_sha256"]) + verify_manifest_source_hashes( + root, manifest_path, manifest["legacy_artifact_sha256"] + ) outputs = v1._mapping(manifest["registered_outputs"], "registered_outputs") v1._exact_keys(outputs, {"final_aggregate", "packet_registry"}, "registered_outputs") registry = _relative_path(output_root, outputs["packet_registry"], "packet_registry") diff --git a/src/neuron_graph_rag/sibling_normalization_evaluation.py b/src/neuron_graph_rag/sibling_normalization_evaluation.py index 5c4d800..a32fa47 100644 --- a/src/neuron_graph_rag/sibling_normalization_evaluation.py +++ b/src/neuron_graph_rag/sibling_normalization_evaluation.py @@ -1,11 +1,15 @@ from __future__ import annotations -import hashlib import json import subprocess from pathlib import Path from typing import Any +from .corpus_integrity import ( + HistoricalSourceSnapshot, + verify_historical_source_hashes, + verify_manifest_source_hashes, +) from .engine import EngineConfig, NeuronGraphRAG @@ -15,22 +19,26 @@ ) -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for chunk in iter(lambda: stream.read(64 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() +def load_json(path: Path) -> dict[str, Any]: + return _load_json_bytes(path.read_bytes(), str(path)) -def load_json(path: Path) -> dict[str, Any]: - with path.open("r", encoding="utf-8") as stream: - value = json.load(stream) +def _load_json_bytes(raw: bytes, source: str) -> dict[str, Any]: + value = json.loads(raw.decode("utf-8", errors="strict")) if not isinstance(value, dict): - raise ValueError(f"Expected an object in {path}") + raise ValueError(f"Expected an object in {source}") return value +def _registered_json( + registered: HistoricalSourceSnapshot, relative: str +) -> dict[str, Any]: + return _load_json_bytes( + registered.artifact_bytes[relative], + f"{registered.source_commit}:{relative}", + ) + + def _git(repo_root: Path, *args: str) -> str: completed = subprocess.run( ["git", *args], @@ -51,33 +59,26 @@ def validate_protocol(repo_root: Path) -> dict[str, Any]: registered = manifest.get("registered_artifacts") if not isinstance(registered, dict) or not registered: raise ValueError("Manifest has no registered artifacts") - actual_hashes: dict[str, str] = {} - for relative, expected in sorted(registered.items()): - path = repo_root / relative - if not path.is_file(): - raise FileNotFoundError(f"Missing registered artifact: {relative}") - actual = sha256_file(path) - if actual != expected: - raise ValueError( - f"Registered artifact hash mismatch: {relative}: {actual} != {expected}" - ) - actual_hashes[relative] = actual + registered_snapshot = verify_manifest_source_hashes( + repo_root, manifest_path, registered + ) + actual_hashes = registered_snapshot.artifact_sha256 source = manifest.get("evaluated_source") if not isinstance(source, dict): raise ValueError("Manifest has no evaluated source") - source_path = repo_root / str(source["path"]) - actual_source_hash = sha256_file(source_path) - if actual_source_hash != source.get("sha256"): - raise ValueError("Evaluated engine source differs from the frozen source hash") source_commit = str(source["commit"]) - _git(repo_root, "cat-file", "-e", f"{source_commit}^{{commit}}") - _git(repo_root, "merge-base", "--is-ancestor", source_commit, "HEAD") + source_snapshot = verify_historical_source_hashes( + repo_root, + source_commit, + {str(source["path"]): str(source["sha256"])}, + ) + actual_source_hash = source_snapshot.artifact_sha256[str(source["path"])] - fixture = load_json(repo_root / str(manifest["paths"]["fixture"])) - gold = load_json(repo_root / str(manifest["paths"]["gold"])) - schedule = load_json(repo_root / str(manifest["paths"]["schedule"])) - gate = load_json(repo_root / str(manifest["paths"]["gate"])) + fixture = _registered_json(registered_snapshot, str(manifest["paths"]["fixture"])) + gold = _registered_json(registered_snapshot, str(manifest["paths"]["gold"])) + schedule = _registered_json(registered_snapshot, str(manifest["paths"]["schedule"])) + gate = _registered_json(registered_snapshot, str(manifest["paths"]["gate"])) _validate_split_identity(fixture, gold) _validate_schedule(schedule) _validate_gate(gate) diff --git a/tests/test_blind_selection.py b/tests/test_blind_selection.py index 9a06ba8..9096171 100644 --- a/tests/test_blind_selection.py +++ b/tests/test_blind_selection.py @@ -3,6 +3,7 @@ import copy import hashlib import json +import subprocess import tempfile import unittest from pathlib import Path @@ -332,12 +333,32 @@ class BlindAggregationRuleTest(unittest.TestCase): def test_synthetic_three_judge_aggregation_passes_frozen_gates(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) + subprocess.run( + ["git", "init", "--initial-branch=main"], cwd=root, check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Blind Test"], cwd=root, check=True + ) + subprocess.run( + ["git", "config", "user.email", "blind@example.invalid"], + cwd=root, check=True, + ) fixtures = root / "tests" / "fixtures" fixtures.mkdir(parents=True) prompt = fixtures / "prompt.txt" prompt.write_text("laneをまたいで数値比較しない\n", encoding="utf-8") baseline = root / "baseline.txt" baseline.write_text("frozen\n", encoding="utf-8") + subprocess.run(["git", "add", "baseline.txt"], cwd=root, check=True) + subprocess.run( + ["git", "commit", "-m", "test: freeze baseline"], + cwd=root, check=True, capture_output=True, + ) + baseline_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, + capture_output=True, text=True, + ).stdout.strip() gold_cases = [] packet = _packet() packet["experiment_id"] = "d1-liplus-blind-channel-selection-v2" @@ -380,6 +401,7 @@ def test_synthetic_three_judge_aggregation_passes_frozen_gates(self) -> None: "schema_version": 1, "experiment_id": "d1-liplus-blind-channel-selection-v2", "gate": [f"gate-{index}" for index in range(12)], + "frozen_v1_baseline_commit": baseline_commit, "frozen_v1_bytes": [ {"path": "baseline.txt", "sha256": _sha256(baseline)} ], @@ -432,6 +454,12 @@ def test_synthetic_three_judge_aggregation_passes_frozen_gates(self) -> None: ) response_paths.append(artifact_path) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "-m", "test: register blind manifest"], + cwd=root, check=True, capture_output=True, + ) + result = aggregate_blind_results( manifest, packet_path, response_paths ) diff --git a/tests/test_canonical_gate_evaluation.py b/tests/test_canonical_gate_evaluation.py index 8d21d65..d592aa0 100644 --- a/tests/test_canonical_gate_evaluation.py +++ b/tests/test_canonical_gate_evaluation.py @@ -1,7 +1,5 @@ from __future__ import annotations -import hashlib -import subprocess import tempfile import unittest from pathlib import Path @@ -17,6 +15,7 @@ verify_registered_result, write_observed_exclusive, ) +from neuron_graph_rag.corpus_integrity import verify_manifest_source_hashes ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" @@ -122,36 +121,12 @@ def test_verifier_rejects_mapping_reordering_duplicates_and_failure(self) -> Non def test_registered_artifacts_are_result_free_or_immutable(self) -> None: manifest = read_json(FIXTURES / "canonical_evidence_gate_v1.manifest.json") - manifest_relative = "tests/fixtures/canonical_evidence_gate_v1.manifest.json" - frozen_commit = subprocess.check_output( - ["git", "log", "-1", "--format=%H", "--", manifest_relative], - cwd=ROOT, - text=True, - ).strip() - commit_check = subprocess.run( - ["git", "cat-file", "-e", f"{frozen_commit}^{{commit}}"], - cwd=ROOT, - check=False, + registered = verify_manifest_source_hashes( + ROOT, + FIXTURES / "canonical_evidence_gate_v1.manifest.json", + manifest["artifacts"], ) - self.assertEqual( - commit_check.returncode, - 0, - "the manifest-introducing commit must be present; CI uses fetch-depth 0", - ) - evolving_surfaces = { - "README.md", - "tests/test_canonical_gate_evaluation.py", - } - for relative, expected_hash in manifest["artifacts"].items(): - if relative in evolving_surfaces: - frozen_bytes = subprocess.check_output( - ["git", "show", f"{frozen_commit}:{relative}"], - cwd=ROOT, - ) - actual = hashlib.sha256(frozen_bytes).hexdigest() - else: - actual = hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() - self.assertEqual(actual, expected_hash, relative) + self.assertEqual(registered.artifact_sha256, manifest["artifacts"]) for stage, relative in manifest["outputs"].items(): output = ROOT / relative if output.exists(): diff --git a/tests/test_corpus_integrity.py b/tests/test_corpus_integrity.py index cd0cb6a..693c8e2 100644 --- a/tests/test_corpus_integrity.py +++ b/tests/test_corpus_integrity.py @@ -1,11 +1,18 @@ from __future__ import annotations import hashlib +import json +import subprocess import tempfile import unittest from pathlib import Path -from neuron_graph_rag.corpus_integrity import verify_source_sha256 +from neuron_graph_rag.corpus_integrity import ( + registered_manifest_commit, + verify_historical_source_hashes, + verify_manifest_source_hashes, + verify_source_sha256, +) ROOT = Path(__file__).resolve().parents[1] @@ -78,5 +85,136 @@ def test_raw_mismatch_without_newlines_is_rejected(self) -> None: self.assertEqual(result.decision, "rejected_no_newline_conversion") +class HistoricalSourceVerificationTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self._git("init", "--initial-branch=main") + self._git("config", "user.name", "Historical Source Test") + self._git("config", "user.email", "historical-source@example.invalid") + self.artifact = self.root / "src" / "artifact.txt" + self.manifest = self.root / "tests" / "frozen.manifest.json" + self.artifact.parent.mkdir(parents=True) + self.manifest.parent.mkdir(parents=True) + self.registered_bytes = b"registered\nsource\n" + self.artifact.write_bytes(self.registered_bytes) + self.expected = hashlib.sha256(self.registered_bytes).hexdigest() + self.manifest.write_text( + json.dumps( + {"artifact_sha256": {"src/artifact.txt": self.expected}}, + indent=2, + ) + + "\n", + encoding="utf-8", + newline="\n", + ) + self._git("add", ".") + self._git("commit", "-m", "test: register historical source") + self.commit = self._git("rev-parse", "HEAD").stdout.strip() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def _git(self, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *arguments], + cwd=self.root, + check=True, + capture_output=True, + text=True, + ) + + def test_registered_commit_bytes_survive_current_working_tree_evolution(self) -> None: + self.artifact.write_bytes(b"later repository evolution\n") + self._git("add", "src/artifact.txt") + self._git("commit", "-m", "test: evolve current source") + + snapshot = verify_manifest_source_hashes( + self.root, + self.manifest, + {"src/artifact.txt": self.expected}, + ) + + self.assertEqual(snapshot.source_commit, self.commit) + self.assertEqual(snapshot.artifact_bytes["src/artifact.txt"], self.registered_bytes) + self.assertEqual(registered_manifest_commit(self.root, self.manifest), self.commit) + + def test_manifest_and_registered_blob_tampering_fail_closed(self) -> None: + original_manifest = self.manifest.read_bytes() + self.manifest.write_bytes(original_manifest + b" ") + with self.assertRaisesRegex(ValueError, "manifest differs"): + verify_manifest_source_hashes( + self.root, + self.manifest, + {"src/artifact.txt": self.expected}, + ) + + self.manifest.write_bytes(original_manifest) + self.artifact.write_bytes(b"tampered registered bytes\n") + self.manifest.write_text( + json.dumps( + { + "artifact_sha256": {"src/artifact.txt": self.expected}, + "revision": 2, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + newline="\n", + ) + self._git("add", ".") + self._git("commit", "-m", "test: forge registered source") + with self.assertRaisesRegex(ValueError, "historical source hash mismatch"): + verify_manifest_source_hashes( + self.root, + self.manifest, + {"src/artifact.txt": self.expected}, + ) + + def test_unknown_commit_and_missing_path_fail_closed(self) -> None: + with self.assertRaisesRegex(ValueError, "commit is unavailable"): + verify_historical_source_hashes( + self.root, + "0" * 40, + {"src/artifact.txt": self.expected}, + ) + with self.assertRaisesRegex(ValueError, "path is missing"): + verify_historical_source_hashes( + self.root, + self.commit, + {"src/missing.txt": self.expected}, + ) + + def test_nonancestor_commit_fails_closed(self) -> None: + tree = self._git("show", "-s", "--format=%T", self.commit).stdout.strip() + nonancestor = self._git( + "commit-tree", tree, "-m", "test: detached historical source" + ).stdout.strip() + + with self.assertRaisesRegex(ValueError, "not an ancestor"): + verify_historical_source_hashes( + self.root, + nonancestor, + {"src/artifact.txt": self.expected}, + ) + + def test_newline_alternate_is_explicit(self) -> None: + expected_crlf = hashlib.sha256(b"registered\r\nsource\r\n").hexdigest() + with self.assertRaisesRegex(ValueError, "hash mismatch"): + verify_historical_source_hashes( + self.root, + self.commit, + {"src/artifact.txt": expected_crlf}, + ) + accepted = verify_historical_source_hashes( + self.root, + self.commit, + {"src/artifact.txt": expected_crlf}, + allow_text_newline_alternate=True, + ) + self.assertEqual(accepted.artifact_bytes["src/artifact.txt"], self.registered_bytes) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_feedback_policy_comparison_evaluation.py b/tests/test_feedback_policy_comparison_evaluation.py index 6b322b9..3cdb875 100644 --- a/tests/test_feedback_policy_comparison_evaluation.py +++ b/tests/test_feedback_policy_comparison_evaluation.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import tempfile import unittest @@ -15,6 +14,7 @@ verify_registered_result, write_observed_exclusive, ) +from neuron_graph_rag.corpus_integrity import verify_manifest_source_hashes ROOT = Path(__file__).resolve().parents[1] @@ -55,12 +55,10 @@ def test_result_free_artifacts_are_canonical_and_complete(self) -> None: self.assertFalse(audit["placeholder_output_registered"]) manifest = read_json(MANIFEST_PATH) - for relative, expected in manifest["artifact_sha256"].items(): - self.assertEqual( - hashlib.sha256((ROOT / relative).read_bytes()).hexdigest(), - expected, - relative, - ) + verified = verify_manifest_source_hashes( + ROOT, MANIFEST_PATH, manifest["artifact_sha256"] + ) + self.assertEqual(verified.artifact_sha256, manifest["artifact_sha256"]) def test_source_projection_and_split_identity_are_frozen(self) -> None: fixture = _fixture("fixture") diff --git a/tests/test_node_first_selection.py b/tests/test_node_first_selection.py index eaa23cd..b1c1d78 100644 --- a/tests/test_node_first_selection.py +++ b/tests/test_node_first_selection.py @@ -248,6 +248,18 @@ class NodeFirstAggregationTest(unittest.TestCase): def test_synthetic_twelve_invocations_allow_channel_split_on_correct_node(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) + subprocess.run( + ["git", "init", "--initial-branch=main"], cwd=root, check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Node First Test"], + cwd=root, check=True, + ) + subprocess.run( + ["git", "config", "user.email", "node-first@example.invalid"], + cwd=root, check=True, + ) fixtures = root / "tests" / "fixtures" fixtures.mkdir(parents=True) prompt = fixtures / "prompt.txt" @@ -260,6 +272,18 @@ def test_synthetic_twelve_invocations_allow_channel_split_on_correct_node(self) prior_v2 = root / "prior-v2.txt" prior_v1.write_text("v1\n", encoding="utf-8") prior_v2.write_text("v2\n", encoding="utf-8") + subprocess.run( + ["git", "add", "prior-v1.txt", "prior-v2.txt"], + cwd=root, check=True, + ) + subprocess.run( + ["git", "commit", "-m", "test: freeze prior artifacts"], + cwd=root, check=True, capture_output=True, + ) + prior_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, + capture_output=True, text=True, + ).stdout.strip() gold_cases = [] for index in range(1, 5): gold: dict[str, object] = { @@ -321,6 +345,7 @@ def test_synthetic_twelve_invocations_allow_channel_split_on_correct_node(self) {"path": "prior-v1.txt", "sha256": _sha256_bytes(prior_v1.read_bytes()), "version": "v1"}, {"path": "prior-v2.txt", "sha256": _sha256_bytes(prior_v2.read_bytes()), "version": "v2"}, ], + "frozen_prior_commit": prior_commit, "judge_prompt": { "path": "tests/fixtures/prompt.txt", "canonical_sha256": _canonical_checkout_sha256(prompt), @@ -364,6 +389,11 @@ def test_synthetic_twelve_invocations_allow_channel_split_on_correct_node(self) _json_document_bytes({**common, "case": case}) ) case_paths.append(case_path) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "commit", "-m", "test: register node-first manifest"], + cwd=root, check=True, capture_output=True, + ) preflighted_case = preflight_node_first_capture( manifest_path, stage_path, case_paths[0] ) diff --git a/tests/test_real_task_shadow.py b/tests/test_real_task_shadow.py index 5f50ce6..e0eaf9c 100644 --- a/tests/test_real_task_shadow.py +++ b/tests/test_real_task_shadow.py @@ -1,7 +1,6 @@ from __future__ import annotations import copy -import hashlib import os import subprocess import sys @@ -12,6 +11,7 @@ from pathlib import Path from neuron_graph_rag import EngineConfig +from neuron_graph_rag.corpus_integrity import verify_manifest_source_hashes from neuron_graph_rag.real_task_shadow import ( ARM_IDS, PROTOCOL_ID, @@ -320,9 +320,10 @@ def test_result_free_artifacts_are_canonical_hashed_and_registered_outputs_absen manifest = read_canonical_json(MANIFEST) self.assertEqual(manifest["protocol_id"], PROTOCOL_ID) self.assertTrue(manifest["result_free"]) - for relative, expected in manifest["artifact_sha256"].items(): - actual = hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() - self.assertEqual(actual, expected, relative) + verified = verify_manifest_source_hashes( + ROOT, MANIFEST, manifest["artifact_sha256"] + ) + self.assertEqual(verified.artifact_sha256, manifest["artifact_sha256"]) for relative in manifest["registered_outputs"].values(): path = ROOT / relative self.assertTrue(not path.exists() or not any(path.iterdir()), relative) diff --git a/tests/test_real_task_shadow_v2.py b/tests/test_real_task_shadow_v2.py index f19dd05..4fd27d0 100644 --- a/tests/test_real_task_shadow_v2.py +++ b/tests/test_real_task_shadow_v2.py @@ -1,7 +1,6 @@ from __future__ import annotations import copy -import hashlib import os import subprocess import sys @@ -10,6 +9,7 @@ from pathlib import Path from neuron_graph_rag.evidence_feedback import EngineConfig +from neuron_graph_rag.corpus_integrity import verify_manifest_source_hashes from neuron_graph_rag.real_task_shadow import read_canonical_json, write_json_exclusive from neuron_graph_rag.real_task_shadow_v2 import ( PROTOCOL_ID, @@ -243,8 +243,10 @@ def test_cli_probe_and_result_free_manifest(self) -> None: manifest = read_canonical_json(MANIFEST) self.assertTrue(manifest["result_free"]) self.assertEqual(manifest["observation_status"], "not_started") - for relative, expected in manifest["artifact_sha256"].items(): - self.assertEqual(hashlib.sha256((ROOT / relative).read_bytes()).hexdigest(), expected, relative) + verified = verify_manifest_source_hashes( + ROOT, MANIFEST, manifest["artifact_sha256"] + ) + self.assertEqual(verified.artifact_sha256, manifest["artifact_sha256"]) for relative in manifest["registered_outputs"].values(): path = ROOT / relative self.assertTrue(not path.exists() or not any(path.iterdir()), relative) diff --git a/tests/test_real_task_shadow_v3.py b/tests/test_real_task_shadow_v3.py index 5617cb3..ad1c52f 100644 --- a/tests/test_real_task_shadow_v3.py +++ b/tests/test_real_task_shadow_v3.py @@ -296,7 +296,9 @@ def test_hash_canonical_slot_immutable_and_field_order_tamper_fail_closed(self) manifest["artifact_sha256"][relative] = "0" * 64 forged_manifest = root / "forged.manifest.json" forged_manifest.write_bytes(canonical_json_bytes(manifest)) - with self.assertRaisesRegex(ValueError, "hash mismatch"): + with self.assertRaisesRegex( + ValueError, "outside repository|not registered|hash mismatch" + ): audit_repository_lifecycle( forged_manifest, repository_root=ROOT, registered_root=root ) From 79cd836b4ab420a8bf15f6436bc7a2bff65fb9b5 Mon Sep 17 00:00:00 2001 From: lipluscodex <268560960+lipluscodex@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:20:35 +0900 Subject: [PATCH 2/2] fix: pin frozen source trust boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 親レビュー finding 1 は accept しました。manifest の最新変更 commit は committed rewrite で動くため、信頼境界を path の初回追加 commit に固定しました。manifest と hash を同じ後続 commit で書き換える regression も fail closed にしています。 親レビュー finding 2 は accept しました。branch、tag、短縮 SHA、revision expression は mutable または曖昧なため、明示 source commit を lowercase full 40-hex object ID に限定し、拒否 regression を追加しました。 frozen manifest、fixture、packet、snapshot、observed result、登録 hash、metric、gate、解釈は変更していません。Core と optional MCP を含む 234 tests と eval smoke が通過しています。 --- docs/Decision-Structure.md | 2 +- .../blind-llm-channel-selection-experiment.md | 2 +- ...e-backed-feedback-trajectory-experiment.md | 2 +- docs/feedback-policy-comparison-evaluation.md | 2 +- docs/historical-source-verification.md | 4 +- docs/real-task-feedback-shadow-protocol-v3.md | 6 +-- docs/requirements.md | 4 +- ...ing-normalization-controlled-evaluation.md | 2 +- src/neuron_graph_rag/corpus_integrity.py | 18 +++++--- tests/test_corpus_integrity.py | 46 ++++++++++++++++++- 10 files changed, 68 insertions(+), 20 deletions(-) diff --git a/docs/Decision-Structure.md b/docs/Decision-Structure.md index c6ebf49..7b2a249 100644 --- a/docs/Decision-Structure.md +++ b/docs/Decision-Structure.md @@ -51,7 +51,7 @@ edge target は Decision Structure node slug とする。外部資料は `Edges` - lowercase kebab-case の Decision Structure entry と `_Sidebar.md` は Wiki-only である。docs-to-Wiki synchronization は、`docs/` に対応物がないことを理由にこれらを create、overwrite、delete しない。 - 個別 Wiki entry は、その判断の current state の正本である。GitHub issue、pull request、commit、test output、fixture、gold、manifest、gate、result artifact は、それぞれの所有境界に従う根拠または契約であり、リンクしただけで Decision Structure entry にはならない。 - `requirements.md`、実験文書、fixture、gold、manifest、gate、result artifact は、既存の source-of-truth boundary を維持する。 -- frozen source / artifact hash の source of truth は、manifest 登録 commit または manifest が明示する source commit の exact blob bytes である。current working tree の同名 path は後続 evolution の現在状態であり、historical evidence ではない。 +- frozen source / artifact hash の source of truth は、manifest path の初回追加 commit または manifest が明示する lowercase full 40-hex source commit の exact blob bytes である。後続の committed manifest rewrite や mutable ref は信頼境界を動かさず、current working tree の同名 path は後続 evolution の現在状態であって historical evidence ではない。 ## Lifecycle diff --git a/docs/blind-llm-channel-selection-experiment.md b/docs/blind-llm-channel-selection-experiment.md index 15ad9b5..ad5e915 100644 --- a/docs/blind-llm-channel-selection-experiment.md +++ b/docs/blind-llm-channel-selection-experiment.md @@ -135,7 +135,7 @@ Development gateは不合格であり、`holdout_status=not_opened_invalid_judge PR #22の最初のLinux CI run `30731318651`では、Windowsでfreezeしたv1 text artifactのCRLF checkout byte hashと、GitHub ActionsのLF checkout byte hashが一致せず、`d1_liplus_channels.contamination.json`のfreeze auditで停止した。内容差ではなく、Gitのtext checkout変換だけが原因である。 -その後、mainから作成したfresh Windows worktreeでは、v2 judge prompt、development packet、judge artifactもLFの固定hashに対してCRLFでcheckoutされ、同じ内容のままmanifest読込とobserved artifact auditが停止した。verificationをv1だけに限定すると同じfrozen text契約をv2へ一貫して適用できないため、v1 は manifest が明示する baseline commit、v2 は manifest 登録 commit の blob bytes を取得し、frozen text-byte verificationを次の順序へ揃えた。current working tree の同名 path は historical evidence に使わない。 +その後、mainから作成したfresh Windows worktreeでは、v2 judge prompt、development packet、judge artifactもLFの固定hashに対してCRLFでcheckoutされ、同じ内容のままmanifest読込とobserved artifact auditが停止した。verificationをv1だけに限定すると同じfrozen text契約をv2へ一貫して適用できないため、v1 は manifest が明示する full baseline commit ID、v2 は manifest path の初回追加 commit の blob bytes を取得し、frozen text-byte verificationを次の順序へ揃えた。current working tree の同名 path は historical evidence に使わない。 1. checkout raw bytesのSHA-256を最初に照合する。 2. raw不一致時だけ、全改行がLF一種類ならCRLFへ、CRLF一種類ならLFへexact変換する。 diff --git a/docs/engine-backed-feedback-trajectory-experiment.md b/docs/engine-backed-feedback-trajectory-experiment.md index f8da768..351c614 100644 --- a/docs/engine-backed-feedback-trajectory-experiment.md +++ b/docs/engine-backed-feedback-trajectory-experiment.md @@ -14,7 +14,7 @@ repository-native controlled corpus v3 に対し、実際の `NeuronGraphRAG` in - Audit: `tests/fixtures/engine_feedback_trajectory_v3.audit.json` - Manifest: `tests/fixtures/engine_feedback_trajectory_v3.manifest.json` -manifest artifact は manifest 登録 commit、source corpus は上記 source corpus commit の exact blob bytes を読む。各 commit の存在と current `HEAD` の ancestor 関係を検証し、current working tree の同名 fixture、source document、evaluator は historical evidence として扱わない。既存の raw-first LF / CRLF whole-file alternate だけを維持する。 +manifest artifact は manifest path の初回追加 commit、source corpus は上記 source corpus commit の exact blob bytes を読む。各 commit の存在と current `HEAD` の ancestor 関係を検証し、current working tree の同名 fixture、source document、evaluator は historical evidence として扱わない。既存の raw-first LF / CRLF whole-file alternate だけを維持する。 development は `signal-stability` と `boundary-recovery`、holdout は `evidence-continuity` を使う。node ID、document path、source URL、explicit-link edge は split 間で重複させない。edge は overview 文書に記載された同一 directory 内の相対 Markdown link だけから固定する。 diff --git a/docs/feedback-policy-comparison-evaluation.md b/docs/feedback-policy-comparison-evaluation.md index a6401d4..4fcddf6 100644 --- a/docs/feedback-policy-comparison-evaluation.md +++ b/docs/feedback-policy-comparison-evaluation.md @@ -10,7 +10,7 @@ protocol ID は `policycmp85-feedback-policy-comparison-v1` である。fixture、gold、query、cohort role、graph projection、engine config、event order、checkpoint、metric、gate array、result schema、exclusive writer、verifier、output path を observed result 不在の freeze commit に固定して push する。 -protocol artifact hash は manifest 登録 commit の exact blob bytes に対して照合し、検証後の fixture、gold、schedule、gate、schema、audit も同じ登録 blob から読む。current working tree の同名 evaluator、test、README、docs は後続 evolution であり、過去の protocol bytes として扱わない。source corpus は引き続き manifest の `source_commit` にある manifest と文書の exact bytes だけを読む。 +protocol artifact hash は manifest path の初回追加 commit の exact blob bytes に対して照合し、検証後の fixture、gold、schedule、gate、schema、audit も同じ初回登録 blob から読む。current working tree の同名 evaluator、test、README、docs は後続 evolution であり、過去の protocol bytes として扱わない。source corpus は引き続き manifest の lowercase full 40-hex `source_commit` にある manifest と文書の exact bytes だけを読む。 development は Amber を `confirmed-use`、Cobalt を `corrected-use` に割り当てる。全 hard gate 通過時だけ開く holdout は Quartz を `confirmed-use`、Willow を `corrected-use` に割り当てる。split 間では node、path、source URL、edge identity、query を共有しない。各 arm は同じ split の8文書、6 edge、query、event order を使う。 diff --git a/docs/historical-source-verification.md b/docs/historical-source-verification.md index 6a63060..a977de7 100644 --- a/docs/historical-source-verification.md +++ b/docs/historical-source-verification.md @@ -6,8 +6,8 @@ frozen evaluation の hash registry は、過去の protocol を固定した時 ## Source-of-truth boundary -- manifest 内の artifact hash は、その manifest の登録 commit にある `git show :` の exact bytes と照合する。登録 commit は current manifest path を最後に変更した commit とし、current manifest bytes 自体がその commit と一致することを先に検証する。 -- manifest が source commit、baseline commit、prior commit を明示する source registry は、その明示 commit の blob を照合する。 +- manifest 内の artifact hash は、その manifest path を最初に追加した登録 commit にある `git show :` の exact bytes と照合する。後続の committed rewrite を新しい信頼点にせず、current manifest bytes 自体が初回登録 blob と一致することを先に検証する。 +- manifest が source commit、baseline commit、prior commit を明示する source registry は、その明示 commit の blob を照合する。明示 commit は lowercase full 40-hex object ID に限定し、branch、tag、`HEAD~1` 等の mutable ref / revision expression を拒否する。 - commit object が存在し、現在の `HEAD` の ancestor であることを必須とする。未知 commit、非 ancestor commit、欠落 path、hash 不一致、working-tree manifest 改変は fail closed にする。 - 同名 path の current working-tree bytes は historical evidence ではない。検証後に evaluator が frozen fixture、gold、schedule、gate、source document を読む場合も、検証済み commit blob を使う。 diff --git a/docs/real-task-feedback-shadow-protocol-v3.md b/docs/real-task-feedback-shadow-protocol-v3.md index 64d3bb6..bba3f71 100644 --- a/docs/real-task-feedback-shadow-protocol-v3.md +++ b/docs/real-task-feedback-shadow-protocol-v3.md @@ -35,8 +35,8 @@ local exact verifier は明示された SQLite snapshot を必要とする。liv repository lifecycle audit は repository 内 artifact だけを使い、snapshot replay を担当しない。次を検証する。 -- v3 manifest 登録 commit にある protocol / legacy frozen artifact の exact blob hash -- 登録 commit の存在と current `HEAD` の ancestor 関係、および current manifest bytes と登録 manifest bytes の一致 +- v3 manifest path の初回追加 commit にある protocol / legacy frozen artifact の exact blob hash +- 初回追加 commit の存在と current `HEAD` の ancestor 関係、および current manifest bytes と初回登録 manifest bytes の一致 - packet と aggregate の canonical JSON bytes - root slot の連番、correction chain の到達可能性、immutable correction field - effective packet 全体で共通の snapshot hash、capture config、search surface と、slot 順に厳密増加する capture timestamp @@ -61,4 +61,4 @@ python tools/run_real_task_shadow_v3.py audit-lifecycle --manifest tests/fixture `probe` は実 MCP adapter search から placeholder packet、snapshot exact verification、two-arm replay、one-time result までを一時 directory 内で一巡し、registered output を repository に生成しない。`audit-lifecycle` は snapshot を使わず、freeze 後の repository lifecycle state を検証する。 -同名 path の current working tree は repository evolution の現在状態であり、v1 / v2 / v3 の historical evidence ではない。lifecycle audit は manifest、packet、aggregate、gate、観測結果を変更せず、hash 取得元だけを登録 commit の blob に固定する。 +同名 path の current working tree は repository evolution の現在状態であり、v1 / v2 / v3 の historical evidence ではない。lifecycle audit は manifest、packet、aggregate、gate、観測結果を変更せず、hash 取得元だけを manifest path の初回追加 commit の blob に固定する。 diff --git a/docs/requirements.md b/docs/requirements.md index 3083748..a4efe6e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -98,7 +98,7 @@ 79. repository-native controlled corpus v3 の engine-backed trajectory experiment は、source commit、split / cluster identity、explicit-link edge、0 / 1 / 3 / 10 feedback schedule、query、used node、credited path、control / treatment、gate、manifest hash、exclusive output を観測前に固定する。control は relation trace と used node を記録して edge を変更せず、treatment だけが同じ schedule の relation trace ID を `record_success` に渡す。headroom は 0 から 10 で厳密改善し途中 checkpoint で退行せず、control case と ceiling case も退行せず、credited edge 以外が変化しない場合だけ development gate を通過する。development 全 gate 通過時だけ holdout を一度開き、観測後は evaluator、fixture、gold、schedule、manifest、gate、docs を変更しない。 80. sibling relation feedback normalization は、明示的に有効化された candidate config でのみ、relation trace の credited edge を強化し、その edge と同じ source から出る未 credit sibling だけを局所的に正規化できる。lexical trace、zero-hop、未関係 source、credited sibling は変更しない。candidate は synthetic isolation test と result-free development / holdout 相当の relation、direct、lexical、negative-control gate を通過するまで default にしない。 81. sibling normalization controlled evaluation は、評価対象 source commit / hash、相互に identity-disjoint な development / holdout cluster、明示 edge、baseline `0.0` / treatment `1.0`、query、used node、credited path、mutation scope、rollback、係数 / 時刻 schedule、hard gate、exclusive output を観測前に固定する。実 `NeuronGraphRAG` の `search_channels` relation trace ID を `record_success` に渡し、headroom strict improvement、ceiling・direct・lexical・directional-negative non-regression、path・mutation・atomicity・determinism の全 development gate 通過時だけ holdout を一度開く。観測後は protocol artifact と docs を変更せず、既定値と external D1 claim を変更しない。 -82. frozen evaluation の historical source hash は、manifest 登録 commit または manifest が明示する source / baseline / prior commit の exact blob bytes に対して検証する。commit object の存在、現在 `HEAD` の ancestor 関係、manifest bytes、登録 path、hash を fail closed に照合し、同名 path の current working tree を過去の evidence として扱わない。既存 protocol が明記する raw-first LF / CRLF whole-file alternate だけを維持し、本文差、mixed newline、bare CR、その他の byte 差を拒否する。 +82. frozen evaluation の historical source hash は、manifest path の初回追加 commit または manifest が明示する lowercase full 40-hex source / baseline / prior commit の exact blob bytes に対して検証する。後続の committed manifest rewrite、mutable ref / revision expression、未知 commit、非 ancestor commit、manifest bytes差、欠落 path、hash 不一致を fail closed にし、同名 path の current working tree を過去の evidence として扱わない。既存 protocol が明記する raw-first LF / CRLF whole-file alternate だけを維持し、本文差、mixed newline、bare CR、その他の byte 差を拒否する。 75. v3 implementation、prompt、manifest、query override、schema、集約、path audit、hash規則、gate、stop rule、testsをresult-free commitでpushした後、development stage / 4 case packet / 12 responses / resultを各一度だけ生成する。 76. development全12 gate通過時だけholdout stageを一度生成し、異なるfresh 12 judgesで同じgateを評価する。packet、response、resultの上書き、観測後の規則変更、実LLM品質値のCI再生成を拒否する。 @@ -133,4 +133,4 @@ - [Engine-backed feedback trajectory experiment](engine-backed-feedback-trajectory-experiment.md) が repository-native controlled corpus v3 上の 0 / 1 / 3 / 10 feedback trajectory、実 relation trace、credited-only mutation、result-free freeze、conditional holdout を定義する。 - [Sibling relation feedback normalization](sibling-relation-feedback-normalization.md) が opt-in candidate の局所 sibling 正規化、trace isolation、default 変更前の検証境界を定義する。 - [Sibling normalization controlled evaluation](sibling-normalization-controlled-evaluation.md) が repository-native corpus、result-free hash freeze、実 relation trace feedback、mutation / rollback gate、conditional holdout を定義する。 -- [Historical source verification](historical-source-verification.md) が frozen manifest / source registry の登録 commit、exact blob、ancestor、path、newline portability、fail-closed 境界を定義する。 +- [Historical source verification](historical-source-verification.md) が frozen manifest path の初回追加 commit、明示 full source commit ID、exact blob、ancestor、path、newline portability、fail-closed 境界を定義する。 diff --git a/docs/sibling-normalization-controlled-evaluation.md b/docs/sibling-normalization-controlled-evaluation.md index 25f8281..4f81efd 100644 --- a/docs/sibling-normalization-controlled-evaluation.md +++ b/docs/sibling-normalization-controlled-evaluation.md @@ -10,7 +10,7 @@ `sibling_normalization_controlled_v1` の fixture、gold、schedule、manifest、gate、evaluator、runner、test と本書を、observed output が存在しない状態で commit / push する。manifest は評価対象の engine source commit と SHA-256、登録 artifact の SHA-256、split / cluster identity、exclusive output path を固定する。 -評価対象 engine source は manifest の明示 commit、protocol artifact は manifest 登録 commit の exact blob bytes を読む。commit の存在と current `HEAD` の ancestor 関係、manifest bytes、path、hash を検証し、current working tree の同名 path は historical evidence として扱わない。 +評価対象 engine source は manifest の明示 full commit ID、protocol artifact は manifest path の初回追加 commit の exact blob bytes を読む。commit の存在と current `HEAD` の ancestor 関係、manifest bytes、path、hash を検証し、current working tree の同名 path は historical evidence として扱わない。 development と holdout は node ID、cluster ID、query 語彙を共有しない。過去の feedback trajectory evaluator、fixture、gold、schedule、manifest、gate、observed result は選択入力にも実行入力にも使わない。 diff --git a/src/neuron_graph_rag/corpus_integrity.py b/src/neuron_graph_rag/corpus_integrity.py index 356f608..5feec22 100644 --- a/src/neuron_graph_rag/corpus_integrity.py +++ b/src/neuron_graph_rag/corpus_integrity.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import re import subprocess from collections.abc import Mapping from dataclasses import dataclass @@ -35,17 +36,20 @@ def registered_manifest_commit( ) -> str: """Resolve the commit that registered the current frozen manifest bytes. - The manifest itself remains the immutable registry. Its most recent commit - is therefore the source boundary for the hashes it contains; later working - tree changes must not move that boundary. + The manifest itself remains the immutable registry. The commit that first + added its current path is therefore the source boundary for the hashes it + contains; later committed or working-tree changes must not move it. """ root = Path(repository_root).resolve() relative = _repository_relative(root, manifest_path) - completed = _git(root, "log", "-1", "--format=%H", "--", relative) - commit = completed.stdout.decode("ascii", errors="strict").strip() - if not commit: + completed = _git(root, "log", "--diff-filter=A", "--format=%H", "--", relative) + commits = completed.stdout.decode("ascii", errors="strict").splitlines() + if not commits: raise ValueError(f"frozen manifest is not registered in git: {relative}") + if len(commits) != 1: + raise ValueError(f"frozen manifest has multiple registration commits: {relative}") + commit = commits[0] _verify_commit_boundary(root, commit) registered = _git_bytes(root, commit, relative) current = (root / relative).read_bytes() @@ -251,7 +255,7 @@ def _repository_relative(root: Path, path: str | Path) -> str: def _verify_commit_boundary(root: Path, commit: str) -> None: - if not commit or any(character.isspace() for character in commit): + if re.fullmatch(r"[0-9a-f]{40}", commit) is None: raise ValueError(f"invalid historical source commit: {commit!r}") try: _git(root, "cat-file", "-e", f"{commit}^{{commit}}") diff --git a/tests/test_corpus_integrity.py b/tests/test_corpus_integrity.py index 693c8e2..053101f 100644 --- a/tests/test_corpus_integrity.py +++ b/tests/test_corpus_integrity.py @@ -165,13 +165,40 @@ def test_manifest_and_registered_blob_tampering_fail_closed(self) -> None: ) self._git("add", ".") self._git("commit", "-m", "test: forge registered source") - with self.assertRaisesRegex(ValueError, "historical source hash mismatch"): + with self.assertRaisesRegex(ValueError, "manifest differs"): verify_manifest_source_hashes( self.root, self.manifest, {"src/artifact.txt": self.expected}, ) + def test_committed_manifest_rewrite_does_not_move_registration_boundary(self) -> None: + tampered = b"committed replacement\n" + tampered_hash = hashlib.sha256(tampered).hexdigest() + self.artifact.write_bytes(tampered) + self.manifest.write_text( + json.dumps( + {"artifact_sha256": {"src/artifact.txt": tampered_hash}}, + indent=2, + ) + + "\n", + encoding="utf-8", + newline="\n", + ) + self._git("add", ".") + self._git("commit", "-m", "test: rewrite frozen manifest and source") + + with self.assertRaisesRegex(ValueError, "manifest differs"): + verify_manifest_source_hashes( + self.root, + self.manifest, + {"src/artifact.txt": tampered_hash}, + ) + self.assertNotEqual( + self._git("rev-parse", "HEAD").stdout.strip(), + self.commit, + ) + def test_unknown_commit_and_missing_path_fail_closed(self) -> None: with self.assertRaisesRegex(ValueError, "commit is unavailable"): verify_historical_source_hashes( @@ -199,6 +226,23 @@ def test_nonancestor_commit_fails_closed(self) -> None: {"src/artifact.txt": self.expected}, ) + def test_mutable_refs_and_revision_expressions_are_rejected(self) -> None: + for source_commit in ( + "HEAD", + "main", + "HEAD~1", + "refs/heads/main", + self.commit[:12], + self.commit.upper(), + ): + with self.subTest(source_commit=source_commit): + with self.assertRaisesRegex(ValueError, "invalid historical source commit"): + verify_historical_source_hashes( + self.root, + source_commit, + {"src/artifact.txt": self.expected}, + ) + def test_newline_alternate_is_explicit(self) -> None: expected_crlf = hashlib.sha256(b"registered\r\nsource\r\n").hexdigest() with self.assertRaisesRegex(ValueError, "hash mismatch"):