validator: add R4 semantic regression gates

This commit is contained in:
2026-08-11 12:54:13 +08:00
parent a7fe1139c3
commit fb20b663df
2 changed files with 845 additions and 16 deletions
+84 -13
View File
@@ -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.
+761 -3
View File
@@ -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: