Merge commit '806de25068bc423136063791afcea811d251ee1b' into HEAD

This commit is contained in:
Agent Control Runner
2026-10-17 19:59:18 +09:00
5 changed files with 17848 additions and 0 deletions
@@ -0,0 +1,75 @@
# P19-03 実装提出報告
提出状態。受入は未確定。原受入「7名×3ルートの到達表があり、未制作内容は欠損として残る。」を維持する。14/88は変更しない。
## 変更範囲
修復開始点e0420d57a381a1644357f0590536e1d47c9bca9fから、このディレクトリの指定5ファイルのみを変更した。原本、原子要件、work packages、decisions、TASK_SPEC、選定記録、frozen pins、ゲーム入力、marker registryは変更していない。git書込み、他worker起動、Runner操作、ゲーム起動は行っていない。素材追加なし。
## 要件と検査の対応
|TASK_SPEC implementation_requirements順|成果物|検査|
|---|---|---|
|1: 原7名×3ルート|REACHABILITY_TABLE.json / .md|expectedのPEOPLE/ROUTES、原本118–124行、atomic entityと21セルの完全一致。test_exact_cell_product|
|2: 8カテゴリ、共通/人物固有の分離|JSON common_rules / cells.categories|CATEGORIES全件のcanonical比較。test_categories_and_citations|
|3: 原本本文・行・status・anchorのsource SHA・owner|JSONの各共通項目/人物固有項目、catalog_evidence|expected.evidence/cite、verify。test_stale_record_evidence / test_owner_and_status|
|4: 欠損、理由、担当、未知値pending|各character_specific、pending、natural_route_complete=false|test_no_invented_values_or_completion。D05/D08は未回答のまま|
|5: docs内stdlibチェッカー/自己試験|check_reachability_table.py / test_reachability_table.py|stdlib importsのみ。選択対象外のdocs内配置|
|6: read-only --check、厳密な表/Markdown|verify / verify_records / markdown|test_json_and_types / test_markdown_drift。--checkは書込み分岐を通らない|
|7: 全frozen source mapと各identity|binding / selection|1368選択ファイルの完全map、marker/policy/engine、184-suite roster、329 .gd、baseline、spec→pins SHA。test_full_frozen_map_and_identities / test_real_source_and_baseline_changes|
|8: UI/Runtime/save/Windowsの適用性|JSON route_support、MDルート別適用性|各ルートをcanonical比較。汎用UIラベル、原本登録Runtime、専用snapshotの限定証拠をcommon_rulesで参照|
|9: 有意な負例と3種の正例|test_reachability_table.py|欠落/重複/誤人物/誤route/カテゴリ/引用/古い本文・SHA/値創作/誤完成/owner・status/JSON型/Markdown、実ファイル読込差替え、map差分とpins/marker/policy/engine改変。全3statusを含む正例|
|10: P13パターン・原文保全|checkerのexpected/verify/markdown、原資料のbase blob照合|tools/check_p13_content_difference.pyのcanonical生成・厳密比較・read-only Git blob証拠パターンを参照。元ファイルは無変更|
|11: 報告・残課題・外部gate|本報告、JSON accounting|正式試験・全単位独立レビュー・統合/finalは未実行と明記|
原子要件はB-7.1〜B-7.6をsource_group_idsに含む61件の完全レコードを読込対象とし、JSON authority_snapshotsに保持する。人物キーと原名はそのentityから確認し、原本§6の順で並べた。原本§6は114–135行、§7は136–161行であり、実際の見出しと本文を照合した。人物別の原本仕様がない部分を共通ルールから創作していない。中断/保存の人物固有手順は原本に定義されず、work_packagesのP19-02受入が所有する欠損として残した。
指定されたoriginal_relationship_state.gdには7名のrosterはなく、汎用subject_id等の明示入力検証のみがある。既存unique catalogのヒロインはheroine_anriのみ。監査IDには既存atomic entity.key(anri/fine/tomari/amelia/liz/mimi/satsuki)を使い、他6名の実装catalog ID、ライバル対応、初期値を創作していない。この実装欠損は表の欠損であり、原7名を削減する理由にしていない。
## Repair 1: 4指摘の対応
編集前に build/owner-handoff/p19-review-r01/ の ACTUAL_OPUS_REPAIR_DIRECTION.json、ACTUAL_WHOLE_UNIT_REVIEW.json、SPEC_HASH_REPRESENTATIONS.json を読んだ。
- MEDIUM: coassignment_attack / zero_break / love_actions / threshold_route_loss と旧UIのlegacy_evidenceを追加。固定anchorの実際の本文・行・Git blob OID・file-bytes SHA-256を保持する。原本準拠statusはmissing、旧実装分類はlegacy_not_originalに統一。P18-01は一対一変換を禁止し、旧数値・日次制限は原本として未承認と明記した。UI定数は実際には98–101行であり、89–97行の無関係なラベルを除いた。共通ルール参照により各該当セルへ結び付けた。所有者・原本欠損・natural_route_complete=falseを維持。
- LOW 独立試験: validate_legacyは候補とcanonicalの比較とは独立に、git showで固定anchorのソースを読み、引用行本文とgit rev-parseによるblob OID、現ファイルとの一致を検査する。test_independent_legacy_source_evidenceは実際のJSONと実ファイルを開き、git hash-object --stdin(書込みなし)でOIDを独立計算し、関数名とshield_broken/EXCLUSIVE_LOCKEDも確認。test_legacy_evidence_required_independentlyは対象全項目の証拠削除・空配列・無条件not implemented・古いOID/本文を拒否する。
- LOW 中断参照: 全21セルのinterruption_cancellationに原本148・153行の本文とbaseline hash、および343–355行のconfirm_route_lockをlegacy_not_originalとして追加。人物固有欠損と担当は維持。
- LOW ハッシュ表示: 263a01b943d8056727335fb76977c010298e1c229077f8da76e48683ba2e89bb は TASK_SPEC.json file-bytes SHA-256。3af3d9b931edc5449796662ad69bb6f67c80b48827a86565b1952931db68dd15 は Runner git-show stripped-UTF-8 contract spec_sha256。上記SPEC_HASH_REPRESENTATIONS.jsonのunchanged_file=trueが証拠であり、両者は同じ無変更ファイルを表す。checker定数と出力にも区別を反映。Git blob OIDは別のgit_blob_oidフィールドでありfile-bytes SHA-256とは区別する。
## 実行記録(今回のローカル確認)
以下は正式Runner実行ではない。記録済み修復指示に基づき3登録コマンドをローカル実行した。
生成 — exit 0
```sh
python3 -B /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement/docs/plans/original-completion-2026-10-02/execution/p19-03/check_reachability_table.py --project-root /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement --write
```
p19-table — exit 0
```sh
python3 -B /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement/docs/plans/original-completion-2026-10-02/execution/p19-03/check_reachability_table.py --project-root /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement --check
```
p19-negative — exit 0
```sh
python3 -B /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement/docs/plans/original-completion-2026-10-02/execution/p19-03/test_reachability_table.py --project-root /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement
```
p19-source-binding — exit 0
```sh
python3 -B /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement/docs/plans/original-completion-2026-10-02/execution/p19-03/check_reachability_table.py --project-root /Users/korizatou114/Projects/TheTree-managed-worktrees/p19-03-reachability/P19-03-TABLE-implement --source-binding-only
```
--writeの実際の呼出しは同一checkout内で相対スクリプトパスと --project-root . を使用(上記は絶対パス表記)。--checkはMarkdownのバイト完全一致を確認。自己試験は12件OK。source bindingは1368ファイル一致、file_equality=true、actual_commit=e0420d57a381a1644357f0590536e1d47c9bca9f、native_evidence_reuse_accepted=false。git diff --checkはexit 0。構文は上記Python実行で確認。最初の編集用here-documentは一時ファイル作成制限でexit 1、変更は行われず、python3 -cで再実行してexit 0。native full・ゲーム起動・git書込み・Runner操作・他エージェント起動は行っていない。
## 残る親成果・ゲームの欠損
- P18-01: 原本3軸/盾の通常UI、承認済み初期値、本来の全単位受入。P18-02/03: 同配置・軽減・ブレイク・接触・閾値・確定遷移。既存旧汎用モデルの閾値を原本へ転用しない。
- P19-01/02: 原本行動と全3種決闘、勝敗・費用・盾/履歴の一度だけ反映、通常入口、停止/取消/離脱/保存再開。原本状態保持のbackendを自然ルート完成としない。
- P20-01/02/03: スキル/章・全エンド・図鑑と新規からの通常到達。P26-01/02/03: 人物別必要内容、状態接続、完成資産と制作受入。人物固有イベント/条件/履歴/立ち絵差分は欠損。
- D05、D08はpending。未知の閾値・率・初期値もpendingのまま。P04の既存D05選択、P13の4件の制作followupも未解決。
- この文書単位のnative GUI/Windows実行は適用外。global same-Windows、held gestures、全ゲーム受入はopen。固定入力一致から既存native証拠の正式再利用承認を推定していない。
- Runnerの3 validator、原本§6–7と元受入に対する実装者から独立したwhole-unit Opus review、その後の通常prepare-integration/finalが必要。どの完了数・元台帳も変更していない。
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""Read-only, stdlib P19-03 documentation validator; never accepts gameplay."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import sys
OUT = 'docs/plans/original-completion-2026-10-02/execution/p19-03/'
PLAN = 'docs/plans/original-completion-2026-10-02/'
BASELINE = 'docs/traceability/original/BASELINE.txt'
ANCHOR = '1b07b87d3736e777932315a4bdde8b70cbf9cb23'
BASE = '8ca9263fee1ec4374d97c01fda82b95e1df6a977'
BASE_HASH = '75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a'
SPEC_FILE_BYTES_SHA256 = '263a01b943d8056727335fb76977c010298e1c229077f8da76e48683ba2e89bb'
# Proof: build/owner-handoff/p19-review-r01/SPEC_HASH_REPRESENTATIONS.json
# Both hashes describe the same unchanged TASK_SPEC.json.
RUNNER_CONTRACT_SPEC_SHA256 = '3af3d9b931edc5449796662ad69bb6f67c80b48827a86565b1952931db68dd15'
PINS_HASH = '6c778226a4cc6d0b0d682500759e7d4229c926a27bd2e247b00b9c3e5f1a2fb0'
ROUTES = {'love': '純愛', 'ntr': 'NTR', 'corruption': '堕落'}
PEOPLE = [('anri', 'アンリ'), ('fine', 'フィーネ'), ('tomari', 'トマリ'),
('amelia', 'アメリア'), ('liz', 'リズ'), ('mimi', 'ミミ'), ('satsuki', 'サツキ')]
CATEGORIES = ('entry_required_events', 'state_conditions', 'duel_effects',
'route_selection_effects', 'interruption_cancellation', 'save_handling',
'history_marks', 'current_reachability')
STATE = 'scripts/relationship/original_relationship_state.gd'
SERVICE = 'scripts/relationship/relationship_service.gd'
RUNTIME = 'autoload/simulation_runtime.gd'
CATALOG = 'data/characters/mvp_unique_characters.json'
EVENT = 'scripts/events/event_service.gd'
PREVIEW = 'scripts/relationship/placement_risk_preview_service.gd'
LEGACY_REASON = 'P18-01 forbids one-to-one conversion; legacy numbers/daily limits are not approved originals.'
LEGACY_RANGES = {
'coassignment_attack': [(SERVICE,264,312),(RUNTIME,6850,6850),(EVENT,1039,1039),(PREVIEW,536,565),(PREVIEW,619,640)],
'zero_break': [(SERVICE,264,312),(RUNTIME,6850,6850),(EVENT,1039,1039)],
'love_actions': [(SERVICE,246,261)],
'threshold_route_loss': [(SERVICE,315,340),(SERVICE,343,355),(RUNTIME,342,343),(EVENT,1047,1047)],
'legacy_event_ui': [('scenes/main/main.gd',98,101)],
}
def validate_legacy(root, document):
rules = {r['id']: r for r in document['common_rules']}
for identity, ranges in LEGACY_RANGES.items():
rule = rules[identity]
require(rule['status'] == 'missing' and rule['legacy_classification'] == 'legacy_not_original',
'legacy/original classification')
require(rule['implementation'] == 'original conformance missing; see legacy_evidence'
and rule['legacy_reason'] == LEGACY_REASON, 'unqualified legacy absence')
entries = rule.get('legacy_evidence')
require(type(entries) is list and len(entries) == len(ranges), 'missing legacy evidence')
for entry, (path, start, end) in zip(entries, ranges):
data = blob(root, ANCHOR, path)
oid = subprocess.check_output(['git','rev-parse',ANCHOR+':'+path],cwd=root).decode().strip()
require(entry == {'path':path,'anchor':ANCHOR,'git_blob_oid':oid,
'sha256':sha(data),'lines':[start,end],
'text':'\n'.join(data.decode().splitlines()[start-1:end])}, 'legacy text/blob/range')
require(data == (root/path).read_bytes(), 'legacy current source mismatch')
def sha(data):
return hashlib.sha256(data).hexdigest()
def require(value, message):
if not value:
raise ValueError(message)
def decode(data):
def unique(pairs):
result = {}
for k, v in pairs:
require(k not in result, 'duplicate JSON key: ' + k)
result[k] = v
return result
return json.loads(data, object_pairs_hook=unique,
parse_constant=lambda s: (_ for _ in ()).throw(ValueError(s)))
def blob(root, commit, path):
return subprocess.check_output(['git', 'show', commit + ':' + path], cwd=root)
def selection(root):
paths = [p for folder in ('autoload', 'scripts', 'scenes', 'data', 'tests', 'assets', 'tools')
for p in (root / folder).rglob('*')
if p.is_file() and p.name != '.DS_Store' and '__pycache__' not in p.parts]
paths += [root / p for p in ('project.godot', 'export_presets.cfg', 'Makefile', 'AGENTS.md')]
require(all(not p.is_symlink() for p in paths), 'selected source aliases refused')
return {p.relative_to(root).as_posix(): {'bytes': p.stat().st_size, 'sha256': sha(p.read_bytes())}
for p in sorted(paths)}
def binding(root):
raw = lambda p: (root / p).read_bytes()
require(sha(raw(OUT + 'TASK_SPEC.json')) == SPEC_FILE_BYTES_SHA256, 'immutable spec changed')
spec = decode(raw(OUT + 'TASK_SPEC.json'))
require(sha(raw(OUT + 'FROZEN_GAME_INPUT_PINS.json')) == spec['frozen_game_input_pins_sha256'] == PINS_HASH,
'frozen pins SHA differs from immutable spec')
pins = decode(raw(OUT + 'FROZEN_GAME_INPUT_PINS.json'))
require(pins['audited_game_source_anchor_commit'] == spec['audit_source_anchor'] == ANCHOR, 'anchor identity')
actual = selection(root)
require(actual == pins['source_files'], 'full selected-source map: missing/new/changed file')
for key in ('marker_contract', 'policy', 'engine'):
require(sha(Path(pins[key + '_path']).read_bytes() if key == 'engine' else raw(pins[key + '_path']))
== pins[key + '_sha256'], key + ' identity')
require(sha(raw(BASELINE)) == pins['baseline_sha256'] == BASE_HASH, 'baseline hash')
require(len(raw(BASELINE)) == pins['baseline_bytes'], 'baseline bytes')
roster = ['tests/run_tests.gd'] + sorted(p.relative_to(root).as_posix()
for p in (root / 'tests').rglob('test*.gd') if 'export' not in p.relative_to(root).parts
and p.relative_to(root).as_posix() != 'tests/s0/test_kernel_and_save.gd')
require(roster == pins['roster'], 'roster identity')
require(sum(p.endswith('.gd') for p in actual) == pins['parse_files'], 'parse selection identity')
# Audit evidence is bound to published blobs, never a future self-containing commit.
for p in (STATE, SERVICE, RUNTIME, CATALOG):
require(raw(p) == blob(root, ANCHOR, p), 'stale audited source: ' + p)
return {'actual_commit': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root).decode().strip(),
'selected_files': len(actual), 'anchor': ANCHOR, 'file_equality': True,
'native_evidence_reuse_accepted': False,
'TASK_SPEC_file_bytes_sha256': SPEC_FILE_BYTES_SHA256,
'Runner_git_show_stripped_UTF8_contract_spec_sha256': RUNNER_CONTRACT_SPEC_SHA256}
def expected(root):
raw = lambda p: (root / p).read_bytes()
require(sha(raw(BASELINE)) == BASE_HASH, 'baseline changed')
for name in ('atomic_requirements.json', 'work_packages.json', 'decisions.json'):
require(raw(PLAN + name) == blob(root, BASE, PLAN + name), 'original register changed: ' + name)
require(raw(OUT + 'ACTUAL_OPUS_NEXT_ORIGINAL_UNIT_SELECTION.json') ==
blob(root, BASE, OUT + 'ACTUAL_OPUS_NEXT_ORIGINAL_UNIT_SELECTION.json'), 'selection record changed')
lines = raw(BASELINE).decode().splitlines()
def cite(start, end=None):
end = end or start
return {'path': BASELINE, 'lines': [start, end],
'text': '\n'.join(lines[start - 1:end]), 'baseline_sha256': BASE_HASH}
def evidence(path, start, end):
data = blob(root, ANCHOR, path)
require(data == raw(path), 'stale source ' + path)
return {'path': path, 'sha256': sha(data), 'anchor': ANCHOR,
'lines': [start, end], 'text': '\n'.join(data.decode().splitlines()[start - 1:end])}
def item(identity, start, end, note, owner, status='missing', source=None):
return {'id': identity, 'authority': cite(start, end), 'status': status,
'implementation': source or 'original conformance missing', 'owner': owner,
'observation': note, 'gap': None if status == 'implemented' else '欠損: ' + note}
# Only narrow storage facts are implemented; simulation/route completion never follows.
rules = [
item('state_fields', 138, 143, '原本3値と盾の明示値の検証・保持のみ。初期値と更新方針は未定。',
'P18-01', 'implemented', evidence(STATE, 4, 34)),
item('affection_max_invariant', 140, 140, '入力時の最大値一致検証のみ。通常操作の回復・更新は欠損。',
'P18-01', 'partial', evidence(STATE, 20, 30)),
item('snapshot_container', 138, 143, '専用状態と旧値の分離保存・復元。人物別ルート進行保存を意味しない。',
'P18-01', 'partial', evidence(SERVICE, 423, 479)),
item('identity_registration', 138, 138, 'Runtimeは登録済み人物・相互対応を検証。既存カタログのヒロインはアンリのみ。通常ルート入口ではない。',
'P19-01', 'partial', evidence(RUNTIME, 6791, 6812)),
item('legacy_event_ui', 154, 157, '既存UIには汎用関係イベントの確認/保留ラベルがある。原本の行動・決闘選択・自然ルート到達は欠損。',
'P19-01', 'partial', evidence('scenes/main/main.gd', 98, 101)),
]
clauses = [
('defense_reduction', 141, 141, 'P18-02'), ('post_break_metric', 142, 143, 'P18-02'),
('barrier_block', 143, 143, 'P18-02'), ('coassignment_attack', 145, 145, 'P18-02'),
('zero_break', 146, 146, 'P18-02'), ('contact_after_break', 147, 147, 'P18-02'),
('threshold_route_loss', 148, 148, 'P18-03'), ('duel_types', 150, 151, 'P19-01'),
('duel_win', 152, 152, 'P19-01'), ('duel_loss', 153, 153, 'P19-01'),
('love_actions', 155, 155, 'P19-01'), ('ntr_actions', 156, 156, 'P19-01'),
('corruption_actions', 157, 157, 'P19-01'), ('history_fields', 159, 159, 'P19-02'),
('history_cycle_parentage', 160, 160, 'P19-02'), ('portrait_marks', 161, 161, 'P26-02'),
('route_skills', 132, 132, 'P20-01'), ('departure', 135, 135, 'P19-02')]
for identity, start, end, owner in clauses:
rules.append(item(identity, start, end,
'原本の共通要求。専用原本モデルにはこの更新・表示の実装なし。旧汎用モデルの数値やルートを代用しない。', owner))
for rule in rules:
if rule['id'] in LEGACY_RANGES:
rule.update(status='missing', implementation='original conformance missing; see legacy_evidence',
legacy_classification='legacy_not_original', legacy_reason=LEGACY_REASON)
rule['gap'] = '欠損: ' + rule['observation'] + ' ' + LEGACY_REASON
rule['legacy_evidence'] = []
for path, start, end in LEGACY_RANGES[rule['id']]:
entry = evidence(path, start, end)
entry['git_blob_oid'] = subprocess.check_output(
['git','rev-parse',ANCHOR+':'+path],cwd=root).decode().strip()
rule['legacy_evidence'].append(entry)
# Read complete selected atomic records, not only their condition strings.
atoms = decode(raw(PLAN + 'atomic_requirements.json'))['requirements']
atoms = [x for x in atoms if set(x.get('source_group_ids', [])) & {'B-7.' + str(i) for i in range(1, 7)}]
owners = {x['id'] for p in decode(raw(PLAN + 'work_packages.json')) for x in p.get('remaining_deliverables', [])}
require(all(r['owner'] in owners for r in rules), 'unknown original owner')
for key, name in PEOPLE:
for route in ROUTES:
require(any(x['id'] == 'AR.heroine.' + key + '.route.' + route and
x['entity'] == {'kind': 'heroine', 'key': key, 'original_name': name} for x in atoms),
'canonical atomic character/route missing')
require(all(name in lines[117 + i] for i, (_, name) in enumerate(PEOPLE)), 'original character names')
cells = []
for i, (key, name) in enumerate(PEOPLE):
for route, route_name in ROUTES.items():
refs = {
'entry_required_events': ['identity_registration', route + '_actions'],
'state_conditions': ['state_fields', 'affection_max_invariant', 'defense_reduction',
'post_break_metric', 'barrier_block', 'coassignment_attack', 'zero_break', 'contact_after_break'],
'duel_effects': ['duel_types', 'duel_win', 'duel_loss'],
'route_selection_effects': ['threshold_route_loss', route + '_actions', 'route_skills'],
'interruption_cancellation': ['departure', 'threshold_route_loss', 'duel_loss'], 'save_handling': ['snapshot_container'],
'history_marks': ['history_fields', 'history_cycle_parentage', 'portrait_marks'],
'current_reachability': ['identity_registration', 'threshold_route_loss']}
categories = {}
for category in CATEGORIES:
owner = {'entry_required_events': 'P26-01', 'state_conditions': 'P18-02',
'duel_effects': 'P19-01', 'route_selection_effects': 'P26-02',
'interruption_cancellation': 'P19-02', 'save_handling': 'P19-02',
'history_marks': 'P19-02', 'current_reachability': 'P20-02'}[category]
gap = item(key + '_' + route + '_' + category, 138, 161,
'原本未定義/D08 pending: 人物固有のイベント列・条件・中断取消・進行保存・履歴と通常到達は未制作/未証明。共通要求から固有仕様を創作しない。', owner)
gap['character_authority'] = cite(118 + i)
categories[category] = {'common_rule_refs': refs[category], 'character_specific': gap}
if category == 'interruption_cancellation':
categories[category].update(route_termination_authorities=[cite(148),cite(153)],
legacy_classification='legacy_not_original', legacy_reason=LEGACY_REASON,
legacy_evidence=[e for r in rules if r['id']=='threshold_route_loss'
for e in r['legacy_evidence'] if e['path']==SERVICE and e['lines']==[343,355]])
cells.append({'character_id': key, 'name': name, 'route_id': route, 'route': route_name,
'natural_route_complete': False, 'categories': categories})
return {'task_id': 'P19-03-TABLE', 'schema_version': 1,
'identity_policy': '原本§6の名前とatomic entity.keyを監査IDに使用。実装IDを捏造しない。カタログにはheroine_anriのみ。OriginalRelationshipStateはsubject_idを保持し7名の列挙はしない。',
'source_anchor': ANCHOR, 'common_rules': rules, 'cells': cells,
'authority_snapshots': {p: {'sha256': sha(raw(p)), 'base_commit': BASE,
'records': decode(raw(p)) if p.endswith('decisions.json') else atoms if p.endswith('atomic_requirements.json')
else [x for x in decode(raw(p)) if x['id'] in ('P18', 'P19', 'P20', 'P26')]}
for p in (PLAN + 'atomic_requirements.json', PLAN + 'work_packages.json', PLAN + 'decisions.json')},
'catalog_evidence': evidence(CATALOG, 1, 32),
'pending': {'D05': 'pending', 'D08': 'pending', 'thresholds': None, 'rates': None, 'initial_values': None},
'route_support': {r: {'UI': '欠損: 原本行動・3種決闘・原本3軸/盾の通常操作なし。既存汎用関係UIを原本ルートとしない。',
'Runtime': 'partial: 原本明示状態の登録・identity検証のみ。共通更新と通常ルート到達は欠損。',
'save': 'partial: 専用状態/旧値コンテナ保存あり。人物固有進行・中断再開は欠損。',
'Windows': '未受入: global same-Windows acceptanceはopen。文書単位のnative GUI/Windows実行は適用外。',
'evidence_refs': ['legacy_event_ui', 'identity_registration', 'snapshot_container', 'state_fields'],
'missing_mechanisms': [x[0] for x in clauses]} for r in ROUTES.values()},
'acceptance': '7名×3ルートの到達表があり、未制作内容は欠損として残る。',
'accounting': '14/88 unchanged; formal Runner validation, independent whole-unit Opus review and normal integration/final required; no game acceptance'}
def verify(candidate, canonical, root=None):
require(type(candidate) is dict, 'document must be an object')
if root is not None:
validate_legacy(root, candidate)
# Exact canonical equality also rejects additions, type coercion, invented numbers,
# owner/status promotion and unsupported completion, including nested metadata.
require(json.dumps(candidate, ensure_ascii=False, sort_keys=True) ==
json.dumps(canonical, ensure_ascii=False, sort_keys=True), 'table differs from pinned canonical audit')
def verify_records(candidate, canonical, markdown_bytes):
verify(candidate, canonical)
require(markdown_bytes == markdown(canonical).encode(), 'Markdown drift')
def markdown(d):
lines = ['# P19-03 到達表', '', d['acceptance'], '', d['identity_policy'], '',
'共通原本ルールと人物固有欠損を分離する。implementedは狭い保持処理だけで、自然ルート完成ではない。', '',
'|人物ID|原名|ルート|現在の到達性|', '|---|---|---|---|']
for c in d['cells']:
lines.append('|%s|%s|%s|欠損・通常到達未証明|' % (c['character_id'], c['name'], c['route']))
lines += ['', '## 共通原本ルール', '']
for r in d['common_rules']:
lines += ['### ' + r['id'], '', r['status'] + ' / owner: ' + r['owner'], '',
r['observation'], '', '原本 %s:%s–%s' % (BASELINE, *r['authority']['lines']), '',
'```text', r['authority']['text'], '```', '',
'実装証拠: ' + json.dumps(r['implementation'], ensure_ascii=False, sort_keys=True), '']
if 'legacy_evidence' in r:
lines += ['旧実装: ' + r['legacy_classification'] + ' / ' + r['legacy_reason'], '',
'旧証拠 (git_blob_oid: Git blob OID; sha256: file-bytes SHA-256): ' +
json.dumps(r['legacy_evidence'], ensure_ascii=False, sort_keys=True), '']
for c in d['cells']:
lines += ['## ' + c['character_id'] + ' / ' + c['name'] + ' / ' + c['route'], '']
for category, record in c['categories'].items():
g = record['character_specific']
lines += ['- ' + category + ': 共通 [' + ', '.join(record['common_rule_refs']) +
'] / missing / ' + g['gap'] + ' / owner: ' + g['owner'] +
' / 原本 ' + str(g['authority']['lines']) + '・人物 ' + str(g['character_authority']['lines'])]
if category == 'interruption_cancellation':
lines += [' 原本中断参照: ' + json.dumps(record['route_termination_authorities'], ensure_ascii=False),
' 旧確定ロック legacy_not_original: ' + json.dumps(record['legacy_evidence'], ensure_ascii=False)]
lines.append('')
lines += ['## ルート別適用性', '']
for route, support in d['route_support'].items():
lines += ['### ' + route, '']
lines += ['- ' + k + ': ' + v for k, v in support.items() if isinstance(v, str)]
lines += ['- 欠損機構: ' + ', '.join(support['missing_mechanisms']), '']
lines += ['D05/D08、閾値・率・初期値はpending。完全な出典本文・SHA・原子要件全レコードはJSONに保持する。', '', d['accounting'], '']
return '\n'.join(lines)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--project-root', type=Path, required=True)
mode = p.add_mutually_exclusive_group(required=True)
mode.add_argument('--check', action='store_true')
mode.add_argument('--write', action='store_true')
mode.add_argument('--source-binding-only', action='store_true')
args = p.parse_args()
try:
root = args.project_root.resolve()
result = binding(root)
if not args.source_binding_only:
canonical = expected(root)
validate_legacy(root, canonical)
if args.write:
(root / OUT / 'REACHABILITY_TABLE.json').write_text(json.dumps(canonical, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
(root / OUT / 'REACHABILITY_TABLE.md').write_text(markdown(canonical), encoding='utf-8')
else:
validate_legacy(root, decode((root / OUT / 'REACHABILITY_TABLE.json').read_bytes()))
verify_records(decode((root / OUT / 'REACHABILITY_TABLE.json').read_bytes()), canonical,
(root / OUT / 'REACHABILITY_TABLE.md').read_bytes())
print(json.dumps({'audit_integrity': 'pass', 'formal_acceptance': 'pending_runner_and_independent_review', **result}))
return 0
except (ValueError, OSError, KeyError, TypeError, subprocess.CalledProcessError) as e:
print(json.dumps({'audit_integrity': 'fail', 'error': str(e)}))
return 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Meaningful in-memory controls; no product, Git or fixture writes."""
import argparse
import copy
from pathlib import Path
import unittest
from unittest.mock import patch
import check_reachability_table as audit
ROOT = None
class AuditTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.canonical = audit.expected(ROOT)
def reject(self, mutate):
changed = copy.deepcopy(self.canonical)
mutate(changed)
with self.assertRaises(ValueError):
audit.verify(changed, self.canonical)
def test_valid_implemented_partial_missing(self):
# All three legitimate status classes must survive serialization and the
# exact same verifier used by --check, without status promotion.
import json
value = audit.decode(json.dumps(self.canonical))
self.assertEqual({r['status'] for r in value['common_rules']}, {'implemented', 'partial', 'missing'})
audit.verify(value, self.canonical)
def test_independent_legacy_source_evidence(self):
import subprocess
document = audit.decode((ROOT / audit.OUT / 'REACHABILITY_TABLE.json').read_bytes())
audit.validate_legacy(ROOT, document)
for rule in document['common_rules']:
for entry in rule.get('legacy_evidence', []):
data = subprocess.check_output(['git', 'show', audit.ANCHOR + ':' + entry['path']], cwd=ROOT)
self.assertEqual(data, (ROOT / entry['path']).read_bytes())
self.assertEqual(entry['git_blob_oid'], subprocess.check_output(
['git','hash-object','--stdin'], input=data, cwd=ROOT).decode().strip())
start, end = entry['lines']
self.assertEqual(entry['text'], '\n'.join(data.decode().splitlines()[start-1:end]))
rules = {r['id']: r for r in document['common_rules']}
self.assertIn('func restore_shield', rules['love_actions']['legacy_evidence'][0]['text'])
self.assertIn('shield_broken', rules['zero_break']['legacy_evidence'][0]['text'])
self.assertIn('EXCLUSIVE_LOCKED', rules['threshold_route_loss']['legacy_evidence'][1]['text'])
def test_legacy_evidence_required_independently(self):
for identity in audit.LEGACY_RANGES:
for mutation in ('drop', 'empty', 'absence', 'blob', 'text'):
changed = copy.deepcopy(self.canonical)
rule = next(r for r in changed['common_rules'] if r['id'] == identity)
if mutation == 'drop':
rule.pop('legacy_evidence')
elif mutation == 'empty':
rule['legacy_evidence'] = []
elif mutation == 'absence':
rule['implementation'] = 'not implemented'
else:
rule['legacy_evidence'][0]['git_blob_oid' if mutation == 'blob' else 'text'] = 'stale'
with self.subTest(identity=identity, mutation=mutation), self.assertRaises(ValueError):
audit.validate_legacy(ROOT, changed)
def test_exact_cell_product(self):
mutations = {
'missing': lambda d: d['cells'].pop(),
'duplicate': lambda d: d['cells'].append(copy.deepcopy(d['cells'][0])),
'wrong_character': lambda d: d['cells'][0].update(character_id='unregistered'),
'wrong_route': lambda d: d['cells'][0].update(route='unregistered'),
}
for name, mutate in mutations.items():
with self.subTest(name=name):
self.reject(mutate)
def test_categories_and_citations(self):
self.reject(lambda d: d['cells'][0]['categories'].pop('save_handling'))
self.reject(lambda d: d['common_rules'][0].pop('authority'))
self.reject(lambda d: d['cells'][0]['categories']['state_conditions']['character_specific'].pop('character_authority'))
def test_stale_record_evidence(self):
for field in ('text', 'baseline_sha256'):
with self.subTest(field=field):
self.reject(lambda d: d['common_rules'][0]['authority'].update({field: 'stale'}))
for field in ('text', 'sha256', 'anchor'):
with self.subTest(field=field):
self.reject(lambda d: d['common_rules'][0]['implementation'].update({field: 'stale'}))
def test_real_source_and_baseline_changes(self):
original = Path.read_bytes
for target in (audit.BASELINE, audit.STATE):
def corrupt(path):
value = original(path)
return value + b'\nchanged' if path == ROOT / target else value
with self.subTest(target=target), patch.object(Path, 'read_bytes', corrupt):
with self.assertRaises(ValueError):
audit.expected(ROOT)
actual_blob = audit.blob
def corrupt_blob(root, commit, path):
value = actual_blob(root, commit, path)
return value + b'\nchanged' if path == audit.STATE else value
with patch.object(audit, 'blob', side_effect=corrupt_blob):
with self.assertRaises(ValueError):
audit.expected(ROOT)
def test_no_invented_values_or_completion(self):
self.reject(lambda d: d['pending'].update(thresholds=50))
self.reject(lambda d: d['common_rules'][5].update(status='implemented'))
self.reject(lambda d: d['cells'][0].update(natural_route_complete=True))
self.reject(lambda d: d['cells'][0]['categories']['entry_required_events']['character_specific'].update(status='implemented', gap=None))
def test_owner_and_status(self):
self.reject(lambda d: d['common_rules'][0].update(owner='P99-01'))
self.reject(lambda d: d['common_rules'][0].update(owner='P19-02'))
self.reject(lambda d: d['common_rules'][0].update(status='complete'))
def test_json_and_types(self):
for data in ('{', '{"x":1,"x":2}', '{"x":NaN}'):
with self.subTest(data=data), self.assertRaises(ValueError):
audit.decode(data)
for value in ([], None, 'table'):
with self.subTest(value=value), self.assertRaises(ValueError):
audit.verify(value, self.canonical)
self.reject(lambda d: d.update(cells={}))
self.reject(lambda d: d.update(schema_version=True))
def test_markdown_drift(self):
expected = audit.markdown(self.canonical).encode()
audit.verify_records(self.canonical, self.canonical,
(ROOT / audit.OUT / 'REACHABILITY_TABLE.md').read_bytes())
with self.assertRaises(ValueError):
audit.verify_records(self.canonical, self.canonical, expected + b'\nchanged')
def test_full_frozen_map_and_identities(self):
audit.binding(ROOT) # Valid frozen-map control; no native execution.
actual_selection = audit.selection(ROOT)
for kind in ('missing', 'new', 'changed'):
changed = copy.deepcopy(actual_selection)
key = next(iter(changed))
if kind == 'missing':
del changed[key]
elif kind == 'new':
changed['scripts/invented.gd'] = {'bytes': 0, 'sha256': '0' * 64}
else:
changed[key]['sha256'] = '0' * 64
with self.subTest(kind=kind), patch.object(audit, 'selection', return_value=changed):
with self.assertRaises(ValueError):
audit.binding(ROOT)
original = Path.read_bytes
pins = audit.decode(original(ROOT / audit.OUT / 'FROZEN_GAME_INPUT_PINS.json'))
targets = [ROOT / audit.OUT / 'FROZEN_GAME_INPUT_PINS.json', ROOT / pins['marker_contract_path'],
ROOT / pins['policy_path'], Path(pins['engine_path'])]
for target in targets:
def corrupt(path):
value = original(path)
return value + b'\nchanged' if path == target else value
with self.subTest(target=str(target)), patch.object(Path, 'read_bytes', corrupt):
with self.assertRaises(ValueError):
audit.binding(ROOT)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--project-root', type=Path, required=True)
args, rest = parser.parse_known_args()
ROOT = args.project_root.resolve()
unittest.main(argv=[__file__] + rest)