From fb20b663df97bb685887e5bef9bb6a3256fa7580 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 11 Aug 2026 12:54:13 +0800 Subject: [PATCH] validator: add R4 semantic regression gates --- docs/reviews/DESIGN_VALIDATION_CONTRACT.md | 97 ++- tools/validate_predevelopment_design.py | 764 ++++++++++++++++++++- 2 files changed, 845 insertions(+), 16 deletions(-) diff --git a/docs/reviews/DESIGN_VALIDATION_CONTRACT.md b/docs/reviews/DESIGN_VALIDATION_CONTRACT.md index b5563088..311c0048 100644 --- a/docs/reviews/DESIGN_VALIDATION_CONTRACT.md +++ b/docs/reviews/DESIGN_VALIDATION_CONTRACT.md @@ -1,8 +1,8 @@ # Pre-development Design Validation Contract -> Contract revision: `abg-design-validation-v2-crossdoc-r3-h1` +> Contract revision: `abg-design-validation-v2-crossdoc-r4-h2` > Frozen input: `.agent-taskgraph/spec.md` revision `design-spec-r3` -> Integrated schema evidence: `b65646c` +> Integrated schema evidence: `8102bd2` > Executable: `tools/validate_predevelopment_design.py` > Scope: merged Markdown design authorities plus one scoped reference gate; no runtime or product authority @@ -13,7 +13,7 @@ UX, art, and audio design package. It detects mechanical omissions before independent review without choosing product values or changing another workstream's schema. -The executable answers ten bounded questions: +The executable answers twelve bounded questions: 1. Do the primary catalogs contain the exact frozen content envelope with unique, gap-free stable IDs and nonempty rows? @@ -32,6 +32,12 @@ The executable answers ten bounded questions: declare the matching total? 10. Does the scoped reference authority reject rather than prescribe retired fixed-cast and relationship scope? +11. Is one Run seed durably reserved and read back before opening generation, + with all four offers, a revisable pair, and atomic exactly-once promotion + bound to that one draft? +12. Does every hidden-profession art row bind the exact content-owned identity, + parents, mechanic, silhouette/prop brief, VFX cue, and action language + without a deferred content pointer? Passing is necessary but not sufficient. The validator cannot establish originality, balance, comprehension, readability, production feasibility, @@ -296,6 +302,58 @@ cast, or an approved exact scope of 30 named recruitable characters fails. Markdown table cells are evaluated separately so a filler-rejection cell does not negate a positive requirement in another cell on the same row. +### 8.6 Seed-First Run-Creation Transaction + +`SAVE_AND_FAILURE_CONTRACT.md` owns the durable transaction. Its seed-first +section must place an applied, persisted or read-back Run seed/draft boundary +before opening generation. `RECRUIT_GENERATION.md` must independently restrict +opening generation to that validated read-back draft created by seed +reservation. A seed that exists only inside `current_run`, or one persisted +after generation, fails `RUN_CREATION_SEED_ORDER_MISMATCH`. + +The save schema must declare `run_creation_draft` with one seed, the exact +four-offer set, the exact two-offer selection and its revision, mutually +exclusive with `current_run`. The transaction table must semantically bind four +operations: reserve a seed-only draft, materialize all four indexed offer +records/IDs/hashes, persist a revisable exact pair over those same four, and +promote the same seed and selected records while removing the draft. The +detailed flow must also establish one-commit offer materialization, revision +without regeneration, atomic promotion, and retry resolution to the original +applied receipt without a second Run or second transfer. + +These checks accept equivalent wording such as persisted/durable, read-back, +all-or-nothing/atomic, original/one applied receipt, and no/cannot create a +second Run. They do not match a repaired sentence byte-for-byte. Missing schema, +table, revision, materialization, promotion, or retry evidence fails +`RUN_CREATION_TRANSACTION_INCOMPLETE`. + +### 8.7 Hidden-Profession Content/Art Binding + +`PROFESSION_CATALOG.md` owns `PF-H01..PF-H06`, each name, exact two-base parent +set, core mechanic and comma-delimited production brief. The validator derives +those values from the Hidden Hybrid Professions table; it does not keep a +second hard-coded parent or mechanic map. + +The matching Hidden Hybrid Profession Kits table in +`ART_ASSET_AND_ANIMATION_CATALOG.md` must provide, for every ID: + +- the exact `ART-PRO-Hxx`, `CONTENT.PROFESSION.HIDDEN[xx]`, `PF-Hxx` and name; +- the same two `PF-Bxx` parents as content; +- resolved silhouette translation and held/worn prop or anchor fields; +- resolved VFX/event-cue and animation/action-verb fields; +- semantic overlap between the content mechanic and art translation, and + between each content production channel and its art field. + +Comparison removes Markdown emphasis, case, plural and common inflection +differences, then ignores generic connective vocabulary. This permits +independently written production language while still rejecting an unrelated +bow/vial, hammer/lantern, or other mechanic package. Missing identities, +parents, fields, or mechanic-compatible channel bindings fail +`HIDDEN_PROFESSION_ART_MISMATCH`. Deferred forms such as +`CONTENT.PROFESSION.HIDDEN[xx].parents`, unresolved, placeholder, pending, or +unknown fail `HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED` and are not also +reported as a parent mismatch for the same row. + ## 9. Diagnostics And Exit Contract Run from the repository root: @@ -333,6 +391,10 @@ The main diagnostic families are: | `RECRUIT_ART_CROSSWALK_MISMATCH` | recruit fields, counts, sources, or deterministic mappings do not agree | | `AUDIO_SCREEN_INVENTORY_MISMATCH` | audio screen IDs/count do not equal the primary screen registry | | `REFERENCE_RETIRED_SCOPE` | the scoped reference authority positively prescribes retired cast/relationship scope | +| `RUN_CREATION_SEED_ORDER_MISMATCH` | durable seed/read-back does not precede and authorize opening generation | +| `RUN_CREATION_TRANSACTION_INCOMPLETE` | exact-four materialization, revisable pair, atomic promotion, or exactly-once retry evidence is incomplete | +| `HIDDEN_PROFESSION_ART_MISMATCH` | hidden art identity, parents, mechanic, silhouette/props, VFX, or actions disagree with content | +| `HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED` | hidden art delegates a required row binding to unresolved/pending language | ## 10. Self-test And Integrated Evidence @@ -351,6 +413,11 @@ coverage for every false-positive class from Attempt 1: - all ten generated-recruit crosswalk rows with compatible source counts - an exact screen/audio ID set including `OUT-006` - historical/rejection-only reference mentions of retired scope +- a durable seed-only draft read back before opening generation, followed by + exact-four materialization, revisable exact-pair selection, and atomic + exactly-once Run promotion +- six content-owned hidden-profession parent/mechanic/production briefs with + exact resolved silhouette, prop, VFX, and action bindings - an alternate integrated-shape pass using staged `SYS-005` onboarding, side-first endpoint clauses, embedded stored-authority counts, and bounded art-binding ranges @@ -359,17 +426,21 @@ Negative fixtures cover exact counts, regional gaps, primary duplicates, broken references, unresolved and unknown bindings, unbounded ranges, positive retired concepts, filler rows, page/art/audio omissions, audio duplicates, and one isolated mutation for each of the five Experience R3 contradiction classes. -Those five mutations run after rewriting the fixture into the alternate -integrated shape, so flexible positive parsing does not weaken the negative -gates. The success terminal remains: +Four additional isolated R4 mutations invert seed/generation order, remove the +atomic promotion proof, substitute a mechanic-incompatible hidden-art package, +and restore a deferred hidden-parent pointer. The five R3 mutations run after +rewriting the fixture into the alternate integrated shape, while all four R4 +mutations run against the same positive semantic fixture. Flexible positive +parsing therefore cannot weaken either negative boundary. The success terminal +is: ```text -ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=20 +ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=24 ``` -On integrated revision `b65646c`, all prior exact content/audio registry checks -remain satisfied and the repaired real documents resolve with zero diagnostics -under the H1 semantic parsers. The self-test still proves all five immutable -Experience R3 contradiction classes fail when independently mutated: -provisional presentation, party direction, recruit field crosswalk, -`OUT-006`/screen total, and positive retired reference scope. +On integrated product revision `8102bd2`, all prior exact content/audio registry +checks remain satisfied and the repaired real documents resolve with zero +diagnostics. The self-test still proves all five immutable Experience R3 +contradiction classes fail independently, and now also proves both R4 failure +boundaries in two ways each: seed order versus durable transaction completeness, +and hidden mechanic mismatch versus unresolved art binding. diff --git a/tools/validate_predevelopment_design.py b/tools/validate_predevelopment_design.py index a2758cf5..fc79a41a 100755 --- a/tools/validate_predevelopment_design.py +++ b/tools/validate_predevelopment_design.py @@ -180,6 +180,28 @@ class RegistryRule: heading_pattern: Optional[re.Pattern] = None +@dataclass(frozen=True) +class HiddenProfessionContent: + identifier: str + name: str + parents: Tuple[str, ...] + mechanic: str + production_segments: Tuple[str, ...] + line: int + + +@dataclass(frozen=True) +class HiddenProfessionArt: + identifier: str + identity: str + parents: Tuple[str, ...] + translation: str + props: str + vfx: str + actions: str + line: int + + def numbered_ids(prefix: str, count: int, width: int) -> Tuple[str, ...]: return tuple("{}{:0{}d}".format(prefix, number, width) for number in range(1, count + 1)) @@ -248,11 +270,75 @@ ENEMY_PATH = "docs/content/ENEMY_CATALOG.md" SCREEN_PATH = "docs/presentation/SCREEN_AND_STATE_MAP.md" AUDIO_PATH = "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md" PRODUCT_PATH = "docs/product/GAME_PRODUCT_CONTRACT.md" +SAVE_PATH = "docs/product/SAVE_AND_FAILURE_CONTRACT.md" RECRUIT_PATH = "docs/content/RECRUIT_GENERATION.md" +PROFESSION_PATH = "docs/content/PROFESSION_CATALOG.md" UI_PATH = "docs/presentation/UI_AND_VISUAL_SYSTEM.md" ART_PATH = "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md" REFERENCE_SCOPE_PATH = "docs/reference/TRANSFERABLE_STRUCTURE_LESSONS.md" PAGE_ID_RE = re.compile(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}") +HIDDEN_PROFESSION_IDS = numbered_ids("PF-H", 6, 2) +HIDDEN_ART_UNRESOLVED_RE = re.compile( + r"\b(?:unresolved|placeholder|pending|unknown|to\s+be\s+(?:resolved|bound))\b|" + r"CONTENT\.PROFESSION\.HIDDEN\[\d{2}\]\.parents\b", + re.IGNORECASE, +) +SEMANTIC_STOP_WORDS = frozenset( + { + "a", + "action", + "an", + "and", + "art", + "as", + "at", + "be", + "both", + "by", + "content", + "cue", + "each", + "event", + "exact", + "field", + "for", + "front", + "from", + "hand", + "in", + "into", + "is", + "it", + "its", + "mechanic", + "new", + "of", + "on", + "one", + "only", + "or", + "owned", + "pair", + "profession", + "rear", + "role", + "same", + "that", + "target", + "the", + "their", + "then", + "this", + "three", + "to", + "toward", + "two", + "use", + "uses", + "when", + "with", + } +) COUNT_WORDS = { "zero": 0, @@ -1394,6 +1480,542 @@ def validate_provisional_selection_presentation( ) +def semantic_plain_text(value: str) -> str: + return re.sub(r"\s+", " ", re.sub(r"[`*]", "", value)).strip().lower() + + +def semantic_has_groups(value: str, groups: Sequence[Sequence[str]]) -> bool: + plain = semantic_plain_text(value) + return all(any(re.search(pattern, plain, re.IGNORECASE) for pattern in group) for group in groups) + + +def find_action_row( + document: Document, action_pattern: re.Pattern +) -> Optional[Tuple[Tuple[str, ...], int]]: + for line_number, line in enumerate(document.text.splitlines(), 1): + cells = split_markdown_row(line) + if cells is None or len(cells) < 3: + continue + if action_pattern.search(normalize_cell(cells[0])): + return cells, line_number + return None + + +def row_cell_has_groups( + row: Optional[Tuple[Tuple[str, ...], int]], + cell_index: int, + groups: Sequence[Sequence[str]], +) -> bool: + return row is not None and len(row[0]) > cell_index and semantic_has_groups( + row[0][cell_index], groups + ) + + +def seed_precedes_opening_generation(value: str) -> bool: + plain = semantic_plain_text(value) + return re.search( + r"(?:run_seed|run seed|seed_reserved|seed).{0,360}" + r"(?:reserv(?:e|ed|ation)|durab(?:le|ly)|persist(?:ed|ence)?|" + r"flush(?:ed)?|read[ -]?back).{0,360}" + r"\b(?:before|prior to)\b.{0,160}" + r"(?:opening (?:offer )?generat(?:or|ion)|opening generator|" + r"generat(?:e|ed|ing|ion) (?:the )?(?:four )?opening)", + plain, + ) is not None + + +def validate_run_creation_transaction( + documents: Sequence[Document], issues: List[Issue] +) -> None: + save = document_by_path(documents, SAVE_PATH) + recruit = document_by_path(documents, RECRUIT_PATH) + if save is None or recruit is None: + return + + seed_lines = section_lines( + save, + re.compile( + r"(?:\bseed[- ]first\b.*\brun\s+creation\b|" + r"\brun\s+creation\b.*\bseed(?:[- ]first|\s+(?:order|reservation|boundary))\b)", + re.IGNORECASE, + ), + ) + opening_lines = section_lines( + recruit, + re.compile(r"\bopening\b.*\b(?:offer|generation)\b|\b(?:offer|generation)\b.*\bopening\b", re.IGNORECASE), + ) + seed_text = "\n".join(line for _, line in seed_lines) + opening_text = "\n".join(line for _, line in opening_lines) + seed_errors: List[str] = [] + if not seed_lines: + seed_errors.append("seed-first Run-creation section is missing") + elif not seed_precedes_opening_generation(seed_text): + seed_errors.append("save authority does not place durable seed read-back before opening generation") + if not opening_lines or not semantic_has_groups( + opening_text, + ( + (r"\bopening generation\b", r"\bopening generator\b"), + (r"\brun_creation_draft\b", r"\brun creation draft\b"), + (r"\bread[ -]?back\b",), + (r"\bseed[- ]reservation\b", r"\breserved seed\b", r"\bseed_reserved\b"), + (r"\blegal only\b", r"\bonly from\b", r"\bcannot\b.*\bbefore\b"), + ), + ): + seed_errors.append("Recruit generation is not bound to a read-back seed-reservation draft") + if seed_errors: + issues.append( + issue( + "RUN_CREATION_SEED_ORDER_MISMATCH", + SAVE_PATH, + seed_lines[0][0] if seed_lines else 1, + 1, + "seed/order authority is incomplete or contradictory: {}".format( + "; ".join(sorted(set(seed_errors))) + ), + ) + ) + + transaction_errors: List[str] = [] + if not semantic_has_groups( + save.text, + ( + (r"\brun_creation_draft\b",), + (r"\brun_seed\b",), + (r"\bopening_offers\s*\[\s*0\.\.3\s*\]",), + (r"\bselected_offer_ids\s*\[\s*2\s*\]",), + (r"\bselection_revision\b",), + (r"\bmutually exclusive\b",), + (r"\bcurrent_run\b",), + ), + ): + transaction_errors.append( + "durable draft schema lacks seed, exact-four, exact-pair, revision, or exclusivity state" + ) + + begin_row = find_action_row( + save, + re.compile( + r"^(?:begin|start|initialize|reserve)\b.*\b(?:run\s+creation|creation\s+draft)\b$", + re.IGNORECASE, + ), + ) + materialize_row = find_action_row( + save, + re.compile( + r"^(?:materialize|persist|store)\b.*\bopening\b.*\boffers?\b$", + re.IGNORECASE, + ), + ) + selection_row = find_action_row( + save, + re.compile( + r"^(?:set|save|persist|revise|accept)\b.*\b(?:opening\s+selection|selected\s+pair)\b$", + re.IGNORECASE, + ), + ) + commit_row = find_action_row( + save, + re.compile( + r"^(?:commit|promote|finalize)\b.*\b(?:run\s+creation|creation\s+draft)\b$", + re.IGNORECASE, + ), + ) + + if not row_cell_has_groups( + begin_row, + 2, + ( + (r"\bdraft\b", r"\brun_creation_draft\b"), + (r"\bseed_reserved\b", r"\breserved seed\b"), + (r"\bno offers\b", r"\bempty offer"), + ( + r"\bno current_run\b", + r"\bno offers or current_run\b", + r"\bcurrent_run\b.*\bnull\b", + ), + ), + ): + transaction_errors.append("Begin run creation does not commit one seed-only draft") + if not ( + row_cell_has_groups(materialize_row, 1, ((r"\bdraft\b",), (r"\bseed\b",))) + and row_cell_has_groups( + materialize_row, + 2, + ( + (r"\ball four\b", r"\bfour\b.*\boffer", r"\b0\.\.3\b"), + (r"\boffer(?: records?| ids?)\b",), + (r"\boutput hashes?\b",), + (r"\boffer[- ]set hash\b",), + ), + ) + ): + transaction_errors.append("opening materialization does not bind one draft to all four offers and hashes") + if not ( + row_cell_has_groups( + selection_row, + 1, + ( + (r"\bdraft\b",), + (r"\bexactly two\b", r"\btwo distinct\b"), + (r"\boffer ids?\b",), + ), + ) + and row_cell_has_groups( + selection_row, + 2, + ( + (r"\bsame four\b",), + (r"\bpair\b",), + (r"\brevision\b",), + (r"\breceipt\b",), + ), + ) + ): + transaction_errors.append("selection transaction is not an exact, durable, revisable pair over the saved four") + if not ( + row_cell_has_groups( + commit_row, + 1, + ((r"\bdraft\b",), (r"\bselection revision\b",), (r"\bpair\b",)), + ) + and row_cell_has_groups( + commit_row, + 2, + ( + (r"\bdraft removed\b", r"\bremove(?:s|d)? the draft\b"), + (r"\bsame\b.*\bseed\b",), + (r"\bselected recruits?\b", r"\bselected recruit records?\b"), + (r"\bcurrent_run\b",), + (r"\breceipt\b",), + ), + ) + ): + transaction_errors.append("Run promotion row does not transfer the same draft seed/pair into current_run") + + if not semantic_has_groups( + opening_text, + ( + (r"\ball four\b",), + (r"\bone materialization commit\b", r"\bone commit\b"), + (r"\boffer ids?\b",), + (r"\boutput hashes?\b", r"\bfour hashes\b"), + ), + ): + transaction_errors.append("Recruit authority lacks one-commit materialization of the saved four") + if not semantic_has_groups( + seed_text, + ( + (r"\brevis(?:e|es|ed|ing)\b", r"\bdifferent legal pair\b"), + (r"\bselection_revision\b", r"\bselection revision\b"), + (r"\buntil run commit\b", r"\bbefore run commit\b"), + (r"\bwithout regenerating\b", r"\bno generator call\b", r"\bnever regenerat"), + ), + ): + transaction_errors.append("accepted selection is not durably revisable without offer regeneration") + if not semantic_has_groups( + seed_text, + ( + ( + r"\batomic\b", + r"\ball[- ]or[- ]nothing\b", + r"\bindivisible\b", + r"\b(?:one|single)\s+(?:committed|applied)\s+state\b", + ), + (r"\bremove(?:s|d)? the draft\b", r"\bdraft removed\b"), + (r"\bcurrent_run\b",), + (r"\bsame\b.{0,80}\brun_seed\b", r"\bsame seed\b"), + (r"\bselected\b.{0,40}\brecruit records?\b", r"\btwo unchanged recruit records?\b"), + (r"\boriginal receipt\b", r"\bone applied receipt\b"), + ( + r"\bno second run\b", + r"\bcannot\b.{0,40}\b(?:create|start)\b.{0,20}\bsecond run\b", + r"\bneither\b.{0,100}\bsecond run\b", + r"\bnever recreate\b", + r"\btransfer twice\b", + ), + ), + ): + transaction_errors.append("promotion is not proven atomic and exactly-once under retry") + + if transaction_errors: + issues.append( + issue( + "RUN_CREATION_TRANSACTION_INCOMPLETE", + SAVE_PATH, + seed_lines[0][0] if seed_lines else 1, + 1, + "durable Run-creation transaction is incomplete: {}".format( + "; ".join(sorted(set(transaction_errors))) + ), + ) + ) + + +def semantic_term(value: str) -> str: + term = value.lower() + if term.endswith("ies") and len(term) > 4: + return term[:-3] + "y" + if term.endswith("ing") and len(term) > 5: + term = term[:-3] + if len(term) > 3 and term[-1] == term[-2]: + term = term[:-1] + return term + if term.endswith("ed") and len(term) > 4: + return term[:-2] + if term.endswith(("ches", "shes", "xes", "zes")) and len(term) > 5: + return term[:-2] + if term.endswith("s") and not term.endswith("ss") and len(term) > 4: + return term[:-1] + return term + + +def semantic_terms(value: str) -> Set[str]: + terms = {semantic_term(word) for word in re.findall(r"[a-z]+", value.lower())} + return {term for term in terms if len(term) >= 3 and term not in SEMANTIC_STOP_WORDS} + + +def hidden_content_bindings( + document: Document, +) -> Tuple[Dict[str, HiddenProfessionContent], List[str]]: + lines = section_lines( + document, + re.compile(r"\bhidden\b.*\b(?:hybrid\s+)?professions\b", re.IGNORECASE), + ) + production_column: Optional[int] = None + mechanic_columns: Tuple[int, ...] = () + rows: Dict[str, HiddenProfessionContent] = {} + errors: List[str] = [] + for line_number, line in lines: + cells = split_markdown_row(line) + if cells is None: + continue + normalized = tuple(normalize_cell(cell) for cell in cells) + if production_column is None and any(cell == "id" for cell in normalized): + production_column = next( + (index for index, cell in enumerate(normalized) if "production" in cell), None + ) + mechanic_columns = tuple( + index + for index, cell in enumerate(normalized) + if "core rule" in cell or "target/reach" in cell + ) + continue + match = re.search(r"\b(PF-H\d{2})\b", normalize_cell(cells[0]), re.IGNORECASE) + if match is None: + continue + identifier = match.group(1).upper() + if identifier in rows: + errors.append("{} is defined more than once in the hidden content table".format(identifier)) + continue + if production_column is None or production_column >= len(cells) or len(cells) < 2: + errors.append("hidden content table lacks identity, mechanic, or production columns") + continue + identity_parts = [part.strip() for part in normalize_cell(cells[1]).split(";")] + parents = tuple(sorted(set(re.findall(r"\bPF-B\d{2}\b", cells[1])))) + production = cells[production_column] + brief = production.split(":", 1)[1] if ":" in production else production + segments = tuple(part.strip() for part in brief.split(",") if part.strip()) + mechanic = " ".join( + cells[index] for index in mechanic_columns if index < len(cells) + ) + rows[identifier] = HiddenProfessionContent( + identifier, + identity_parts[0] if identity_parts else "", + parents, + mechanic, + segments, + line_number, + ) + return rows, errors + + +def hidden_art_bindings( + document: Document, +) -> Tuple[Dict[str, HiddenProfessionArt], List[str]]: + lines = section_lines( + document, + re.compile( + r"\bhidden\b.*\bprofession\b.*\b(?:kits?|bindings?|art)\b", + re.IGNORECASE, + ), + ) + columns: Dict[str, int] = {} + rows: Dict[str, HiddenProfessionArt] = {} + errors: List[str] = [] + for line_number, line in lines: + cells = split_markdown_row(line) + if cells is None: + continue + normalized = tuple(normalize_cell(cell) for cell in cells) + if not columns and any("art id" in cell for cell in normalized): + for index, cell in enumerate(normalized): + if "art id" in cell: + columns["identity"] = index + elif "parent" in cell: + columns["parents"] = index + elif "silhouette" in cell or "translation" in cell: + columns["translation"] = index + elif "prop" in cell or "anchor" in cell or "equipment" in cell: + columns["props"] = index + elif "vfx" in cell or "event cue" in cell: + columns["vfx"] = index + elif "animation" in cell or "action verb" in cell or "motion" in cell: + columns["actions"] = index + continue + identity_index = columns.get("identity", 0) + if identity_index >= len(cells): + continue + match = re.search( + r"\bPF-H\d{2}\b", normalize_cell(cells[identity_index]), re.IGNORECASE + ) + if match is None: + continue + identifier = match.group(0).upper() + if identifier in rows: + errors.append("{} appears more than once in the hidden-art table".format(identifier)) + continue + required = {"identity", "parents", "translation", "props", "vfx", "actions"} + if not required <= set(columns) or max(columns.values()) >= len(cells): + errors.append("hidden-art table lacks identity, parent, silhouette, prop, VFX, or action columns") + continue + rows[identifier] = HiddenProfessionArt( + identifier, + cells[columns["identity"]], + tuple(sorted(set(re.findall(r"\bPF-B\d{2}\b", cells[columns["parents"]])))), + cells[columns["translation"]], + cells[columns["props"]], + cells[columns["vfx"]], + cells[columns["actions"]], + line_number, + ) + return rows, errors + + +def validate_hidden_profession_art( + documents: Sequence[Document], issues: List[Issue] +) -> None: + content_document = document_by_path(documents, PROFESSION_PATH) + art_document = document_by_path(documents, ART_PATH) + if content_document is None or art_document is None: + return + content_rows, content_errors = hidden_content_bindings(content_document) + art_rows, art_errors = hidden_art_bindings(art_document) + mismatch_errors = list(content_errors) + list(art_errors) + + missing_content = sorted(set(HIDDEN_PROFESSION_IDS) - set(content_rows)) + missing_art = sorted(set(HIDDEN_PROFESSION_IDS) - set(art_rows)) + if missing_content: + mismatch_errors.append("content is missing {}".format(", ".join(missing_content))) + if missing_art: + mismatch_errors.append("art is missing {}".format(", ".join(missing_art))) + + for identifier in HIDDEN_PROFESSION_IDS: + content = content_rows.get(identifier) + art = art_rows.get(identifier) + if content is None or art is None: + continue + art_source_lines = art_document.text.splitlines() + row_text = ( + art_source_lines[art.line - 1] + if 0 < art.line <= len(art_source_lines) + else " | ".join( + (art.identity, " ".join(art.parents), art.translation, art.props, art.vfx, art.actions) + ) + ) + if HIDDEN_ART_UNRESOLVED_RE.search(row_text) or has_pending_marker(row_text): + issues.append( + issue( + "HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED", + ART_PATH, + art.line, + 1, + "{} retains unresolved identity, parent, mechanic, or production binding language".format( + identifier + ), + ) + ) + continue + + ordinal = int(identifier[-2:]) + expected_art_id = "ART-PRO-H{:02d}".format(ordinal) + expected_content_id = "CONTENT.PROFESSION.HIDDEN[{:02d}]".format(ordinal) + identity_plain = semantic_plain_text(art.identity) + if ( + expected_art_id.lower() not in identity_plain + or expected_content_id.lower() not in identity_plain + or identifier.lower() not in identity_plain + or content.name.lower() not in identity_plain + ): + mismatch_errors.append("{} does not bind its exact art/content identity and name".format(identifier)) + if len(content.parents) != 2 or art.parents != content.parents: + mismatch_errors.append( + "{} art parents {} do not equal content parents {}".format( + identifier, + "+".join(art.parents) if art.parents else "none", + "+".join(content.parents) if content.parents else "none", + ) + ) + + fields = { + "silhouette translation": art.translation, + "props/anchors": art.props, + "VFX/event cue": art.vfx, + "animation/action verbs": art.actions, + } + for label, value in fields.items(): + if not semantic_terms(value): + mismatch_errors.append("{} has no resolved {} semantics".format(identifier, label)) + + mechanic_overlap = semantic_terms(content.mechanic) & semantic_terms(art.translation) + if len(mechanic_overlap) < 2: + mismatch_errors.append( + "{} silhouette translation does not express its content mechanic".format(identifier) + ) + if len(content.production_segments) < 3: + mismatch_errors.append( + "{} content production brief has fewer than three semantic channels".format(identifier) + ) + continue + production_targets = ( + ("silhouette", art.translation), + ("props", art.props), + ("action", art.actions), + ("VFX", art.vfx), + ) + visual_terms = semantic_terms(content.production_segments[0]) + for label, value in production_targets[:2]: + if not visual_terms or not (visual_terms & semantic_terms(value)): + mismatch_errors.append("{} {} does not bind the content visual brief".format(identifier, label)) + action_terms = semantic_terms(content.production_segments[1]) + if not action_terms or not (action_terms & semantic_terms(art.actions)): + mismatch_errors.append("{} actions do not bind the content motion brief".format(identifier)) + vfx_terms = semantic_terms(content.production_segments[2]) + if not vfx_terms or not (vfx_terms & semantic_terms(art.vfx)): + mismatch_errors.append("{} VFX does not bind the content cue brief".format(identifier)) + combined_art = " ".join((art.translation, art.vfx, art.actions)) + for extra in content.production_segments[3:]: + extra_terms = semantic_terms(extra) + if not extra_terms or not (extra_terms & semantic_terms(combined_art)): + mismatch_errors.append( + "{} art does not bind production brief segment '{}'".format( + identifier, normalize_cell(extra) + ) + ) + + if mismatch_errors: + issues.append( + issue( + "HIDDEN_PROFESSION_ART_MISMATCH", + ART_PATH, + min((row.line for row in art_rows.values()), default=1), + 1, + "hidden content/art bindings are incomplete or incompatible: {}".format( + "; ".join(sorted(set(mismatch_errors))) + ), + ) + ) + + def endpoint_kind(value: str) -> Optional[str]: exposed = re.search(r"\b(?:exposed|front)\b", value, re.IGNORECASE) is not None protected = re.search(r"\b(?:protected|rear|last)\b", value, re.IGNORECASE) is not None @@ -2201,8 +2823,10 @@ def audit(root: Path) -> AuditResult: validate_pending_bindings(documents, issues) validate_retired_concepts(documents, issues) validate_provisional_selection_presentation(documents, definitions, issues) + validate_run_creation_transaction(documents, issues) validate_party_direction(documents, issues) validate_recruit_art_crosswalk(documents, issues) + validate_hidden_profession_art(documents, issues) validate_screen_coverage(documents, definitions, issues, counts) validate_art_coverage(documents, references, issues) validate_audio_coverage( @@ -2232,7 +2856,46 @@ def write_fixture(root: Path) -> None: "No relationship system, six-unit cap, three-lane grid, Directive, Anchor, or Cache remains.\n" ), "ECONOMY_AND_BALANCE.md": "# Economy\n\nRecruitment, dismissal, rewards, and replacement have explicit costs.\n", - "SAVE_AND_FAILURE_CONTRACT.md": "# Save\n\nDeath, failure, rollback, and recovery are explicit.\n", + "SAVE_AND_FAILURE_CONTRACT.md": """# Save + +Death, failure, rollback, and recovery are explicit. + +## 2. Save Topology And Envelope + +```text +run_creation_draft + draft_id, run_id, status, run_seed + opening_offers[0..3] or empty, opening_offer_set_hash + selected_offer_ids[2] or empty, selection_revision +current_run or null +``` + +The run_creation_draft and current_run are mutually exclusive. + +## 6. Transaction Boundaries + +| Action | Pending intent contains | Applied child contains | +|---|---|---| +| Begin run creation | Contract and derived seed | one seed_reserved run_creation_draft; no offers or current_run | +| Materialize opening offers | draft ID and run seed hash | all four offer records and IDs, output hashes, and one offer-set hash | +| Set opening selection | draft ID, expected revision, and exactly two distinct offer IDs | same four offers, canonical pair, selection revision, and receipt | +| Commit run creation | draft ID, offer-set hash, selection revision, and exact pair | draft removed; same run seed and selected Recruit records in current_run; run-start receipt | + +## 7. Run Creation + +### 7.1 Seed-First Run Creation + +The applied Begin operation durably creates a seed_reserved run_creation_draft, +then it is flushed and read back before the opening generator may run. + +The player may revise a different legal pair and increment selection_revision +until Run commit without regenerating any opening offer. + +Commit is one atomic state: it removes the draft, creates current_run with the +same run_seed, transfers the two unchanged Recruit records, and stores the +original receipt. Duplicate or recovery retries return that original receipt +and cannot create a second Run. +""", } for name, text in product_texts.items(): (root / "docs/product" / name).write_text(text, encoding="utf-8") @@ -2249,8 +2912,21 @@ def write_fixture(root: Path) -> None: profession.append(table(numbered_ids("PF-B", 12, 2), 6)) profession.extend(("", "## Regular Advanced Professions", "")) profession.append(table(numbered_ids("PF-A", 24, 2), 5)) - profession.extend(("", "## Hidden Hybrid Professions", "")) - profession.append(table(numbered_ids("PF-H", 6, 2), 5)) + profession.extend( + ( + "", + "## Hidden Hybrid Professions", + "", + "| ID | Name; required bases; deed | Role/band; core rules | Target/reach and automatic cadence | Counters; synergies | Production needs |", + "|---|---|---|---|---|---|", + "| `PF-H01` | Wardmender; `PF-B01` + `PF-B07`; restore intercepted harm | guard-restore; intercepted harm becomes repair energy for the protected ally. | reach 1; brace, then mend the oldest harm. | Anti-heal delays repair. | `P/O/A/V/S`: mantle buckler, brace/mend, square pulse, repair chime. |", + "| `PF-H02` | Tempo Cutter; `PF-B02` + `PF-B11`; alter a timed strike | tempo-pressure; cadence Edge shortens an enemy telegraph between ally actions. | reach 2; step between beats, then cut. | Silence breaks cadence. | `P/O/A/V/S`: bell blades, step/cut, split metronome, cadence strike. |", + "| `PF-H03` | Farline Lancer; `PF-B03` + `PF-B05`; pierce a rear target | projectile-reach; a planted bow-spear guides a piercing rear shot. | line reach; plant, draw, then recover. | Movement breaks the guide. | `P/O/A/V/S`: bow-spear, plant/draw/recover, horizon line, guided shot. |", + "| `PF-H04` | Route Smith; `PF-B04` + `PF-B09`; change a device by position | reorder-construct; a carried automaton changes guard, strike, and pull modes by indexed position. | adjacent reach; carry, drop, and switch. | Roots stop modes. | `P/O/A/V/S`: pack automaton, carry/drop/switch, numbered footprints, mode click. |", + "| `PF-H05` | Eclipse Scribe; `PF-B06` + `PF-B10`; retain an interrupted cast | area-impair; an interrupted charge becomes shadow text released on hindrance expiry. | line spell; collapse, inscribe, release. | Cleanse erases text. | `P/O/A/V/S`: split tablet, collapse/inscribe/release, eclipsed diamond, reversed charge. |", + "| `PF-H06` | Loan Factor; `PF-B08` + `PF-B12`; share distinct resources | enable-supply; linked loans repay as flexible Credit after recipient actions. | reach 2; loan, mark, then close. | Isolation creates bad debt. | `P/O/A/V/S`: ledger-knot frame, loan/mark/close, linked tabs, stamped pluck. |", + ) + ) profession.extend( ( "", @@ -2335,6 +3011,14 @@ body_frame, face_shape, skin_palette, hair_shape, hair_palette garment_cut, garment_palette, profession_overlay, detail_mark ``` +### 2.1 Opening Four-Offer Binding + +Opening generation is legal only from a validated, read-back +`run_creation_draft` created by an applied seed-reservation operation. +All four complete records, their offer IDs, and output hashes enter one +materialization commit. Selecting or revising the accepted exact pair does not +regenerate or mutate an offer. + ## 4. Modular Visual Grammar | Part | Options | Rule | @@ -2454,6 +3138,17 @@ Region coverage: `ART-REG-01..08`. | `profession_overlay` | 42 | Profession kits | 42 | same stable index | | `detail_mark` | 20 | Face detail sets | 20 | same stable index | | `voice_effort_set` | 8 | Audio-owned nonvisual field | N/A | audio-owned deterministic field value | + +### 5.4 Hidden Hybrid Profession Kits: 6 + +| Art ID / content identity | Exact parent pair | Combat-to-silhouette translation | Held props and anchors | Material | VFX / event cue | Animation needs / action verbs | +|---|---|---|---|---|---|---| +| `ART-PRO-H01` / `CONTENT.PROFESSION.HIDDEN[01]` / `PF-H01` Wardmender | `PF-B01` + `PF-B07` | A mantle buckler braces intercepted harm and opens repair energy toward the protected ally. | Mantle buckler and repair lamp. | iron and cloth | A square pulse closes with a repair chime. | `BRACE` (brace/mend) | +| `ART-PRO-H02` / `CONTENT.PROFESSION.HIDDEN[02]` / `PF-H02` Tempo Cutter | `PF-B02` + `PF-B11` | Bell blades show cadence Edge shortening an enemy telegraph between ally actions. | Paired bell blades and beat tabs. | steel and brass | A split metronome marks cadence. | `MOBILE` (step/cut/strike) | +| `ART-PRO-H03` / `CONTENT.PROFESSION.HIDDEN[03]` / `PF-H03` Farline Lancer | `PF-B03` + `PF-B05` | A bow-spear plants a guide for a piercing rear shot and recovery. | Bow-spear with a ground plant. | wood and steel | One horizon line marks the guided shot. | `RELEASE` (plant/draw/recover) | +| `ART-PRO-H04` / `CONTENT.PROFESSION.HIDDEN[04]` / `PF-H04` Route Smith | `PF-B04` + `PF-B09` | A pack automaton changes guard, strike, and pull mode at each indexed position. | Pack automaton with a ground anchor. | steel and canvas | Numbered footprints close with a mode click. | `DEPLOY` (carry/drop/switch) | +| `ART-PRO-H05` / `CONTENT.PROFESSION.HIDDEN[05]` / `PF-H05` Eclipse Scribe | `PF-B06` + `PF-B10` | A split tablet stores interrupted charge as shadow text until hindrance expiry. | Split tablet at the effect anchor. | glass and wood | An eclipsed diamond reverses the charge. | `CHANNEL` (collapse/inscribe/release) | +| `ART-PRO-H06` / `CONTENT.PROFESSION.HIDDEN[06]` / `PF-H06` Loan Factor | `PF-B08` + `PF-B12` | A ledger-knot frame shows linked loans repaid as flexible Credit after recipient actions. | Ledger-knot frame and linked loan tabs. | wood and cord | Linked tabs close with a stamped pluck. | `PULSE` (loan/mark/close) | """ (root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8") @@ -2665,6 +3360,45 @@ def mutate_integrated_reference_scope_fixture(root: Path) -> None: ) +def mutate_seed_order_fixture(root: Path) -> None: + replace_fixture_text( + root / SAVE_PATH, + "read back before the opening generator may run", + "read back after the opening generator may run", + ) + + +def mutate_incomplete_run_creation_transaction_fixture(root: Path) -> None: + replace_fixture_text( + root / SAVE_PATH, + "Commit is one atomic state:", + "Commit creates a state:", + ) + + +def mutate_hidden_profession_mechanic_fixture(root: Path) -> None: + art_path = root / ART_PATH + replacements = ( + ( + "Bell blades show cadence Edge shortening an enemy telegraph between ally actions.", + "A bow arc crosses a rear vial wing while the front hand stays clear.", + ), + ("Paired bell blades and beat tabs.", "A long bow, rear vials, and a projectile anchor."), + ("A split metronome marks cadence.", "A mist cloud marks deployed ammunition."), + ("`MOBILE` (step/cut/strike)", "`RELEASE` (draw/deploy)"), + ) + for old, new in replacements: + replace_fixture_text(art_path, old, new) + + +def mutate_hidden_profession_unresolved_fixture(root: Path) -> None: + replace_fixture_text( + root / ART_PATH, + "| `PF-B02` + `PF-B11` |", + "| `CONTENT.PROFESSION.HIDDEN[02].parents` |", + ) + + def mutate_remove_line(path: Path, needle: str) -> None: lines = path.read_text(encoding="utf-8").splitlines() for index, line in enumerate(lines): @@ -2874,6 +3608,26 @@ def run_self_test() -> None: mutate_integrated_reference_scope_fixture, "REFERENCE_RETIRED_SCOPE", ), + ( + "run-creation-seed-order", + mutate_seed_order_fixture, + "RUN_CREATION_SEED_ORDER_MISMATCH", + ), + ( + "run-creation-transaction", + mutate_incomplete_run_creation_transaction_fixture, + "RUN_CREATION_TRANSACTION_INCOMPLETE", + ), + ( + "hidden-profession-mechanic", + mutate_hidden_profession_mechanic_fixture, + "HIDDEN_PROFESSION_ART_MISMATCH", + ), + ( + "hidden-profession-unresolved", + mutate_hidden_profession_unresolved_fixture, + "HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED", + ), ) isolated_semantic_cases = { "provisional-selection-presentation", @@ -2881,6 +3635,10 @@ def run_self_test() -> None: "recruit-art-crosswalk", "audio-screen-inventory", "reference-retired-scope", + "run-creation-seed-order", + "run-creation-transaction", + "hidden-profession-mechanic", + "hidden-profession-unresolved", } for name, mutate, expected_code in cases: with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary: