diff --git a/agents/critic/scripts/check_seal_timeline.py b/agents/critic/scripts/check_seal_timeline.py new file mode 100644 index 0000000..3596854 --- /dev/null +++ b/agents/critic/scripts/check_seal_timeline.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""회고적 사전등록 탐지 게이트 (Registry-Replay A2 → gate). + +"sealed-forward"라 주장한 예측이 실제로는 결과보다 늦게 커밋됐는지를 git 커밋 시각으로 검증한다. +PITFALLS A2("봉인의 진위는 시각이 가른다") + Registry-Replay MISSED 3건 중 최우선 구멍을 메운다. +7종 CI 중 git 이력을 보는 게이트가 없어 이 사고를 못 잡았다 — 이 스크립트가 8번째 게이트다. + +입력: seal manifest JSON + {"seals": [ + {"claim": "폐 histology sealed-forward", + "prediction_commit": "", "result_commit": ""}, # 방식 A: 커밋 SHA + {"claim": "...", "prediction_path": "", "result_path": ""} # 방식 B: 파일 마지막 커밋 + ]} + +판정: 각 seal에 대해 prediction 커밋 시각 < result 커밋 시각 이어야 통과. + prediction >= result 이면 회고적(retrospective)이므로 "sealed-forward" 주장 무효 → FAIL. +규약: 결정론(커밋 시각은 불변). 게이트를 점수 좋게 손대는 것 금지(A5 골대이동). +""" +import argparse, json, subprocess, sys +from pathlib import Path + + +def commit_ct(sha: str) -> int: + """커밋 SHA의 committer timestamp(epoch). 실패 시 예외.""" + out = subprocess.run(["git", "show", "-s", "--format=%ct", sha], + capture_output=True, text=True) + if out.returncode != 0 or not out.stdout.strip(): + raise ValueError(f"커밋 시각 조회 실패: {sha} ({out.stderr.strip()})") + return int(out.stdout.strip().splitlines()[-1]) + + +def path_last_ct(path: str) -> int: + """파일 경로의 마지막 커밋 committer timestamp(epoch).""" + out = subprocess.run(["git", "log", "-1", "--format=%ct", "--", path], + capture_output=True, text=True) + if out.returncode != 0 or not out.stdout.strip(): + raise ValueError(f"경로 커밋 시각 조회 실패: {path} ({out.stderr.strip()})") + return int(out.stdout.strip()) + + +def resolve(seal: dict): + if "prediction_commit" in seal and "result_commit" in seal: + return commit_ct(seal["prediction_commit"]), commit_ct(seal["result_commit"]) + if "prediction_path" in seal and "result_path" in seal: + return path_last_ct(seal["prediction_path"]), path_last_ct(seal["result_path"]) + raise ValueError(f"seal에 prediction/result (commit 또는 path) 쌍이 없음: {seal.get('claim')}") + + +def main() -> int: + ap = argparse.ArgumentParser(description="회고적 사전등록 탐지 게이트") + ap.add_argument("manifest", help="seal manifest JSON") + ap.add_argument("--json", help="결과 JSON 출력 경로") + a = ap.parse_args() + + data = json.loads(Path(a.manifest).read_text()) + seals = data.get("seals", []) + if not seals: + print("[check_seal_timeline] seal 0건 — 검사 대상 없음(통과)") + return 0 + + results, failed = [], 0 + for s in seals: + claim = s.get("claim", "(unnamed)") + try: + pred_ct, res_ct = resolve(s) + except ValueError as e: + print(f" ERROR {claim}: {e}") + results.append({"claim": claim, "verdict": "ERROR", "detail": str(e)}) + failed += 1 + continue + ok = pred_ct < res_ct + verdict = "SEALED-FORWARD" if ok else "RETROSPECTIVE" + if not ok: + failed += 1 + print(f" {'PASS' if ok else 'FAIL'} {claim}: prediction_ct={pred_ct} " + f"{'<' if ok else '>='} result_ct={res_ct} → {verdict}") + results.append({"claim": claim, "prediction_ct": pred_ct, + "result_ct": res_ct, "verdict": verdict}) + + summary = {"n_seals": len(seals), "n_failed": failed, + "pass": failed == 0, "results": results} + if a.json: + Path(a.json).write_text(json.dumps(summary, ensure_ascii=False, indent=2)) + if failed: + print(f"[check_seal_timeline] FAIL — {failed}/{len(seals)}건이 회고적(예측이 결과보다 늦음). " + f"'sealed-forward' 주장 무효.") + return 1 + print(f"[check_seal_timeline] PASS — {len(seals)}건 모두 예측이 결과보다 먼저 커밋됨.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/critic/seal_manifests/README.md b/agents/critic/seal_manifests/README.md new file mode 100644 index 0000000..d37792d --- /dev/null +++ b/agents/critic/seal_manifests/README.md @@ -0,0 +1,16 @@ +# seal_manifests — 회고적 사전등록 탐지 + +Registry-Replay(2026-08-20, `experiments/kkkim/20260820_registry_replay/`)가 드러낸 구멍 **A2**를 메우는 게이트의 입력. + +- **게이트**: `agents/critic/scripts/check_seal_timeline.py` +- **자기검증**: `agents/critic/tests/test_seal_timeline.py` (비공허 mutation 테스트 — 정상 통과 + 회고적 검출 둘 다 확인) +- **판정**: "sealed-forward"라 주장한 예측이 결과보다 **먼저** 커밋됐는지 git 시각으로 검증. 늦었으면(회고적) FAIL. + +## 왜 +7종 CI 중 git 이력을 보는 게이트가 없어 A2("봉인의 진위는 시각이 가른다")를 못 잡았다. 결정론 게이트로 기계화 가능한 3건 중 분석-무결성 직결 최우선 구멍. + +## CI 배선 전 필수 (공허 게이트 방지) +빈 매니페스트는 seal 0건이라 무조건 통과한다 = **공허 게이트**(우리가 금지한 anti-pattern). 따라서: +1. 먼저 실제 봉인 주장을 매니페스트에 채운다(SHA 또는 path). 근거 = `LAW_HELDOUT_SCOREBOARD.md`. +2. 채운 뒤에 `critic-validators.yml`에 8번째 스텝으로 배선(Critic=braveji 판단). +빈 채로 CI에 넣으면 "통과만 하는 게이트"라 이 게이트의 존재 이유와 모순된다. diff --git a/agents/critic/seal_manifests/TEMPLATE.json b/agents/critic/seal_manifests/TEMPLATE.json new file mode 100644 index 0000000..15bceed --- /dev/null +++ b/agents/critic/seal_manifests/TEMPLATE.json @@ -0,0 +1,13 @@ +{ + "_doc": "회고적 사전등록 탐지용 seal manifest. check_seal_timeline.py 입력.", + "_rule": "각 seal: prediction 커밋 시각 < result 커밋 시각 이어야 sealed-forward. 위반 시 게이트 FAIL.", + "_how": "prediction/result_commit = git SHA, 또는 prediction/result_path = 파일(마지막 커밋 시각).", + "_populate": "실제 봉인 주장(예: 교차암 폐·위·두경부 sealed-forward)을 여기 채운다. 근거 타임라인 = experiments/crosscancer/LAW_HELDOUT_SCOREBOARD.md('폐 예측 07-12 05:06 < 결과 15:35' 등).", + "seals": [ + { + "claim": "(예시) 폐 histology sealed-forward", + "prediction_commit": "<예측 커밋 SHA>", + "result_commit": "<결과 커밋 SHA>" + } + ] +} diff --git a/agents/critic/seal_manifests/VALIDATION_2026-08-20.md b/agents/critic/seal_manifests/VALIDATION_2026-08-20.md new file mode 100644 index 0000000..e8c85d5 --- /dev/null +++ b/agents/critic/seal_manifests/VALIDATION_2026-08-20.md @@ -0,0 +1,18 @@ +# A2 게이트 실데이터 검증 (2026-08-20) + +Registry-Replay가 MISSED로 지목한 A2(회고적 사전등록)를 게이트로 만든 뒤, **실제 교차암 커밋에 돌려** 검증했다. 근거 타임라인 = `experiments/crosscancer/LAW_HELDOUT_SCOREBOARD.md` §인식론 구분. + +## 결과 — 게이트가 sealed-forward와 retrospective를 git 시각만으로 정확히 구분 + +| 코호트 | 예측(봉인) 커밋 | 결과 커밋 | 게이트 판정 | 스코어보드 라벨 | +|---|---|---|---|---| +| 폐 | 77c0633 (07-12 05:06) | 994b187 (07-12 15:35) | ✅ SEALED-FORWARD | sealed ✓일치 | +| 위 | b5b0088 (07-12 05:18) | 2760fb8 (07-12 16:22) | ✅ SEALED-FORWARD | sealed ✓일치 | +| 두경부 | b5b0088 (07-12 05:18) | 28eb0af (07-13 20:36) | ✅ SEALED-FORWARD | sealed ✓일치 | +| **대장(데모)** | 77c0633 (07-12 05:06) | **afedc6a (07-12 04:45)** | ❌ **RETROSPECTIVE** | **retrospective ✓일치** | + +**핵심**: 게이트가 사람이 손으로 붙인 라벨(스코어보드의 정직한 sealed/retrospective 구분)을 **git 커밋 시각만으로 독립 재현**했다. 폐·위·두경부 봉인 주장은 실증됐고, 대장은 정확히 회고적으로 걸린다(스코어보드가 이미 정직하게 회고적이라 라벨한 것과 일치). + +- 적용(enforcement) 매니페스트 = `crosscancer_seals.json`(폐·위·두경부 3건 = sealed-forward 주장 → 전부 PASS). +- 대장은 sealed-forward로 **주장하지 않으므로** enforcement에 넣지 않는다(정직 라벨 그대로). 위 표의 대장은 게이트가 회고적을 잡는지 보이는 **데모**. +- 규율: 게이트 튜닝 없음, 커밋 시각은 불변(결정론). incident(A2)→gate→실데이터 검증 루프 완결. diff --git a/agents/critic/seal_manifests/crosscancer_seals.json b/agents/critic/seal_manifests/crosscancer_seals.json new file mode 100644 index 0000000..8789072 --- /dev/null +++ b/agents/critic/seal_manifests/crosscancer_seals.json @@ -0,0 +1,21 @@ +{ + "_doc": "교차암 sealed-forward 봉인 검증 (A2 게이트 실데이터). 근거 LAW_HELDOUT_SCOREBOARD.md.", + "_rule": "예측(사전등록 봉인) 커밋이 결과(mil_cost_results.json) 커밋보다 먼저여야 sealed-forward.", + "seals": [ + { + "claim": "폐 held-out sealed-forward", + "prediction_commit": "77c063306209771a6655ec18667dc1e6091ad3e7", + "result_commit": "994b18734d89eb0ab867bd59ddd42c39c00d4bb2" + }, + { + "claim": "위 held-out sealed-forward", + "prediction_commit": "b5b0088cd5257db75f384cd3f9e99b7fb7d3fcb6", + "result_commit": "2760fb8af9175dbba2d0ce04d8dd0ea041767558" + }, + { + "claim": "두경부 held-out sealed-forward", + "prediction_commit": "b5b0088cd5257db75f384cd3f9e99b7fb7d3fcb6", + "result_commit": "28eb0af3ba043308eb5b0f453eb56afea87f6b7f" + } + ] +} \ No newline at end of file diff --git a/agents/critic/tests/test_seal_timeline.py b/agents/critic/tests/test_seal_timeline.py new file mode 100644 index 0000000..bfb41d2 --- /dev/null +++ b/agents/critic/tests/test_seal_timeline.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""check_seal_timeline 게이트 자기검증 (비공허 mutation 테스트). + +게이트가 "통과만 하는" 공허한 게이트가 아님을 증명한다: + - 정상(예측이 결과보다 먼저 커밋) → PASS(exit 0) + - mutant(예측이 결과보다 늦게 = 회고적) → FAIL(exit 1) +둘 다 기대대로여야 이 테스트가 통과한다. (critic-validators.yml의 게이트 mutation 규율) + +임시 git 저장소를 만들어 커밋 시각을 GIT_COMMITTER_DATE로 통제한다 — 결정론·자기완결. +""" +import json, os, subprocess, sys, tempfile +from pathlib import Path + +GATE = Path(__file__).resolve().parents[1] / "scripts" / "check_seal_timeline.py" + + +def run(cmd, cwd, env=None): + return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True) + + +def git_commit(repo, fname, when_epoch, env0): + (Path(repo) / fname).write_text(fname + "\n") + run(["git", "add", fname], repo, env0) + env = dict(env0) + env["GIT_AUTHOR_DATE"] = f"{when_epoch} +0000" + env["GIT_COMMITTER_DATE"] = f"{when_epoch} +0000" + run(["git", "commit", "-m", f"add {fname}"], repo, env) + sha = run(["git", "rev-parse", "HEAD"], repo, env0).stdout.strip() + return sha + + +def main() -> int: + with tempfile.TemporaryDirectory() as repo: + env0 = dict(os.environ) + env0["GIT_AUTHOR_NAME"] = env0["GIT_COMMITTER_NAME"] = "test" + env0["GIT_AUTHOR_EMAIL"] = env0["GIT_COMMITTER_EMAIL"] = "test@test" + run(["git", "init", "-q"], repo, env0) + sha_pred = git_commit(repo, "prediction.txt", 1_700_000_000, env0) # 먼저 + sha_res = git_commit(repo, "result.txt", 1_700_009_999, env0) # 나중 + + ok_manifest = Path(repo) / "ok.json" + ok_manifest.write_text(json.dumps({"seals": [ + {"claim": "정상 sealed-forward", "prediction_commit": sha_pred, "result_commit": sha_res}]})) + mut_manifest = Path(repo) / "mut.json" + mut_manifest.write_text(json.dumps({"seals": [ + {"claim": "회고적 mutant", "prediction_commit": sha_res, "result_commit": sha_pred}]})) + + r_ok = run([sys.executable, str(GATE), str(ok_manifest)], repo, env0) + r_mut = run([sys.executable, str(GATE), str(mut_manifest)], repo, env0) + + pass_ok = r_ok.returncode == 0 + fail_mut = r_mut.returncode == 1 + print("정상 케이스 exit", r_ok.returncode, "(기대 0):", "OK" if pass_ok else "실패") + print("mutant 케이스 exit", r_mut.returncode, "(기대 1):", "OK" if fail_mut else "실패") + if pass_ok and fail_mut: + print("✅ 게이트 비공허 확인 — 정상 통과 + 회고적 검출") + return 0 + print("❌ 게이트가 공허하거나 오작동") + print(" ok stdout:", r_ok.stdout.strip()[:200]) + print(" mut stdout:", r_mut.stdout.strip()[:200]) + return 1 + + +if __name__ == "__main__": + sys.exit(main())