Compare commits

..
8 changed files with 982084 additions and 0 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
# P26-01 全必要内容の台帳 — 修復提出
対象は P26-01 / AC-P26-01。原作業「全章、7名の3ルート、全エンド、台詞、立ち絵、音声の必要枠と条件を確定する。」、原受入「原本で要求された未制作内容を欠損として記録し、架空の完成数を置かない。」を保持する。自己受入せず、30パッケージ / 88単位、正式完了15、今回加算0、partial_credit=false、whole_unit_accepted=falseを維持する。
変更は契約の5成果物のみ。TASK_SPEC、Opus選択、frozen pins、原本・AR・計画・決定・修復回数は変更しない。git書込み、他エージェント、Runner操作、native/full run、ゲーム・素材・設定・権限・API変更は行っていない。新規素材の導入はない。
## 引継ぎと決定の版
build/owner-handoff/p26-repair3/ の ACTUAL_WHOLE_REVIEW.json、ACTUAL_OPUS_REPAIR3.json、D05_ACTUAL_HUMAN_APPROVAL.json、D05_APPROVAL_SOURCE_BINDING.json を編集前に全文読み、データとして照合した。開始時に存在した前回blocked報告の承認出典不足は、この4資料により解消している。
監査アンカーは f4f85bc54c39361f9efcd16f67a17835559ca70c、原計画定義ベースは 379a1ba8fd25b5ad24226178a2492f91761dc31d。この両時点ではD05の残質問は未解決として登録されていた、という歴史的事実のみを保存する。現在のD05が未回答だとは記述しない。
現在の実承認は a13f7c0b791dd385ff8113c3d321c0e3fe7d7c94 の docs/plans/original-completion-2026-10-02/execution/p04-01-owner-approval-20261018/D05_ACTUAL_HUMAN_APPROVAL.json、SHA256 cd365ded1b777a0327d090709f0a4161f5005562819183455d9f2e106fba3e96。チェッカーはこのgit objectのhashを照合し、4回答の原レコードを台帳に保存する。質問IDは call_5fa8987f83284eb58b685002499cb9df。破壊/完了、日付境界、停止理由、退出順序の承認であり、P04への製品採用・内容完成・P26受入ではない。D08/D09は未解決。
## 修復5: 証拠のAR別帰属
今回の変更前に build/owner-handoff/p26-support-attribution-repair/ACTUAL_OPUS_ASTRA_ATTRIBUTION_DIRECTION.json と ACTUAL_WHOLE_REVIEW_DA80360A.json を全文読了し、D05の上記4資料も再確認した。修復5は証拠帰属に限定する。
結合した引用文のキーワードからpartialを付ける処理を削除した。SUPPORT_POLICYは証拠種別→許容ARの閉じた対応表である。関係状態は21ルートと感情3軸/盾最大値の明示状態・保存だけ、覗きは記録係/観察/証拠収集に関する列挙ARの停止中コンテナだけ、夜間は2 ARの欲求入力だけ、赤字は終端計算ARだけを支援する。人物のidentity/技能や任意のrelationship/route_action全域へは拡張しない。視点・角度・介入そのものを支援する証拠とはしない。
各行のscoped_supportは対象AR・証拠種別・限定範囲・content_status=missingを明記する。unsupported_atomic_requirement_idsはその行で基盤支援がない残りのARを列挙する(用途行の既存placeholder/code-drawingは従来どおり別記)。L9は21ルートのコンテナだけpartialで、全エンド/図鑑/所要時間は未支援。L257とclause.productionは観察工程のコンテナだけpartialで、ボイス収録/実装を含む他ARは未支援。voice.all、endings全3行、encyclopedia.allはmissing、証拠なし。声に関するどのARも対応表に入れない。行数・AR対応・引用・制作数量は増減させない。
verify_authorityはexpectedやcanonicalを呼ばず、提出された各claimのARと行の種類、固定出典・抜粋全文、支援/未支援ARの分割、状態と範囲文を検査する。有効なSHAでも無関係な証拠、無根拠なpartial、声への同一引用行の観察証拠の流用は拒否する。負例はmissingの音声/立ち絵/エンド/図鑑への証拠注入、混合行内の支援AR拡張、正しい夜間支援への無関係な証拠追加を含む。route、L65、L53の実際のpartialを正例にする。既存負例は維持し、AR削除負例だけ新しい分割フィールドも整合して削除する。
矛盾した「no dedicated support established; …only」の連結を廃止した。任意LOWのL226はAR.ui.cut_duel/cut_defenseの対象外理由にP24 UIカットインを明記した。owner決定用content ARの追加分類は、既存の原典リンクと所有先を狭める別の判断が必要なため今回はdeferred。ownerは引き続き引用ARのprimary_packageに基づく追跡先で、専任制作担当の確定ではない。
## whole-unit対応と残存不足
| 対象 | 実際の記録・修復 | 残存不足 |
| --- | --- | --- |
| 全章 | chapters.all、原本内容行、対応AR | 章名・章数・順序・シーン列は未定義。仕様書章番号を物語章数に転用しない |
| 7名×3ルート | 原名の21 route行、人物別AR、P19セルの版固定と関係状態ソース | 共通状態・保存のみpartial。固有イベント列・自然到達・台詞・素材・終点はmissing、D08未決 |
| 全エンド | endings.all / bad / game_overとAR | 全3行missing、全集合/本数は未定義。赤字終端の処理はoriginal.line.72の該当ARだけpartial |
| 台詞/立ち絵/音声/図鑑 | 各総括行、原文個別行、人物/用途AR、P22全614枠 | 完成本文・画像・ボイス・項目集合の完成証拠なし。汎用BGM/UI音は声の代用にしない |
| 原本欠落の修復 | original.line.13 / 53 / 65 / 196 / 221 / 222、各原文全文・行・SHAとAR | イベント/台詞/CG/音声/専用操作内容はmissing。個別場面数はundefined |
| 独立網羅 | 原本テキストの内容語彙・節見出しとARドメイン/condition/source_anchorsから選択 | 機械的な保守的選択方針を明記し、独立レビューが原単位網羅を判定 |
| AR独立確認 | atomic_dispositionsで全ARに原レコード引用・所管原P単位・明示処置 | 旧view_results/statusを転記せず、未再制作/未定義を完成に昇格しない |
| 所有と状態 | 引用ARのprimary_package/work_packagesと原P単位定義に結合 | 複数原P単位から特定制作担当を推測しない。owner_assignmentに担当未定義を明記 |
| 負例 | 省略イベント行、AR欠落/未知/処置欠落、AR不一致owner、route/usage状態、stale AR/原文/usage SHA、partial誤完成 | 正式実行はRunner |
現在の生成物は762行(既存651行を保持し、選択した原文111行を追加)、関連AR675件。これは監査レコードの数であり、制作数量・完成数・受入分母ではない。全required_count/completed_countはnull。ARの関連選択外は追加施設契約/財布・採用等の別単位条件または独立メカニクスとして理由と原所有先を保持し、製品上の適用外/受入済みとはしない。
各行はatomic_requirement_idsを持つ。対応ARがない原文も消さず、所有先undefinedとして残す。ownerは引用ARのprimary_package、owner_deliverable_refsは固定参照の原P単位定義を指す。ARごとのowning_deliverablesにはprimary_packageとwork_packagesの各パッケージの元のid/title/work/acceptanceを保存する。特定の1単位が専任と原本で決まっていない場合、候補定義全体と担当未定義を示す。clause.*を一律P26-03に割り当てる旧処理を除いた。
## 現物の部分支援と欠損
scripts/relationship/original_relationship_state.gd の明示値検証と relationship_service.gd の保存コンテナを直接参照する。route行と対応する原文行はpartialであり、content_status=missingを別記する。更新・攻撃・閾値・固有物語を実装済みとはしない。
scripts/special/peeping_room_service.gd:127–162 と autoload/simulation_runtime.gd:10860–10880 を直接引用する。停止・対象参照・証拠キーの基盤はあるが、製品配置はBUILD_DEFINITION_PENDING_D04、記録はRECORDER_ELIGIBILITY_PENDING_D04_D08_P16で止まる。閉じた対応表にあるARのscoped_supportだけがこの基盤を指し、視界、専用画面、スクリーンショット、介入や制作イベントを証明しない。L13の自律観察/介入、L53の専用視点、L222の角度/介入にはこの証拠を帰属させない。
scripts/guest/guest_service.gd:355–360 は夜間の欲求入力の切替のみ。L65のNTRイベント内容の制作証拠ではない。scripts/operations/operations_coordinator.gd:1183–1195 の赤字連続による終端計算は存在するが、完成シナリオ/画像/声ではない。L196の専用代物弁済イベントは現物制作証拠なしとしてmissing。数量/シーン列はundefinedのまま。
P19/P22の旧状態をそのまま合否にしない。P19の正確な21セルと自然到達未完成の事実を版固定し、現在の関係ソースも独立参照する。P22全614用途レコードと全296mediaを保存し、表現先の現物SHAを監査アンカーへ再結合する。表現型からcode-drawing / placeholder / missingを区別し、歴史的inventoryのSHAと現在アンカーのSHAを別々に保持する。全assetsのパス/SHAも保存する。P22-03はdraft/unaccepted reference only。
P22の公開入力は137639773c9017f9ee7733d78e37179b687f965cのaccepted-p22-01-inputs/、manifest SHA256 90b5a69d5f364012edabe5a68b8c7613f5f5ca47bc189d435dd2e69b8dd8c915 に固定。元artifacts別名が存在すれば同一バイト必須、なければ公開git objectを読む。
## 検査の構造と実行状況
check_content_ledger.py はstdlibのみ。--checkは読取専用の台帳/出典/入力/Markdown検査、--source-binding-onlyは出典結合と台帳検査、--writeは指定JSON/Markdown生成。JSON重複キー・非有限値は拒否する。
authorities()は提出行や旧監査の合否を使わず、BASELINE本文の内容語彙/節見出し、ARのドメイン/condition/source_anchorsから必要行・ARを導く。選択条件はcoverage_policyに公開する。verify_authority()はcanonicalを引数に取らず、必要行とARの網羅、原文全文/行/hash、AR自身のrecord、所有原P単位、状態と表現pin、部分支援と内容欠損を検査する。canonical完全一致は追加のdrift検査として残す。原計画定義はベースへのprojectionで固定し、明示した進捗メタデータ以外の変更を拒否する。
負例には従来の章/route/全エンド/category欠落、架空数、schema/type、重複ID、不正JSON、Markdown drift、原計画改変、公開manifest改変、ゲーム入力追加/欠落/改変を保持する。新しいindependent_rejectは正例も負例もverify_authorityだけを呼び、canonical一致に頼らずAR網羅・owner・状態・hash不正を捕捉する。各mutationに有効な提出資料のcontrolを置く。
このworkerはJSON/Markdownの生成処理と読取による実装確認だけを行った。p26-ledger / p26-negative / p26-source-binding、--check、--source-binding-only、unittestは実行していない。PASSは主張しない。正式試験はRunnerが契約の3コマンドを実行する。
修復5の生成後の読取比較では762行、1309 AR処置、675関連ARを保持し、全行のID・引用・ARリンクは開始時と同一。全required_count/completed_countはnull。JSONの変更した最上位領域はrowsとatomic_dispositionsのみで、D05の4回答を含む他の領域は同一。JSONとMDはexpected/markdownによる決定的生成であり、試験実行結果ではない。生成後に変更したcheckerの独立検証と負例はRunnerでの実行待ち。
## 新Sと受入の残ゲート
FROZEN_GAME_INPUT_PINS.json SHA256 6c778226a4cc6d0b0d682500759e7d4229c926a27bd2e247b00b9c3e5f1a2fb0 と、全1368 selected game inputs・settings・assets・tests・tools・marker・policy・engine・roster・329parse・default player identityの照合処理は保持する。docs helpersは対象ゲーム集合外。TASK_SPEC raw SHAは8026040345de6b7d06cfce40beac9150bfe9ad303d6b26c11bd94e66b1080b29、strip表現は1fedc9ade42a04722846daa9ac8ec26f821fb4efbce8dbbb53e3e76e31f38d02。
旧SでのRoot source/input equality結果を新Sへ流用しない。workerはgit書込みを禁止されており、新Sの確定/Root qualificationはRunner・Rootの担当で未実行。新Sが存在した後に改めて全入力一致を資格確認する必要がある。
native 184 suites / 21903 checks / 329 parse / 186 owned-endedは履歴のみ。新規native/full runなし。exact S/Cの独立適用性確認前に再利用を承認しない。whole-unit独立Opusレビュー、prepare-integration、exact C/M、normal final、Rootの台帳/main pushも未実行。P26-02/03・上流制作、D08/D09、held gestures、same-Windows、global acceptanceは未完/未受入であり、これらの完了は主張しない。15/88から16/88への加算は行わない。
@@ -0,0 +1,102 @@
{
"id": "P26-01-LEDGER",
"original_plan_id": "P26-01",
"title": "全必要内容の台帳",
"original_work": "全章、7名の3ルート、全エンド、台詞、立ち絵、音声の必要枠と条件を確定する。",
"original_acceptance": "原本で要求された未制作内容を欠損として記録し、架空の完成数を置かない。",
"original_denominator": {
"packages": 30,
"units": 88,
"completed_at_start": 15,
"partial_credit": false
},
"whole_original_unit_review_required": true,
"actual_opus_selection": "6b9bb7f7a8474689b4094efbfcde2eef",
"audit_source_anchor": "f4f85bc54c39361f9efcd16f67a17835559ca70c",
"scope": "Original documentation/audit unit only. Record content requirements, neutral abstract IDs, source traceability and missing/undefined production content. Do not author narrative, dialogue or intimate content or invent values. No game/asset code changes.",
"authority": [
{
"path": "docs/traceability/original/BASELINE.txt",
"sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a",
"scope": "Read all applicable original clauses, including chapters,7characters,3routes,endings,dialogue,portraits,voice and encyclopedia. Exact original citations, no invented counts."
},
{
"path": "docs/plans/original-completion-2026-10-02/work_packages.json",
"unit": "P26-01",
"original_definition_must_remain_exact": true
},
{
"path": "docs/plans/original-completion-2026-10-02/atomic_requirements.json"
},
{
"path": "docs/plans/original-completion-2026-10-02/decisions.json",
"scope": "Only approved decisions apply; unresolved D08/D09 and other decisions remain explicit missing/undefined."
}
],
"frozen_game_input_pins_sha256": "6c778226a4cc6d0b0d682500759e7d4229c926a27bd2e247b00b9c3e5f1a2fb0",
"reusable_sources": [
"docs/plans/original-completion-2026-10-02/execution/p19-03/REACHABILITY_TABLE.json",
"artifacts/original-completion-2026-10-02/p22-01-usage-contract-candidate-r02/",
"docs/plans/original-completion-2026-10-02/execution/p22-03/production_backlog.json"
],
"implementation_requirements": [
"Produce CONTENT_LEDGER.json and deterministic CONTENT_LEDGER.md covering every original-required chapter/route/end/dialogue/portrait/voice/encyclopedia slot or explicit undefined scope.7originalcharacters crossed3originalroutes must retain exact identity. Enumerate original-defined end categories without inventing missing counts or scene lists.",
"Each requirement has exact original citation text/line/hash, required condition, current evidence/status, explicit source or asset path+SHA at audit anchor or explicit missing, reason, and owning remaining original deliverable/decision. Distinguish produced versus placeholder/code drawing/partial/missing/undefined. An existing generic/backend asset never proves all original content produced.",
"Reuse completed P19-03 route audit and P22-01 usage inventory with exact source pins. P22-03 backlog is draft/unaccepted reference only. Independently verify actual available content, do not just copy old statuses. Preserve known evidence and distinguish approved scope from unapproved candidate scope.",
"All unproduced or undefined content remains missing with reason and ownership; no fabricated quantity, threshold, completion count or D05/D08/D09 approval. The audit does not complete content/gameplay/Windows/global acceptance.",
"Implement stdlib checker check_content_ledger.py (--check read-only, --source-binding-only, optional --write) plus test_content_ledger.py under this docs directory only. Verify exact original full clause coverage; closed row/schema/unique IDs; source citation/pin integrity; truthful completion/unknown values and exact deterministic view. Do not use tautological generated facts as sole verification.",
"Meaningful negative tests for omitted original chapter/route/ending/category, fabricated count, missing item falsely produced, invalid owner/status/type, stale original/source/hash, malformed JSON and Markdown drift, with valid controls. No test inflation or native game runs.",
"Preserve immutable TASK_SPEC, selection and frozen pins. Anchor planning definitions to immutable base and keep all original30/88/acceptance/dependency definitions failclosed while allowing only explicit existing metadata progress fields; do not freeze entire mutable progress docs. Avoid prior P19 integration bug. All other source evidence exact and type-preserving.",
"Prove complete frozen1368 game inputs/settings/assets/tests/tools/marker/policy/engine/roster/parse/default player identity. No omitted/new/changed selected input; docs helpers stay outside selected game set. Reuse native184/21903/329parse/186ended only when applicability independently proven at exactS/C. No new uncontrolled full run.",
"Provide IMPLEMENTATION_REPORT.md mapping entire original unit to actual evidence and remaining gaps. Whole-unit independent Opus review must decide original acceptance; if pending scope prevents it, fail honestly with no credit. Normal prepare-integration/exactC-M/final then Root ledger/main push required before16/88."
],
"required_outputs": [
"CONTENT_LEDGER.json",
"CONTENT_LEDGER.md",
"check_content_ledger.py",
"test_content_ledger.py",
"IMPLEMENTATION_REPORT.md"
],
"validations": {
"p26-ledger": {
"argv": [
"{python}",
"-B",
"docs/plans/original-completion-2026-10-02/execution/p26-01/check_content_ledger.py",
"--project-root",
".",
"--check"
]
},
"p26-negative": {
"argv": [
"{python}",
"-B",
"docs/plans/original-completion-2026-10-02/execution/p26-01/test_content_ledger.py",
"--project-root",
"."
]
},
"p26-source-binding": {
"argv": [
"{python}",
"-B",
"docs/plans/original-completion-2026-10-02/execution/p26-01/check_content_ledger.py",
"--project-root",
".",
"--source-binding-only"
]
}
},
"pending_not_waived": [
"P04 existing D05 questions",
"D08/D09 unresolved scopes",
"P26-02/03 and upstream production content",
"held gestures and sameWindows acceptance"
],
"model_proposal": {
"codex_model": "gpt-6.1-sol",
"reason": "This is a documentation audit with a stdlib checker, following the proven P13-03/P19-03 pattern. P19-03 used Sol and closed with 2 repairs. It needs no game code, no native GUI and no new reasoning about game rules, so Astra's extra cost and usage isn't worth it."
},
"normal_OS_applicability": "Documentation audit introduces no product interaction. Existing route/save/UI support and missing requirements are audited; global and other-unit OS acceptance stays open."
}
@@ -0,0 +1,617 @@
#!/usr/bin/env python3
"""P26 documentation audit. Stdlib only; --check never writes or runs Godot."""
import argparse
from functools import lru_cache
import hashlib
import json
import re
from pathlib import Path
import subprocess
import sys
OUT = 'docs/plans/original-completion-2026-10-02/execution/p26-01/'
PLAN = 'docs/plans/original-completion-2026-10-02/'
BASELINE = 'docs/traceability/original/BASELINE.txt'
BASE = '379a1ba8fd25b5ad24226178a2492f91761dc31d'
ANCHOR = 'f4f85bc54c39361f9efcd16f67a17835559ca70c'
BASE_HASH = '75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a'
PINS_HASH = '6c778226a4cc6d0b0d682500759e7d4229c926a27bd2e247b00b9c3e5f1a2fb0'
SPEC_HASH = '1fedc9ade42a04722846daa9ac8ec26f821fb4efbce8dbbb53e3e76e31f38d02'
P22 = 'artifacts/original-completion-2026-10-02/p22-01-usage-contract-candidate-r02/'
PUBLISHED = '137639773c9017f9ee7733d78e37179b687f965c'
ARCHIVE = OUT + 'accepted-p22-01-inputs/'
MANIFEST_HASH = '90b5a69d5f364012edabe5a68b8c7613f5f5ca47bc189d435dd2e69b8dd8c915'
P19 = PLAN + 'execution/p19-03/REACHABILITY_TABLE.json'
BACKLOG = PLAN + 'execution/p22-03/production_backlog.json'
PEOPLE = [('anri', 'アンリ'), ('fine', 'フィーネ'), ('tomari', 'トマリ'),
('amelia', 'アメリア'), ('liz', 'リズ'), ('mimi', 'ミミ'), ('satsuki', 'サツキ')]
ROUTES = {'love': '純愛', 'ntr': 'NTR', 'corruption': '堕落'}
STATUSES = {'produced', 'placeholder', 'code-drawing', 'partial', 'missing', 'undefined'}
AR_PATH = PLAN + 'atomic_requirements.json'
CONTENT_WORDS = re.compile(r'章|ストーリー|シナリオ|ルート|エンド|図鑑|台詞|会話|立ち絵|ボイス|音声|CG|スチル|モーション|アニメ|差分|イベント|覗|記録係|脅迫|介入|カメラ|アングル|スクリーンショット|代物弁済|破産|履歴|妊娠|刻印|衣装|キャラクター|ヒロイン')
CONTENT_DOMAINS = ('AR.heroine.', 'AR.asset.', 'AR.art.', 'AR.narrative.',
'AR.observation.', 'AR.route_action.', 'AR.progression.')
APPROVAL_COMMIT = 'a13f7c0b791dd385ff8113c3d321c0e3fe7d7c94'
APPROVAL_PATH = PLAN + 'execution/p04-01-owner-approval-20261018/D05_ACTUAL_HUMAN_APPROVAL.json'
APPROVAL_HASH = 'cd365ded1b777a0327d090709f0a4161f5005562819183455d9f2e106fba3e96'
# Closed attribution policy: membership means only the named backend fragment,
# never the whole AR, its content, or other ARs sharing its source line.
SUPPORT_POLICY = {
'relationship-container': {
'ars': frozenset({'AR.relationship.three_emotions',
'AR.relationship.affection_shield_max'} |
{'AR.heroine.' + p + '.route.' + r for p, _ in PEOPLE for r in ROUTES}),
'sources': [('scripts/relationship/original_relationship_state.gd', None),
('scripts/relationship/relationship_service.gd', None)],
'scope': 'shared explicit relationship state/validation/save container only; no updates, attacks, thresholds, authored content or natural route completion'},
'peeping-container': {
'ars': frozenset({'AR.observation.recorder', 'AR.underground.observation',
'AR.underground.blackmail', 'AR.route_action.observation_progress',
'AR.ui.observation_camera_entry', 'AR.ui.observation_capture',
'AR.schedule.B-14.3.observation'}),
'sources': [('scripts/special/peeping_room_service.gd', (127, 162)),
('autoload/simulation_runtime.gd', (10860, 10880))],
'scope': 'stopped room/target reference and fixture evidence-key container only; production placement/recorder eligibility pending; no view, camera UI, screenshot, intervention, transaction or authored content'},
'night-input': {
'ars': frozenset({'AR.events.ntr_night_peak', 'AR.guest.need_sexual_night_peak'}),
'sources': [('scripts/guest/guest_service.gd', (355, 360))],
'scope': 'night-dependent guest need input only; no event production or peak-frequency implementation'},
'bankruptcy-bookkeeping': {
'ars': frozenset({'AR.progression.three_deficits_gameover'}),
'sources': [('scripts/operations/operations_coordinator.gd', (1183, 1195))],
'scope': 'bankruptcy terminal bookkeeping only; no authored ending'},
}
def support_family(row):
# Content summaries cannot inherit support from neighbouring schedule ARs.
return (row['kind'] == 'route' or row['id'].startswith('original.line.') or
row['id'] in {'clause.route_rules', 'clause.production', 'clause.scope'})
def support_pins(root, kind):
return [excerpt(root, path, *span) if span else source(root, path)
for path, span in SUPPORT_POLICY[kind]['sources']]
def authorities(root):
"""Selection reads authority text, never generated rows or old audit statuses.
All non-heading content-bearing lines plus whole narrative/character/art
sections are conservative audit scope. Every AR receives a disposition;
unrelated mechanics are explicitly outside this documentation unit.
"""
require(sha((root / BASELINE).read_bytes()) == BASE_HASH, 'authority BASELINE hash')
require((root / AR_PATH).read_bytes() == blob(root, BASE, AR_PATH), 'authority AR hash')
lines = (root / BASELINE).read_text().splitlines()
required = set()
section_content = False
for n, text in enumerate(lines, 1):
if text.startswith('# '):
section_content = bool(re.search(r'世界観|ユニークキャラクター|恋愛|デザイン|アセット|ロードマップ', text))
if text.strip() and not text.startswith('#') and (section_content or CONTENT_WORDS.search(text)):
required.add(n)
records = decode((root / AR_PATH).read_bytes())['requirements']
relevant = {a['id'] for a in records if atomic_lines(a) and (a['id'].startswith(CONTENT_DOMAINS)
or CONTENT_WORDS.search(a['condition']) or any(
s['path'] == BASELINE and required.intersection(s['lines'])
for s in a['source_anchors']))}
required.update(n for a in records if a['id'] in relevant for n in atomic_lines(a))
packages = decode(blob(root, BASE, PLAN + 'work_packages.json'))
return lines, required, {a['id']: a for a in records}, relevant, {p['id']: p for p in packages}
def atomic_lines(record):
return {n for s in record['source_anchors'] if s['path'] == BASELINE for n in s['lines']}
def owning_units(record, packages):
# Preserve the actual definitions; do not invent a uniquely assigned unit
# where the original package has several remaining deliverables.
return [{'package': p, 'primary': p == record['primary_package'],
'deliverables': [{k: u[k] for k in ('id', 'title', 'work', 'acceptance')}
for u in packages[p]['remaining_deliverables']]}
for p in dict.fromkeys([record['primary_package']] + record['work_packages'])]
def require(ok, message):
if not ok:
raise ValueError(message)
def sha(raw):
return hashlib.sha256(raw).hexdigest()
def decode(raw):
def unique(pairs):
result = {}
for key, value in pairs:
require(key not in result, 'duplicate JSON key: ' + key)
result[key] = value
return result
return json.loads(raw, object_pairs_hook=unique,
parse_constant=lambda s: (_ for _ in ()).throw(ValueError(s)))
def encoded(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False)
@lru_cache(maxsize=None)
def blob(root, commit, path):
return subprocess.check_output(['git', 'show', commit + ':' + path], cwd=root)
PACKAGE_MUTABLE = {'state', 'legacy_state', 'current_summary', 'updated_at_jst'}
UNIT_MUTABLE = {'state', 'current_execution', 'remaining_gaps', 'updated_at_jst',
'submitted_commit', 'current_summary', 'progress_summary', 'evidence',
'completed_at_jst', 'implementation_commit', 'integrated_commit',
'integration_commit', 'normal_integration_candidate'}
def projection(packages):
require(type(packages) is list, 'package register type')
result = []
for package in packages:
require(type(package) is dict, 'package type')
row = {k: v for k, v in package.items() if k not in PACKAGE_MUTABLE}
require(type(row.get('remaining_deliverables')) is list, 'deliverables type')
row['remaining_deliverables'] = [{k: v for k, v in unit.items()
if k not in UNIT_MUTABLE} for unit in row['remaining_deliverables']]
result.append(row)
return result
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 symlink')
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()
spec_raw = raw(OUT + 'TASK_SPEC.json')
require(sha(spec_raw) == '8026040345de6b7d06cfce40beac9150bfe9ad303d6b26c11bd94e66b1080b29', 'raw spec SHA')
require(sha(spec_raw.decode().strip().encode()) == SPEC_HASH, 'immutable spec')
require(spec_raw == blob(root, BASE, OUT + 'TASK_SPEC.json'), 'raw immutable spec')
spec = decode(spec_raw)
require(spec['audit_source_anchor'] == ANCHOR, 'audit anchor')
require(sha(raw(OUT + 'FROZEN_GAME_INPUT_PINS.json')) == PINS_HASH ==
spec['frozen_game_input_pins_sha256'], 'frozen pins')
require(raw(OUT + 'ACTUAL_OPUS_NEXT_ORIGINAL_UNIT_SELECTION.json') ==
blob(root, BASE, OUT + 'ACTUAL_OPUS_NEXT_ORIGINAL_UNIT_SELECTION.json'), 'selection')
require(sha(raw(BASELINE)) == BASE_HASH, 'original source')
for name in ('work_packages.json', 'atomic_requirements.json', 'decisions.json'):
current, original = raw(PLAN + name), blob(root, BASE, PLAN + name)
if name == 'work_packages.json':
require(encoded(projection(decode(current))) == encoded(projection(decode(original))),
'original definitions')
else:
require(current == original, 'original register: ' + name)
pins = decode(raw(OUT + 'FROZEN_GAME_INPUT_PINS.json'))
actual = selection(root)
require(encoded(actual) == encoded(pins['source_files']), 'selected game set: missing/new/changed')
require(len(actual) == 1368, 'selected game cardinality')
for key in ('marker_contract', 'policy', 'engine'):
path = Path(pins[key + '_path']) if key == 'engine' else root / pins[key + '_path']
require(sha(path.read_bytes()) == pins[key + '_sha256'], key + ' identity')
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')
require(sum(p.endswith('.gd') for p in actual) == pins['parse_files'] == 329, 'parse set')
# Every selected file, including settings and default player identity, is bound
# to both the immutable source map and the exact published game audit anchor.
for path in actual:
require(sha(blob(root, ANCHOR, path)) == actual[path]['sha256'], 'audit source: ' + path)
return {'selected_files': len(actual), 'parse_files': 329,
'source_anchor': ANCHOR, 'native_reuse_accepted': False,
'native_reuse_reason': 'Exact game equality is checked; external native proof and exact S/C applicability remain independent review gates.'}
def read_source(root, path):
if path.startswith(P22):
archived = blob(root, PUBLISHED, ARCHIVE + path[len(P22):])
if (root / path).exists():
require((root / path).read_bytes() == archived, 'P22 alias changed: ' + path)
return archived
return (root / path).read_bytes()
def source(root, path):
raw = read_source(root, path)
anchor = PUBLISHED if path.startswith(P22) else ANCHOR
archived_path = ARCHIVE + path[len(P22):] if path.startswith(P22) else path
anchored = blob(root, anchor, archived_path)
require(raw == anchored, 'stale evidence: ' + path)
return {'path': path, 'sha256': sha(raw), 'bytes': len(raw),
'anchor': anchor, 'anchor_path': archived_path}
def excerpt(root, path, start, end):
pin = source(root, path)
return dict(pin, lines=[start, end], text='\n'.join(
read_source(root, path).decode().splitlines()[start-1:end]))
def expected(root):
"""Independent original clauses plus pinned actual evidence, not ledger-derived facts."""
lines = (root / BASELINE).read_text().splitlines()
require(sha((root / BASELINE).read_bytes()) == BASE_HASH, 'baseline')
manifest_raw = blob(root, PUBLISHED, ARCHIVE + 'ROOT_MATERIAL_PRESERVATION_MANIFEST.json')
require(sha(manifest_raw) == MANIFEST_HASH, 'published P22 manifest')
for entry in decode(manifest_raw)['files']:
raw = read_source(root, entry['original_path'])
require(len(raw) == entry['bytes'] and sha(raw) == entry['sha256'], 'published P22 file pin')
def cite(start, end=None):
end = end or start
return {'path': BASELINE, 'lines': [start, end],
'text': '\n'.join(lines[start-1:end]), 'sha256': BASE_HASH}
rows = []
def add(identity, kind, spans, condition, owner, status='undefined', evidence=None,
reason='欠損: 個別ID・必要量・完成内容は原本未定義。D08/D09未決。', identity_data=None):
rows.append({'id': identity, 'kind': kind, 'identity': identity_data,
'citations': [cite(*span) for span in spans], 'required_condition': condition,
'status': status, 'required_count': None, 'completed_count': None,
'evidence': evidence or [], 'missing': 'original production content missing or undefined',
'reason': reason, 'owner': owner})
add('chapters.all', 'chapter', [(8,), (26,), (27,30), (256,)],
'メインストーリー全章。原本の仕様書章番号をシナリオ章と混同しない。章名・章数・順序・場面一覧は未定義。', 'D08')
add('endings.all', 'ending', [(9,), (135,)],
'全エンドを要求。個別エンドID・本数・到達条件は未定義。', 'D08', 'missing')
add('endings.bad', 'ending', [(135,)],
'特定バッドエンド確定時の敵/背景オブジェクト化。カテゴリのみ原本定義、対象人物/場面/数は未定義。', 'P26-02', 'missing')
add('endings.game_over', 'ending', [(72,)],
'赤字決算3回連続のゲームオーバー。物語エンド数への換算不可。', 'P26-02', 'missing',
[],
'欠損: 経済処理の既存コードは存在するが終点のシナリオ・画像・音声・全受入を証明しない。')
add('encyclopedia.all', 'encyclopedia', [(9,)],
'図鑑コンプ。対象集合・項目ID・解放条件・必要項目数は未定義。', 'D08', 'missing')
add('dialogue.all', 'dialogue', [(155,), (230,), (256,)],
'会話/イベント用台詞とシナリオ。本文・話者・行数・場面単位は未定義。UI通知文を完成脚本に数えない。', 'P26-02')
add('portraits.all', 'portrait', [(160,161), (230,), (235,), (242,)],
'メイン/サブ人物の表情・衣装・破損・汚れ・刻印・妊婦外見・ルート別描き分け。差分枚数未定義。', 'P26-03', 'missing')
add('voice.all', 'voice', [(252,), (257,)],
'キャラクターボイスの収録・実装。話者/台詞/クリップ数/担当未定義。BGMやUI音は代用不可。', 'P26-03', 'missing')
# All applicable full clauses are retained even where the original defines no finite scene list.
for key, spans, condition in [
('world', [(27,30)], '舞台・主人公・7人物の3運命を保つ。'),
('scope', [(31,34)], '原本表現要求と今回の非露骨な監査範囲との差異を残す。'),
('route_skills', [(132,), (134,135)], 'ルートによる技能・衣装補正・離脱条件。'),
('route_rules', [(138,161)], '3軸/盾・侵食・3種決闘・プレイヤー行動・履歴/外見の全共通条件。'),
('art', [(229,230), (234,235)], '原本のマップ/SD/立ち絵・モーションと描き分け。'),
('cg', [(242,243)], '人物立ち絵およびルート別CG/差分/モブ汎用スチル。'),
('motion', [(245,246)], '待機・歩行・作業・気絶・原本必須モーションと効果。'),
('production', [(252,), (255,258)], '原本のシナリオ/素材量産/音声制作と受入は未完。')]:
add('clause.' + key, 'category', spans, condition, 'P26-03', 'missing')
table = decode((root / P19).read_bytes())
table_pin = source(root, P19)
cells = {(c['character_id'], c['route_id']): c for c in table['cells']}
for i, (person, name) in enumerate(PEOPLE):
require(name in lines[117+i], 'original identity')
for route, route_name in ROUTES.items():
cell = cells[(person, route)]
require(cell['name'] == name and cell['route'] == route_name and
cell['natural_route_complete'] is False, 'P19 route identity/status')
add('route.' + person + '.' + route, 'route', [(118+i,), (30,), (132,), (138,161)],
'人物固有の入口・イベント列・分岐・終点・台詞・立ち絵・音声・履歴/保存と通常到達。',
'P26-02', 'missing', [dict(table_pin, cell=[person, route])],
'欠損: P19共通モデル/保存の部分実装は人物別制作/到達ではない。人物固有条件・場面列はD08未決。',
{'character_id': person, 'original_name': name, 'route_id': route, 'route_name': route_name})
# Preserve every existing usage record (614 usage groups, never 614 finished assets).
usage_inputs = ['facility_usage_slots.json', 'character_usage_slots.json',
'other_usage_slots.json', 'state_usage_groups.json']
usage_snapshots = {}
for filename in usage_inputs:
path = P22 + filename
records = decode(read_source(root, path))
usage_snapshots[path] = {'source': source(root, path), 'records': records}
for record in records:
anchors = record['source_anchors']
for a in anchors:
require(a['sha256'] == BASE_HASH and a['text'] == lines[a['line']-1], 'P22 citation')
representations = []
for rep in record.get('representations', []):
pin = source(root, rep['path'])
representations.append(dict(pin, historical_inventory=rep,
current_bytes_equal_inventory=pin['sha256'] == rep['sha256']))
kind = record.get('kind', 'usage')
status = 'missing'
if representations:
status = 'code-drawing' if all(r['historical_inventory']['representation'] == 'code_drawn'
for r in representations) else 'placeholder'
slot_id = record.get('slot_id', record.get('group_id'))
add('usage.' + slot_id, kind, [(a['line'],) for a in anchors],
record['usage_condition'], 'P26-03', status, representations,
'欠損: 利用枠/用途群。既存表現の現物SHAを再取得。汎用描画/仮素材は固有人物・必須差分の完成証明ではない。D09数量/品質/担当未決。',
{'usage_source': path, 'slot_id': slot_id})
references = {p: source(root, p) for p in [P19, BACKLOG] +
[P22 + n for n in ['contract.json', 'source_pins.json', 'current_media_inventory.json']]}
actual_support = [excerpt(root, 'data/characters/mvp_unique_characters.json', 1, 32),
excerpt(root, 'scripts/relationship/original_relationship_state.gd', 4, 34),
excerpt(root, 'autoload/simulation_runtime.gd', 6791, 6812),
excerpt(root, 'scripts/relationship/relationship_service.gd', 423, 479),
excerpt(root, 'scripts/ui/pixel_people.gd', 4, 18)]
media = decode(read_source(root, P22 + 'current_media_inventory.json'))
current_media = [dict(source(root, m['path']), historical_inventory=m) for m in media]
pins = decode((root / OUT / 'FROZEN_GAME_INPUT_PINS.json').read_bytes())
available_assets = {p: v for p, v in pins['source_files'].items() if p.startswith('assets/')}
original_lines, required_lines, atoms, relevant, packages = authorities(root)
# A separate row for every selected original clause prevents a broad category
# citation from concealing an omitted event/control requirement.
for n in sorted(required_lines):
add('original.line.' + str(n), 'original-clause', [(n,)],
'原本の必要条件。個別の脚本/台詞/CG/音声/場面数は定義しない。',
'undefined', 'missing', reason='原本条件を保存。専用制作内容は欠損、場面ID・数量は未定義。下記AR所有先で残作業を追跡。')
relationship_paths = ['scripts/relationship/original_relationship_state.gd',
'scripts/relationship/relationship_service.gd']
# Direct source inspection is limited to explicit state/container support.
# These excerpts never assert authored scenes, usable observation controls,
# or a naturally reachable character route.
require('class_name OriginalRelationshipState' in read_source(root, relationship_paths[0]).decode(),
'relationship class evidence')
peep_pin = excerpt(root, 'scripts/special/peeping_room_service.gd', 127, 162)
peep_runtime_pin = excerpt(root, 'autoload/simulation_runtime.gd', 10860, 10880)
require('RECORDER_ELIGIBILITY_PENDING_D04_D08_P16' in peep_pin['text'] and
'BUILD_DEFINITION_PENDING_D04' in peep_runtime_pin['text'], 'peeping backend boundary')
dispositions = {}
for row in rows:
cited = {n for c in row['citations'] for n in range(c['lines'][0], c['lines'][1] + 1)}
matches = sorted(a for a in relevant if cited.intersection(atomic_lines(atoms[a])))
if row['kind'] == 'route':
matches = ['AR.heroine.' + row['identity']['character_id'] + '.route.' + row['identity']['route_id']]
row['atomic_requirement_ids'] = matches
owners = sorted({atoms[a]['primary_package'] for a in matches})
row['owner'] = '/'.join(owners) if owners else 'undefined'
row['owner_deliverable_refs'] = owners
row['owner_assignment'] = 'original package deliverables; specific production assignee undefined'
row['content_status'] = row['status']
row['support_scope'] = 'no dedicated support established'
row['scoped_support'] = []
for kind, policy in SUPPORT_POLICY.items():
supported = sorted(set(matches) & policy['ars']) if support_family(row) else []
if supported:
row['scoped_support'].append({'kind': kind, 'atomic_requirement_ids': supported,
'scope': policy['scope'], 'content_status': 'missing'})
row['evidence'] += support_pins(root, kind)
supported_ids = {a for claim in row['scoped_support'] for a in claim['atomic_requirement_ids']}
row['unsupported_atomic_requirement_ids'] = sorted(set(matches) - supported_ids)
if row['scoped_support']:
row['status'] = 'partial'
row['support_scope'] = '; '.join(c['scope'] for c in row['scoped_support'])
row['reason'] += ' scoped_supportに列挙したARの基盤断片のみpartial。他のARは未支援、全制作内容は欠損。'
if row['id'].startswith('clause.'):
row['reason'] += ' 所有先は引用ARのprimary_packageと原P単位定義。包括P26-03への割当はしない。'
for identity, atom in atoms.items():
linked = [r['id'] for r in rows if identity in r['atomic_requirement_ids']]
dispositions[identity] = {
'record': {k: atom[k] for k in ('id', 'condition', 'source_anchors',
'source_sha256', 'primary_package', 'work_packages')},
'rows': linked,
'disposition': 'covered-by-row' if identity in relevant else 'out-of-scope-with-reason',
'reason': ('原本条件・欠損は各行に記録。ARの旧監査合否は転記しない。'
if identity in relevant else 'L226カットイン警告・決闘/防衛演出はP24 UIの所管。制作/受入済みを意味しない。'
if identity in {'AR.ui.cut_duel', 'AR.ui.cut_defense'} else
'原本内容行に結合しない追加施設契約/採用・財布条件、または内容語彙/対象節/内容ARドメインに該当しない独立メカニクス。製品受入は当該原P単位の責任。'),
'owning_deliverables': owning_units(atom, packages)}
approval_raw = blob(root, APPROVAL_COMMIT, APPROVAL_PATH)
require(sha(approval_raw) == APPROVAL_HASH, 'published D05 approval')
approval = {'commit': APPROVAL_COMMIT, 'path': APPROVAL_PATH,
'sha256': APPROVAL_HASH, 'record': decode(approval_raw),
'scope': 'actual approval received; P04 product adoption remains outside P26'}
return {'task_id': 'P26-01-LEDGER', 'schema_version': 1, 'source_anchor': ANCHOR,
'definition_base': BASE, 'rows': rows, 'usage_snapshots': usage_snapshots,
'coverage_policy': {'line_selector': CONTENT_WORDS.pattern,
'whole_sections': '世界観|ユニークキャラクター|恋愛|デザイン|アセット|ロードマップ',
'ar_domains': list(CONTENT_DOMAINS), 'selected_original_lines': sorted(required_lines),
'relevant_ar_ids': sorted(relevant)},
'atomic_authority': source(root, AR_PATH), 'atomic_dispositions': dispositions,
'owning_package_definitions': {p: {'title': v['title'],
'remaining_deliverables': [{k: u[k] for k in ('id', 'title', 'work', 'acceptance')}
for u in v['remaining_deliverables']]} for p, v in packages.items()},
'current_D05_approval': approval,
'published_material': {'anchor': PUBLISHED, 'manifest_sha256': MANIFEST_HASH,
'manifest_path': ARCHIVE + 'ROOT_MATERIAL_PRESERVATION_MANIFEST.json'},
'references': references, 'P22_03_authority': 'draft/unaccepted reference only',
'actual_support': actual_support,
'support_interpretation': 'Explicit state/identity/container support only; generic pose has no original character-specific appearance. No finished chapter/dialogue/portrait/voice/encyclopedia inferred.',
'current_media_reaudit': current_media,
'available_asset_source_set': available_assets,
'pending': {'D05': 'Historical register only: unresolved as of audit anchor f4f85bc54c39361f9efcd16f67a17835559ca70c and base 379a1ba8fd25b5ad24226178a2492f91761dc31d. Current actual approval is separately pinned; P04 adoption pending outside P26.',
'D08': 'unresolved', 'D09': 'unresolved'},
'accounting': {'packages': 30, 'units': 88, 'completed': 15, 'credit': 0,
'partial_credit': False, 'whole_unit_accepted': False},
'native_evidence': {'reuse_accepted': False, 'new_full_run': False,
'historical_only': {'suites': 184, 'checks': 21903, 'parse': 329, 'owned_ended': 186},
'reason': 'Runner must independently establish exact S/C applicability; no native result claimed here.'}}
def verify_authority(candidate, root):
"""Non-canonical coverage/ownership/citation checks, callable on their own."""
lines, required, atoms, relevant, packages = authorities(root)
seen_lines, seen_atoms = set(), set()
row_ids = {r['id'] for r in candidate['rows']}
for row in candidate['rows']:
require(set(row) == {'id', 'kind', 'identity', 'citations', 'required_condition',
'status', 'required_count', 'completed_count', 'evidence', 'missing', 'reason',
'owner', 'atomic_requirement_ids', 'owner_deliverable_refs', 'owner_assignment',
'content_status', 'support_scope', 'scoped_support',
'unsupported_atomic_requirement_ids'}, 'closed row schema')
require(row['status'] in STATUSES, 'invalid row status')
require(row['required_count'] is None and row['completed_count'] is None, 'invented quantity')
require(type(row['atomic_requirement_ids']) is list, 'AR ID list')
ids = row['atomic_requirement_ids']
require(set(ids) <= relevant, 'unknown or unrelated AR ID')
owners = sorted({atoms[a]['primary_package'] for a in ids})
require(row['owner'] == ('/'.join(owners) if owners else 'undefined') and
row['owner_deliverable_refs'] == owners, 'AR owner mismatch')
cited = set()
for c in row['citations']:
lo, hi = c['lines']
require(type(lo) is int and type(hi) is int and 1 <= lo <= hi <= len(lines), 'citation lines')
require(c['path'] == BASELINE and c['sha256'] == BASE_HASH and
c['text'] == '\n'.join(lines[lo-1:hi]), 'original citation integrity')
cited.update(range(lo, hi + 1))
require(all(cited.intersection(atomic_lines(atoms[a])) for a in ids), 'AR citation correspondence')
seen_lines.update(cited)
seen_atoms.update(ids)
require(row['content_status'] in STATUSES and row['content_status'] != 'produced', 'unsupported content production')
require(row['status'] != 'produced', 'partial support falsely produced')
# Inspect submitted claims and pins directly, without expected() or a
# generated row. The policy is a normative compatibility table, not a
# keyword classifier. A valid hash alone cannot establish relevance.
claims = row['scoped_support']
require(type(claims) is list, 'support claims type')
claimed_ids, kinds, allowed_pins = set(), set(), []
for claim in claims:
require(set(claim) == {'kind', 'atomic_requirement_ids', 'scope', 'content_status'},
'closed support claim')
kind = claim['kind']
require(kind in SUPPORT_POLICY and kind not in kinds, 'unknown/duplicate support kind')
policy = SUPPORT_POLICY[kind]
require(support_family(row), 'support incompatible with requirement family')
targets = claim['atomic_requirement_ids']
require(type(targets) is list and targets and len(targets) == len(set(targets)) and
set(targets) <= set(ids) & policy['ars'], 'support incompatible with AR')
require(claim['scope'] == policy['scope'] and claim['content_status'] == 'missing',
'support scope/content mismatch')
kinds.add(kind)
claimed_ids.update(targets)
for path, span in policy['sources']:
pin = source(root, path)
if span:
pin.update(lines=list(span), text='\n'.join(
read_source(root, path).decode().splitlines()[span[0]-1:span[1]]))
allowed_pins.append(pin)
require(row['unsupported_atomic_requirement_ids'] == sorted(set(ids) - claimed_ids),
'unsupported AR partition')
require((row['status'] == 'partial') == bool(claims),
'route support/content distinction' if row['kind'] == 'route' else 'unqualified partial support')
if claims:
require(row['support_scope'] == '; '.join(c['scope'] for c in claims) and
row['content_status'] == 'missing', 'support summary/content mismatch')
else:
require(row['support_scope'] == 'no dedicated support established', 'unsupported scope text')
if not row['id'].startswith('usage.'):
if row['kind'] == 'route':
allowed_pins.insert(0, dict(source(root, P19), cell=[
row['identity']['character_id'], row['identity']['route_id']]))
require(encoded(row['evidence']) == encoded(allowed_pins), 'unrelated or unscoped support evidence')
else:
require(not claims, 'usage cannot inherit backend support')
if row['kind'] in {'voice', 'ending', 'encyclopedia'}:
require(row['status'] == row['content_status'] == 'missing' and not row['evidence'],
'unsupported content family')
for pin in row['evidence']:
require(encoded({k: pin[k] for k in ('path', 'sha256', 'bytes', 'anchor', 'anchor_path')}) ==
encoded(source(root, pin['path'])), 'row evidence integrity')
if row['id'].startswith('usage.'):
reps = row['evidence']
status = ('code-drawing' if all(p['historical_inventory']['representation'] == 'code_drawn'
for p in reps) else 'placeholder') if reps else 'missing'
require(row['status'] == status and row['content_status'] == status, 'usage representation status')
if row['kind'] == 'route':
require(row['status'] == 'partial' and row['content_status'] == 'missing', 'route support/content distinction')
identity = row['identity']
require((identity['character_id'], identity['original_name']) in PEOPLE and
ROUTES.get(identity['route_id']) == identity['route_name'], 'original route identity')
require(ids == ['AR.heroine.' + identity['character_id'] + '.route.' + identity['route_id']], 'route AR identity')
require(required <= seen_lines, 'independent original line coverage')
require(relevant <= seen_atoms, 'independent AR coverage')
require(set(candidate['atomic_dispositions']) == set(atoms), 'AR disposition set')
for identity, atom in atoms.items():
entry = candidate['atomic_dispositions'][identity]
require(entry['record'] == {k: atom[k] for k in entry['record']} and
set(entry['record']) == {'id', 'condition', 'source_anchors', 'source_sha256', 'primary_package', 'work_packages'},
'stale AR record')
require(entry['owning_deliverables'] == owning_units(atom, packages), 'AR owning deliverables')
require(entry['reason'] and entry.get('disposition') == (
'covered-by-row' if identity in relevant else 'out-of-scope-with-reason'), 'missing AR disposition')
require(set(entry['rows']) <= row_ids and set(entry['rows']) == {
r['id'] for r in candidate['rows'] if identity in r['atomic_requirement_ids']}, 'AR row linkage')
require(identity not in relevant or entry['rows'], 'AR without content row')
def verify(candidate, canonical, root=None):
require(type(candidate) is dict, 'closed document object')
require(type(candidate.get('rows')) is list, 'closed rows list')
rows = candidate['rows']
ids = [r['id'] for r in rows]
require(len(ids) == len(set(ids)), 'duplicate row ID')
require(set(ids) == {r['id'] for r in canonical['rows']}, 'original clause/slot coverage')
for row in rows:
require(row['status'] in STATUSES, 'invalid status')
require(row['required_count'] is None and row['completed_count'] is None, 'invented quantity')
require(row['status'] != 'produced', 'unsupported completion')
require(row['citations'] and row['reason'] and row['missing'], 'missing traceability')
require(root is not None, 'independent authority root required')
verify_authority(candidate, root)
require(encoded(candidate) == encoded(canonical), 'ledger differs from original/source-bound audit')
def markdown(document):
# A lossless deterministic human view, including exact citations and source hashes.
lines = ['# P26-01 全必要内容の台帳', '',
'15/88、今回加算0。未制作/未定義は欠損。利用枠数は完成数ではない。', '',
'D05の過去未解決記録はf4f85bc/379a1ba時点。現行承認は別途固定。D08/D09未解決。P22-03は未受入参考。', '']
for row in document['rows']:
lines += ['## ' + row['id'], '', '- 種別: ' + row['kind'],
'- 識別: ' + encoded(row['identity']),
'- 状態: ' + row['status'], '- 所管: ' + row['owner'],
'- 内容状態: ' + row['content_status'], '- 部分支援範囲: ' + row['support_scope'],
'- AR: ' + encoded(row['atomic_requirement_ids']),
'- AR別部分支援(列挙ARの基盤断片のみ、制作内容はmissing): ' + encoded(row['scoped_support']),
'- 基盤支援なしのAR: ' + encoded(row['unsupported_atomic_requirement_ids']),
'- 所管単位: ' + encoded(row['owner_deliverable_refs']) + ' (固定参照の原P単位定義を参照)',
'- 条件: ' + (row['required_condition'] if isinstance(row['required_condition'], str)
else encoded(row['required_condition'])), '- 理由: ' + row['reason'],
'- 必要数/完成数: 未定義 / 未計数', '- 欠損: ' + row['missing'], '']
for c in row['citations']:
lines += [f"原本 `{c['path']}:{c['lines'][0]}-{c['lines'][1]}` SHA256 `{c['sha256']}`", '',
'```text', c['text'], '```', '']
lines += ['証拠(空配列は明示的欠損):', '', '```json',
json.dumps(row['evidence'], ensure_ascii=False, indent=2), '```', '']
lines += ['## 固定参照・監査境界', '', '```json',
json.dumps({k: v for k, v in document.items() if k != 'rows'}, ensure_ascii=False, indent=2), '```', '']
return '\n'.join(lines)
def check_view(raw, document):
require(raw == markdown(document).encode(), 'Markdown drift')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--project-root', type=Path, default=Path('.'))
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument('--check', action='store_true')
mode.add_argument('--source-binding-only', action='store_true')
mode.add_argument('--write', action='store_true')
args = parser.parse_args()
root = args.project_root.resolve()
try:
result = binding(root)
canonical = expected(root)
if args.write:
(root / OUT / 'CONTENT_LEDGER.json').write_text(json.dumps(canonical, ensure_ascii=False, indent=2) + '\n')
(root / OUT / 'CONTENT_LEDGER.md').write_text(markdown(canonical))
else:
document = decode((root / OUT / 'CONTENT_LEDGER.json').read_bytes())
verify(document, canonical, root)
if args.check:
check_view((root / OUT / 'CONTENT_LEDGER.md').read_bytes(), document)
print(json.dumps({'documentation_check': 'pass', **result}, ensure_ascii=False))
return 0
except (ValueError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as error:
print('P26 audit rejected: ' + str(error), file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Runner negative controls for P26; no fixture writes and no native execution."""
import argparse
import copy
from pathlib import Path
import unittest
from unittest.mock import patch
import check_content_ledger as audit
class LedgerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.canonical = audit.expected(ROOT)
cls.submitted = audit.decode((ROOT / audit.OUT / 'CONTENT_LEDGER.json').read_bytes())
def reject(self, mutate):
audit.verify(self.submitted, self.canonical, ROOT)
value = copy.deepcopy(self.submitted)
mutate(value)
with self.assertRaises((ValueError, TypeError, KeyError)):
audit.verify(value, self.canonical, ROOT)
def test_valid_submitted_and_independent_source_control(self):
audit.verify(self.submitted, self.canonical, ROOT)
audit.verify(copy.deepcopy(self.canonical), self.canonical, ROOT)
audit.check_view((ROOT / audit.OUT / 'CONTENT_LEDGER.md').read_bytes(), self.submitted)
def test_required_sets_are_independent_of_submitted_rows(self):
ids = {r['id'] for r in self.canonical['rows']}
self.assertTrue({'chapters.all', 'endings.all', 'endings.bad', 'endings.game_over',
'dialogue.all', 'portraits.all', 'voice.all', 'encyclopedia.all'} <= ids)
self.assertEqual({r['id'] for r in self.canonical['rows'] if r['kind'] == 'route'},
{'route.' + p + '.' + r for p, _ in audit.PEOPLE for r in audit.ROUTES})
def test_omitted_original_categories_and_route(self):
for identity in ['chapters.all', 'route.satsuki.corruption', 'endings.all',
'endings.bad', 'endings.game_over', 'dialogue.all', 'voice.all',
'portraits.all', 'encyclopedia.all', 'clause.route_rules']:
with self.subTest(identity=identity):
self.reject(lambda d: d.update(rows=[r for r in d['rows'] if r['id'] != identity]))
def test_missing_usage_slot(self):
self.reject(lambda d: d.update(rows=[r for r in d['rows'] if r['id'] !=
next(x['id'] for x in d['rows'] if x['id'].startswith('usage.'))]))
def test_fabricated_quantity_and_false_completion(self):
self.reject(lambda d: d['rows'][0].update(required_count=8))
self.reject(lambda d: d['rows'][0].update(completed_count=0))
self.reject(lambda d: d['rows'][0].update(status='produced', missing=None))
def test_closed_schema_owner_status_type_and_duplicates(self):
for field, value in [('owner', 'P99-99'), ('status', 'pass'), ('kind', 'invented'),
('required_count', False), ('citations', []), ('extra', True)]:
with self.subTest(field=field):
self.reject(lambda d: d['rows'][0].update({field: value}))
self.reject(lambda d: d['rows'].append(copy.deepcopy(d['rows'][0])))
self.reject(lambda d: d.update(schema_version=True))
def test_exact_route_identity(self):
self.reject(lambda d: next(r for r in d['rows'] if r['kind'] == 'route')
['identity'].update(original_name='別人'))
def test_stale_original_and_source(self):
self.reject(lambda d: d['rows'][0]['citations'][0].update(text='stale'))
self.reject(lambda d: d['rows'][0]['citations'][0].update(sha256='0' * 64))
self.reject(lambda d: d['references'][audit.P19].update(sha256='0' * 64))
self.reject(lambda d: d.update(source_anchor='HEAD'))
def test_malformed_json_duplicate_keys_and_nonfinite(self):
self.assertEqual(audit.decode('{"rows": []}'), {'rows': []})
for raw in ['{', '{"x":1,"x":2}', '{"x":NaN}', '{"x":Infinity}']:
with self.subTest(raw=raw), self.assertRaises(ValueError):
audit.decode(raw)
def test_markdown_drift(self):
view = audit.markdown(self.submitted).encode()
audit.check_view(view, self.submitted)
with self.assertRaises(ValueError):
audit.check_view(view + b'\n', self.submitted)
def test_definition_projection_allows_only_progress(self):
packages = audit.decode(audit.blob(ROOT, audit.BASE, audit.PLAN + 'work_packages.json'))
changed = copy.deepcopy(packages)
changed[0]['state'] = 'progress-only-control'
changed[0]['remaining_deliverables'][0]['evidence'] = ['progress-only-control']
self.assertEqual(audit.encoded(audit.projection(packages)), audit.encoded(audit.projection(changed)))
for field in ['acceptance', 'work', 'depends_on', 'requirement_ids']:
changed = copy.deepcopy(packages)
changed[0][field] = 'mutated definition'
self.assertNotEqual(audit.encoded(audit.projection(packages)), audit.encoded(audit.projection(changed)))
def test_published_source_changed_bytes_rejected(self):
original = audit.blob
def stale(root, anchor, path):
raw = original(root, anchor, path)
return raw + b' ' if path == audit.ARCHIVE + 'ROOT_MATERIAL_PRESERVATION_MANIFEST.json' else raw
with patch.object(audit, 'blob', stale), self.assertRaises(ValueError):
audit.expected(ROOT)
def test_selected_input_mutation_rejected(self):
actual = audit.selection(ROOT)
# Valid map control comes from frozen independent pins, not a second selection call.
pins = audit.decode((ROOT / audit.OUT / 'FROZEN_GAME_INPUT_PINS.json').read_bytes())
self.assertEqual(audit.encoded(actual), audit.encoded(pins['source_files']))
for mode in ('missing', 'new', 'changed', 'wrong_type'):
changed = copy.deepcopy(actual)
key = 'project.godot'
if mode == 'missing':
del changed[key]
elif mode == 'new':
changed['data/unapproved.json'] = {'bytes': 0, 'sha256': '0' * 64}
elif mode == 'changed':
changed[key]['sha256'] = '0' * 64
else:
changed[key]['bytes'] = str(changed[key]['bytes'])
with self.subTest(mode=mode), patch.object(audit, 'selection', return_value=changed), self.assertRaises(ValueError):
audit.binding(ROOT)
def independent_reject(self, mutate, message):
# The positive control and rejection invoke only the independent layer:
# no canonical document/equality can make these tests pass.
audit.verify_authority(self.submitted, ROOT)
value = copy.deepcopy(self.submitted)
mutate(value)
with self.assertRaisesRegex(ValueError, message):
audit.verify_authority(value, ROOT)
def test_omitted_review_event_clauses(self):
for n in (13, 53, 65, 196, 221, 222):
with self.subTest(line=n):
self.reject(lambda d: d.update(rows=[r for r in d['rows']
if r['id'] != 'original.line.' + str(n)]))
# Remove a line from every citation (including broad category ranges),
# preserving other lines, AR links and IDs: authority coverage must fail.
def omit(d):
for row in d['rows']:
row['citations'] = [c for c in row['citations']
if not c['lines'][0] <= 65 <= c['lines'][1]]
self.independent_reject(omit, 'AR citation correspondence|independent original line coverage')
def test_atomic_coverage_unknown_and_missing_disposition(self):
identity = 'AR.events.ntr_night_peak'
def remove(d):
for row in d['rows']:
if identity in row['atomic_requirement_ids']:
row['atomic_requirement_ids'].remove(identity)
if identity in row['unsupported_atomic_requirement_ids']:
row['unsupported_atomic_requirement_ids'].remove(identity)
for claim in row['scoped_support']:
if identity in claim['atomic_requirement_ids']:
claim['atomic_requirement_ids'].remove(identity)
atoms = audit.authorities(ROOT)[2]
owners = sorted({atoms[a]['primary_package'] for a in row['atomic_requirement_ids']})
row.update(owner='/'.join(owners) if owners else 'undefined', owner_deliverable_refs=owners)
self.independent_reject(remove, 'independent AR coverage')
self.independent_reject(lambda d: d['rows'][0]['atomic_requirement_ids'].append('AR.unknown'), 'unknown or unrelated AR ID')
self.independent_reject(lambda d: d['atomic_dispositions'][identity].pop('disposition'), 'disposition')
def test_owners_route_usage_and_partial_truth(self):
for prefix in ('route.', 'usage.', 'original.line.65'):
with self.subTest(prefix=prefix):
self.independent_reject(lambda d: next(r for r in d['rows']
if r['id'].startswith(prefix)).update(owner='P26-03'), 'AR owner mismatch')
self.independent_reject(lambda d: next(r for r in d['rows'] if r['kind']=='route')
.update(status='missing'), 'route support/content distinction')
self.independent_reject(lambda d: next(r for r in d['rows'] if r['id'].startswith('usage.') and r['evidence'])
.update(status='missing'), 'usage representation status')
self.independent_reject(lambda d: next(r for r in d['rows'] if r['status']=='partial')
.update(status='produced'), 'partial support falsely produced')
def test_stale_atomic_original_and_usage_pins(self):
identity = 'AR.events.ntr_night_peak'
self.independent_reject(lambda d: d['atomic_dispositions'][identity]['record']
.update(source_sha256='0'*64), 'stale AR record')
self.independent_reject(lambda d: d['rows'][0]['citations'][0]
.update(sha256='0'*64), 'original citation integrity')
self.independent_reject(lambda d: next(r for r in d['rows'] if r['id'].startswith('usage.') and r['evidence'])
['evidence'][0].update(sha256='0'*64), 'row evidence integrity')
def test_closed_support_attribution_independent_controls_and_injection(self):
rows = {r['id']: r for r in self.submitted['rows']}
audit.verify_authority(self.submitted, ROOT)
for identity, kind in [('route.anri.love', 'relationship-container'),
('original.line.65', 'night-input'),
('original.line.53', 'peeping-container')]:
with self.subTest(control=identity):
self.assertEqual(rows[identity]['status'], 'partial')
self.assertEqual(rows[identity]['content_status'], 'missing')
self.assertIn(kind, {c['kind'] for c in rows[identity]['scoped_support']})
for identity in ('voice.all', 'portraits.all', 'endings.all', 'encyclopedia.all'):
self.assertEqual(rows[identity]['status'], 'missing')
self.assertEqual(rows[identity]['evidence'], [])
# Valid pins + the old textual qualifier must not confer support.
def inject(d):
row = next(r for r in d['rows'] if r['id'] == identity)
row.update(status='partial', support_scope='backend only',
evidence=copy.deepcopy(rows['original.line.53']['evidence']))
self.independent_reject(inject, 'unqualified partial support')
# Even a well-formed observation claim linked on the SAME source line
# cannot be attached to a voice summary.
def voice_claim(d):
voice = next(r for r in d['rows'] if r['id'] == 'voice.all')
mixed = rows['original.line.257']
for key in ('status', 'support_scope', 'scoped_support', 'evidence'):
voice[key] = copy.deepcopy(mixed[key])
self.independent_reject(voice_claim, 'support incompatible with requirement family')
# An original-clause row is an eligible family, but its debt-settlement
# ARs still cannot receive a peeping-container claim.
def content_claim(d):
row = next(r for r in d['rows'] if r['id'] == 'original.line.196')
donor = rows['original.line.53']
for key in ('status', 'support_scope', 'scoped_support', 'evidence'):
row[key] = copy.deepcopy(donor[key])
row['scoped_support'][0]['atomic_requirement_ids'] = row['atomic_requirement_ids'][:]
row['unsupported_atomic_requirement_ids'] = []
self.assertEqual(rows['original.line.196']['status'], 'missing')
self.independent_reject(content_claim, 'support incompatible with AR')
# Mixed rows retain missing voice/end/encyclopedia ARs; an attacker
# cannot expand a valid backend claim to those ARs.
for identity, atom in [('original.line.257', 'AR.schedule.B-14.3.voice_record'),
('clause.production', 'AR.asset.character_voice'),
('original.line.9', 'AR.progression.all_endings'),
('original.line.9', 'AR.progression.encyclopedia_complete')]:
with self.subTest(mixed=identity, atom=atom):
self.assertIn(atom, rows[identity]['unsupported_atomic_requirement_ids'])
def expand(d):
row = next(r for r in d['rows'] if r['id'] == identity)
row['scoped_support'][0]['atomic_requirement_ids'].append(atom)
row['unsupported_atomic_requirement_ids'].remove(atom)
self.independent_reject(expand, 'support incompatible with AR')
# A valid claim does not license an additional, unrelated pinned file.
self.independent_reject(lambda d: next(r for r in d['rows'] if r['id']=='original.line.65')
['evidence'].extend(copy.deepcopy(rows['original.line.53']['evidence'])),
'unrelated or unscoped support evidence')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--project-root', type=Path, default=Path('.'))
args, remaining = parser.parse_known_args()
ROOT = args.project_root.resolve()
unittest.main(argv=[__file__] + remaining)