fix(validation): gate R3 cross-document contradictions

This commit is contained in:
2026-08-11 10:04:22 +08:00
parent 96ea49ab2a
commit 8a6f0c8499
2 changed files with 912 additions and 16 deletions
+99 -15
View File
@@ -1,10 +1,10 @@
# Pre-development Design Validation Contract
> Contract revision: `abg-design-validation-v2`
> Contract revision: `abg-design-validation-v2-crossdoc-r3`
> Frozen input: `.agent-taskgraph/spec.md` revision `design-spec-r3`
> Integrated schema evidence: `b2d8f7b`
> Integrated schema evidence: `999034a`
> Executable: `tools/validate_predevelopment_design.py`
> Scope: merged Markdown design authorities only; no runtime or product authority
> Scope: merged Markdown design authorities plus one scoped reference gate; no runtime or product authority
## 1. Purpose And Boundary
@@ -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 five bounded questions:
The executable answers ten bounded questions:
1. Do the primary catalogs contain the exact frozen content envelope with
unique, gap-free stable IDs and nonempty rows?
@@ -22,6 +22,16 @@ The executable answers five bounded questions:
4. Does an authority positively prescribe a retired redesign concept?
5. Are the required page, art, music, ambience, SFX, and voice registries
structurally complete?
6. Does the stable-screen and onboarding presentation preserve the product's
provisional selected/offered values and pre-creation revision boundary?
7. Do UI and art assign the same exposed/protected party endpoints to left and
right?
8. Does every generated-recruit presentation field have an exact,
deterministic content/art crosswalk?
9. Does audio bind the exact primary screen-ID set, including `OUT-006`, and
declare the matching total?
10. Does the scoped reference authority reject rather than prescribe retired
fixed-cast and relationship scope?
Passing is necessary but not sufficient. The validator cannot establish
originality, balance, comprehension, readability, production feasibility,
@@ -41,6 +51,7 @@ These files must exist:
- `docs/product/SYSTEMS_AND_BATTLE.md`
- `docs/product/ECONOMY_AND_BALANCE.md`
- `docs/product/SAVE_AND_FAILURE_CONTRACT.md`
- `docs/content/RECRUIT_GENERATION.md`
- `docs/presentation/SCREEN_AND_STATE_MAP.md`
- `docs/presentation/UI_AND_VISUAL_SYSTEM.md`
- `docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md`
@@ -48,7 +59,10 @@ These files must exist:
`docs/content/CHARACTER_BIBLE.md` is a retired fixed-character authority and
must not exist. Reference research, reviews, prototypes, and taskgraph files
are outside the product-authority scan.
are outside the ordinary authority scan. The sole exception is the targeted
retired-scope scan of `docs/reference/TRANSFERABLE_STRUCTURE_LESSONS.md`; its
IDs, ranges, and other research statements do not enter product/content
registry validation.
## 3. Primary Content Registries
@@ -217,7 +231,66 @@ casualty, dismissal, reward, failure, accessibility, and generated-recruit
voice scope. Underscore event names such as `inspect_open` count as explicit
inspection coverage.
## 8. Diagnostics And Exit Contract
## 8. Cross-Document Semantic Gates
### 8.1 Provisional Opening Choice
The product authority supplies the selected and offered cardinalities; the
validator does not duplicate them as fallback constants. Every positive
provisional-choice declaration in the product contract must resolve to one
consistent pair. A primary stable-screen row and one row in the onboarding map
must each repeat that pair and state that the choice remains revisable before
Run creation. Pair presence elsewhere in the screen document cannot satisfy
either row-bound requirement.
### 8.2 Party Direction
`UI_AND_VISUAL_SYSTEM.md` owns the left/right placement of `First exposed` and
`Last`. `ART_ASSET_AND_ANIMATION_CATALOG.md` must independently declare the
left/right exposed-front and protected-rear endpoints. The validator normalizes
both to `(left endpoint, right endpoint)` and requires exact equality; naming
both concepts without direction is insufficient.
### 8.3 Recruit Content/Art Crosswalk
The required generated-recruit fields and option counts come from the fenced
Recruit Record and the Modular Visual Grammar table in
`RECRUIT_GENERATION.md`. Art must provide a Recruit Content/Art Crosswalk table
with these semantic columns:
| Column | Required value |
|---|---|
| Content field | exact snake-case Recruit Record field |
| Content count/options/values | exact content-owned cardinality |
| Art source/target/library/binding | the declared source library or explicit nonvisual owner |
| Art count/options/masters/values | the same cardinality for visual fields; `N/A` or zero for `voice_effort_set` |
| Mapping/resolution/rule | deterministic, one-to-one, or same-index resolution |
The ten required rows are `body_frame`, `face_shape`, `skin_palette`,
`hair_shape`, `hair_palette`, `garment_cut`, `garment_palette`,
`profession_overlay`, `detail_mark`, and `voice_effort_set`. Each visual row's
count must also equal the matching source inventory declaration. The voice row
must explicitly identify audio ownership or a nonvisual disposition. A prose
claim that generation is deterministic cannot replace this field-level table.
### 8.4 Screen/Audio Identity
Primary screen rows in `SCREEN_AND_STATE_MAP.md` are the canonical set. That
set must include `OUT-006`. The audio dependency row named `Screen and action
IDs` must expand to exactly the same set, with neither omissions nor additions,
and its declared screen total must equal the canonical set size. A matching
count with a different ID set still fails.
### 8.5 Scoped Reference Retirement
`TRANSFERABLE_STRUCTURE_LESSONS.md` may discuss fixed-cast or relationship
concepts only as explicit rejection, removal, or historical/reference
evidence. Positive `profession affinities`, `relationship hook/system`, fixed
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.
## 9. Diagnostics And Exit Contract
Run from the repository root:
@@ -249,8 +322,13 @@ The main diagnostic families are:
| `BINDING_*` | declaration missing/duplicated or value unresolved |
| `RETIRED_*` | obsolete product authority remains positive |
| `PAGE_*`, `ART_*`, `AUDIO_*` | presentation registry or coverage absent |
| `PROVISIONAL_SELECTION_*` | product selected/offered values are missing, inconsistent, or absent from row-bound presentation |
| `PARTY_DIRECTION_MISMATCH` | UI and art disagree on left/right exposed/protected endpoints |
| `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 |
## 9. Self-test And Integrated Evidence
## 10. Self-test And Integrated Evidence
The generated pass fixture uses the integrated schemas and includes regression
coverage for every false-positive class from Attempt 1:
@@ -262,19 +340,25 @@ coverage for every false-positive class from Attempt 1:
- abbreviated, suffix, brace, and mixed-prefix ranges
- music/ambience tables plus fenced voice/SFX registries
- underscore-based inspection events
- a coherent two-of-four provisional flow in both stable-screen and onboarding rows
- matching UI/art party endpoints
- 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
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, and audio duplicates.
retired concepts, filler rows, page/art/audio omissions, audio duplicates, and
one isolated mutation for each of the five Experience R3 contradiction classes.
The success terminal is:
```text
ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=15
ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=20
```
On integrated revision `b2d8f7b`, all content and production registry counts
resolve exactly. The remaining failures are the bracket bindings declared by
`AUDIO_MUSIC_AND_VOICE_CATALOG.md` as required integration inputs. That is an
authority boundary, not a validator schema alias; the validator must continue
to return nonzero until those bindings are resolved or redesigned by their
owner.
On baseline revision `999034a`, all prior exact content/audio registry checks
remain satisfied while the new gates report the five immutable Experience R3
contradiction classes: provisional presentation, party direction, recruit
field crosswalk, the missing audio binding for `OUT-006` plus the 49/50 total,
and positive retired reference scope. That nonzero result is expected until the
disjoint authority repairs are supplied and reintegrated by PMO.
+813 -1
View File
@@ -24,6 +24,7 @@ REQUIRED_FILES = (
Path("docs/product/SYSTEMS_AND_BATTLE.md"),
Path("docs/product/ECONOMY_AND_BALANCE.md"),
Path("docs/product/SAVE_AND_FAILURE_CONTRACT.md"),
Path("docs/content/RECRUIT_GENERATION.md"),
Path("docs/presentation/SCREEN_AND_STATE_MAP.md"),
Path("docs/presentation/UI_AND_VISUAL_SYSTEM.md"),
Path("docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md"),
@@ -246,6 +247,105 @@ EQUIPMENT_PATH = "docs/content/EQUIPMENT_CATALOG.md"
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"
RECRUIT_PATH = "docs/content/RECRUIT_GENERATION.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}")
COUNT_WORDS = {
"zero": 0,
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
"twelve": 12,
"sixteen": 16,
"twenty": 20,
"twenty-four": 24,
"thirty-six": 36,
"forty-two": 42,
}
COUNT_TOKEN = r"(?:\d+|" + "|".join(sorted(COUNT_WORDS, key=len, reverse=True)) + r")"
PROVISIONAL_SELECTION_RE = re.compile(
r"\b(?:choose|select(?:ed|s|ing)?)\s+(?:exactly\s+)?(?P<selected>{})\b"
r"(?=[^\n|.]{{0,120}}\bprovisional\b)"
r"(?=[^\n|.]{{0,120}}\brecruits?\b)"
r"[^\n|.]{{0,100}}\b(?:of|from)\s+(?:the\s+)?(?P<offered>{})\b".format(
COUNT_TOKEN, COUNT_TOKEN
),
re.IGNORECASE,
)
REVISABLE_SELECTION_RE = re.compile(
r"\b(?:revis(?:e|ed|able)|change(?:d|able)?|edit(?:ed|able)?|reselect|undo|uncommitted)\b",
re.IGNORECASE,
)
BEFORE_RUN_CREATION_RE = re.compile(
r"(?:\bbefore\b.{0,60}\brun\s+creat(?:ion|ed?)\b|"
r"\buntil\b.{0,60}\brun\s+(?:creation|commit)|"
r"\bno\s+run\s+(?:is\s+)?created\b)",
re.IGNORECASE,
)
RECRUIT_PART_FIELDS = (
(
"body_frame",
re.compile(r"^body\s+frame$", re.IGNORECASE),
re.compile(r"\bbody\s+(?:foundations?|frames?)\b", re.IGNORECASE),
),
(
"face_shape",
re.compile(r"^face\s+shape$", re.IGNORECASE),
re.compile(r"\bface\s+(?:structures?|shapes?)\b", re.IGNORECASE),
),
(
"skin_palette",
re.compile(r"^skin\s+palette$", re.IGNORECASE),
re.compile(r"\bskin\s+(?:ramps?|palettes?)\b", re.IGNORECASE),
),
(
"hair_shape",
re.compile(r"^hair(?:/head)?\s+shape$", re.IGNORECASE),
re.compile(r"\bhair\s+(?:famil(?:y|ies)|shapes?)\b", re.IGNORECASE),
),
(
"hair_palette",
re.compile(r"^hair\s+palette$", re.IGNORECASE),
re.compile(r"\bhair\s+(?:ramps?|palettes?)\b", re.IGNORECASE),
),
(
"garment_cut",
re.compile(r"^garment\s+cut$", re.IGNORECASE),
re.compile(r"\bgarment\s+cuts?\b|\bunderlayers?\b(?!\s+ramps?)", re.IGNORECASE),
),
(
"garment_palette",
re.compile(r"^garment\s+palette$", re.IGNORECASE),
re.compile(r"\b(?:garment\s+palettes?|underlayer\s+ramps?)\b", re.IGNORECASE),
),
(
"profession_overlay",
re.compile(r"^profession\s+overlay$", re.IGNORECASE),
re.compile(r"\bprofession\s+(?:kits?|overlays?)\b", re.IGNORECASE),
),
(
"detail_mark",
re.compile(r"^detail\s+mark$", re.IGNORECASE),
re.compile(r"\b(?:face\s+detail\s+sets?|detail\s+marks?)\b", re.IGNORECASE),
),
(
"voice_effort_set",
re.compile(r"^voice\s+effort\s+set$", re.IGNORECASE),
re.compile(r"\b(?:voice\s+effort\s+sets?|audio)\b", re.IGNORECASE),
),
)
def relative_path(path: Path, root: Path) -> str:
@@ -1042,6 +1142,558 @@ def document_by_path(documents: Sequence[Document], path: str) -> Optional[Docum
return next((document for document in documents if document.path == path), None)
def read_scoped_document(root: Path, path: str, issues: List[Issue]) -> Optional[Document]:
absolute = root / path
if not absolute.is_file():
issues.append(issue("AUTHORITY_FILE_MISSING", path, 1, 1, "required scoped authority is missing"))
return None
try:
return Document(path, absolute.read_text(encoding="utf-8"))
except (OSError, UnicodeError) as error:
issues.append(
issue(
"AUTHORITY_READ_ERROR",
path,
1,
1,
"cannot read UTF-8 Markdown: {}".format(error),
)
)
return None
def heading_level_and_text(line: str) -> Optional[Tuple[int, str]]:
match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
return (len(match.group(1)), match.group(2)) if match else None
def section_lines(
document: Document, heading_pattern: re.Pattern
) -> List[Tuple[int, str]]:
lines = document.text.splitlines()
for index, line in enumerate(lines):
heading = heading_level_and_text(line)
if heading is None or not heading_pattern.search(heading[1]):
continue
level = heading[0]
selected: List[Tuple[int, str]] = []
for following_index in range(index + 1, len(lines)):
following = lines[following_index]
next_heading = heading_level_and_text(following)
if next_heading is not None and next_heading[0] <= level:
break
selected.append((following_index + 1, following))
return selected
return []
def count_value(token: str) -> Optional[int]:
normalized = token.strip().lower()
if normalized.isdigit():
return int(normalized)
return COUNT_WORDS.get(normalized)
def provisional_selection_pairs(text: str) -> List[Tuple[int, int, int, int]]:
pairs: List[Tuple[int, int, int, int]] = []
for line_number, line in enumerate(text.splitlines(), 1):
for match in PROVISIONAL_SELECTION_RE.finditer(line):
selected = count_value(match.group("selected"))
offered = count_value(match.group("offered"))
if selected is not None and offered is not None:
pairs.append((selected, offered, line_number, match.start() + 1))
return pairs
def selection_is_revisable_before_creation(text: str) -> bool:
return REVISABLE_SELECTION_RE.search(text) is not None and BEFORE_RUN_CREATION_RE.search(text) is not None
def validate_provisional_selection_presentation(
documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue]
) -> None:
product = document_by_path(documents, PRODUCT_PATH)
screen = document_by_path(documents, SCREEN_PATH)
if product is None or screen is None:
return
product_pairs = provisional_selection_pairs(product.text)
product_values = {(selected, offered) for selected, offered, _, _ in product_pairs}
if len(product_values) != 1:
issues.append(
issue(
"PROVISIONAL_SELECTION_CONTRACT_INVALID",
PRODUCT_PATH,
product_pairs[0][2] if product_pairs else 1,
product_pairs[0][3] if product_pairs else 1,
"product authority must declare one consistent provisional selected/offered pair",
)
)
return
expected = next(iter(product_values))
primary_candidates: List[Tuple[Definition, str]] = []
for definition in definitions:
if definition.path != SCREEN_PATH or not PAGE_ID_RE.fullmatch(definition.identifier):
continue
row_text = " | ".join(definition.cells)
if re.search(r"\bprovisional\b", row_text, re.IGNORECASE) and re.search(
r"\brecruits?\b", row_text, re.IGNORECASE
):
primary_candidates.append((definition, row_text))
primary_valid = any(
expected in {(selected, offered) for selected, offered, _, _ in provisional_selection_pairs(row_text)}
and selection_is_revisable_before_creation(row_text)
for _, row_text in primary_candidates
)
onboarding = section_lines(screen, re.compile(r"\bonboarding\b", re.IGNORECASE))
onboarding_valid = any(
expected
in {(selected, offered) for selected, offered, _, _ in provisional_selection_pairs(line)}
and selection_is_revisable_before_creation(line)
for _, line in onboarding
)
if primary_valid and onboarding_valid:
return
missing: List[str] = []
if not primary_valid:
missing.append("a stable screen row")
if not onboarding_valid:
missing.append("the onboarding map")
issues.append(
issue(
"PROVISIONAL_SELECTION_PRESENTATION_MISMATCH",
SCREEN_PATH,
primary_candidates[0][0].line if primary_candidates else 1,
primary_candidates[0][0].column if primary_candidates else 1,
"{} must present choosing {} of {} provisional recruits as revisable before run creation".format(
" and ".join(missing), expected[0], expected[1]
),
)
)
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
if exposed == protected:
return None
return "exposed" if exposed else "protected"
def extract_ui_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
for line_number, line in enumerate(document.text.splitlines(), 1):
exposed = re.search(r"\b(?:first\s+)?exposed\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
last = re.search(r"\blast\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
if exposed is None or last is None or exposed.group(1).lower() == last.group(1).lower():
continue
sides = {exposed.group(1).lower(): "exposed", last.group(1).lower(): "protected"}
return sides["left"], sides["right"], line_number, exposed.start() + 1
return None
def extract_art_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
direction_re = re.compile(
r"\bleft\s*-?to\s*-?right\b\s+from\s+(.{1,50}?)\s+to\s+(.{1,50}?)(?:[.;]|$)",
re.IGNORECASE,
)
for line_number, line in enumerate(document.text.splitlines(), 1):
match = direction_re.search(line)
if match:
left = endpoint_kind(match.group(1))
right = endpoint_kind(match.group(2))
if left is not None and right is not None and left != right:
return left, right, line_number, match.start() + 1
exposed = re.search(r"\b(?:exposed|front)\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
protected = re.search(r"\b(?:protected|rear)\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
if exposed is None or protected is None or exposed.group(1).lower() == protected.group(1).lower():
continue
sides = {exposed.group(1).lower(): "exposed", protected.group(1).lower(): "protected"}
return sides["left"], sides["right"], line_number, exposed.start() + 1
return None
def validate_party_direction(documents: Sequence[Document], issues: List[Issue]) -> None:
ui = document_by_path(documents, UI_PATH)
art = document_by_path(documents, ART_PATH)
if ui is None or art is None:
return
ui_direction = extract_ui_party_direction(ui)
art_direction = extract_art_party_direction(art)
if ui_direction is not None and art_direction is not None and ui_direction[:2] == art_direction[:2]:
return
line = art_direction[2] if art_direction else 1
column = art_direction[3] if art_direction else 1
rendered_ui = "{} left / {} right".format(*ui_direction[:2]) if ui_direction else "not explicit"
rendered_art = "{} left / {} right".format(*art_direction[:2]) if art_direction else "not explicit"
issues.append(
issue(
"PARTY_DIRECTION_MISMATCH",
ART_PATH,
line,
column,
"party endpoints must agree with UI authority (UI: {}; art: {})".format(rendered_ui, rendered_art),
)
)
def normalize_cell(value: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"[`*]", "", value)).strip().lower()
def first_fenced_block(lines: Sequence[Tuple[int, str]]) -> str:
collected: List[str] = []
in_fence = False
for _, line in lines:
if line.strip().startswith("```"):
if in_fence:
return "\n".join(collected)
in_fence = True
continue
if in_fence:
collected.append(line)
return ""
def recruit_content_part_counts(document: Document) -> Tuple[Dict[str, int], List[str]]:
errors: List[str] = []
record_lines = section_lines(document, re.compile(r"\brecruit\s+record\b", re.IGNORECASE))
record_block = first_fenced_block(record_lines)
record_fields = set(re.findall(r"\b[a-z][a-z0-9_]*(?:\[\])?\b", record_block))
grammar_lines = section_lines(document, re.compile(r"\bmodular\s+visual\s+grammar\b", re.IGNORECASE))
counts: Dict[str, int] = {}
for _, line in grammar_lines:
cells = split_markdown_row(line)
if cells is None or len(cells) < 2:
continue
label = normalize_cell(cells[0])
for field, content_pattern, _ in RECRUIT_PART_FIELDS:
if not content_pattern.fullmatch(label):
continue
value = count_value(normalize_cell(cells[1]))
if value is None:
errors.append("{} lacks an exact content option count".format(field))
elif field in counts:
errors.append("{} is declared more than once in the content grammar".format(field))
else:
counts[field] = value
for field, _, _ in RECRUIT_PART_FIELDS:
if field not in record_fields:
errors.append("{} is absent from the Recruit Record".format(field))
if field not in counts:
errors.append("{} is absent from Modular Visual Grammar".format(field))
return counts, errors
def art_declared_source_counts(document: Document) -> List[Tuple[str, int]]:
declarations: List[Tuple[str, int]] = []
for line in document.text.splitlines():
cells = split_markdown_row(line)
if cells is None or len(cells) < 3:
continue
label = normalize_cell(cells[0])
if not label or label in ("library", "family") or set(label) <= {"-", ":"}:
continue
candidate_cells = (cells[1], cells[2])
for candidate in candidate_cells:
cleaned = normalize_cell(candidate)
match = re.fullmatch(r"(\d+)(?:\s+(?:masters?|identities?))?", cleaned)
if match:
declarations.append((label, int(match.group(1))))
break
return declarations
def find_crosswalk_rows(
document: Document,
) -> Tuple[Dict[str, Tuple[Tuple[str, ...], int, Dict[str, int]]], List[str]]:
lines = section_lines(
document,
re.compile(r"(?:\brecruit\b.*\bcrosswalk\b|\bcrosswalk\b.*\brecruit\b)", re.IGNORECASE),
)
if not lines:
return {}, ["explicit recruit content/art crosswalk section is missing"]
header: Optional[Tuple[str, ...]] = None
columns: Dict[str, int] = {}
rows: Dict[str, Tuple[Tuple[str, ...], int, Dict[str, int]]] = {}
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 header is None:
candidate_columns: Dict[str, int] = {}
for index, cell in enumerate(normalized):
if "field" in cell and ("content" in cell or cell == "field"):
candidate_columns["field"] = index
elif "content" in cell and re.search(r"\b(?:count|options?|values?)\b", cell):
candidate_columns["content_count"] = index
elif "art" in cell and re.search(r"\b(?:source|target|library|binding)\b", cell):
candidate_columns["art_source"] = index
elif "art" in cell and re.search(r"\b(?:count|options?|masters?|values?)\b", cell):
candidate_columns["art_count"] = index
elif re.search(r"\b(?:mapping|resolution|rule)\b", cell):
candidate_columns["mapping"] = index
if set(candidate_columns) == {"field", "content_count", "art_source", "art_count", "mapping"}:
header = cells
columns = candidate_columns
continue
if all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in normalized):
continue
maximum = max(columns.values())
if len(cells) <= maximum:
continue
field = normalize_cell(cells[columns["field"]]).replace("[]", "")
if not re.fullmatch(r"[a-z][a-z0-9_]*", field):
continue
if field in rows:
return rows, ["{} appears more than once in the art crosswalk".format(field)]
rows[field] = (cells, line_number, dict(columns))
if header is None:
return {}, [
"crosswalk table requires content field/count, art source/count, and deterministic mapping columns"
]
return rows, []
def mapping_is_deterministic(value: str) -> bool:
return re.search(
r"\b(?:deterministic|one-to-one|same\s+(?:stable\s+)?(?:index|ordinal)|"
r"matching\s+(?:index|ordinal)|direct(?:ly)?\s+(?:maps?|indexes?)|"
r"nonvisual|audio[- ]owned)\b|1\s*:\s*1",
value,
re.IGNORECASE,
) is not None
def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[Issue]) -> None:
content = document_by_path(documents, RECRUIT_PATH)
art = document_by_path(documents, ART_PATH)
if content is None or art is None:
return
content_counts, errors = recruit_content_part_counts(content)
rows, row_errors = find_crosswalk_rows(art)
errors.extend(row_errors)
source_counts = art_declared_source_counts(art)
first_crosswalk_line = min((line for _, line, _ in rows.values()), default=1)
for field, _, source_pattern in RECRUIT_PART_FIELDS:
expected = content_counts.get(field)
row = rows.get(field)
if expected is None or row is None:
if row is None:
errors.append("{} lacks an art crosswalk row".format(field))
continue
cells, _, columns = row
content_count = count_value(normalize_cell(cells[columns["content_count"]]))
art_count_text = normalize_cell(cells[columns["art_count"]])
art_count = count_value(art_count_text)
source = normalize_cell(cells[columns["art_source"]])
mapping = normalize_cell(cells[columns["mapping"]])
if content_count != expected:
errors.append(
"{} crosswalk content count is {}; expected {}".format(field, content_count, expected)
)
if field == "voice_effort_set":
if not re.search(r"\b(?:audio|nonvisual|no\s+art)\b", source, re.IGNORECASE):
errors.append("voice_effort_set must be explicitly audio-owned/nonvisual")
if art_count not in (None, 0):
errors.append("voice_effort_set must not declare visual masters")
else:
if not source_pattern.search(source):
errors.append("{} maps to an unrecognized art source".format(field))
if art_count != expected:
errors.append("{} art count is {}; expected {}".format(field, art_count, expected))
matching_sources = {
count for label, count in source_counts if source_pattern.search(label)
}
if matching_sources != {expected}:
errors.append(
"{} art source declares {}; expected exactly {}".format(
field,
sorted(matching_sources) if matching_sources else "no count",
expected,
)
)
if not mapping_is_deterministic(mapping):
errors.append("{} lacks a deterministic mapping rule".format(field))
if not errors:
return
issues.append(
issue(
"RECRUIT_ART_CROSSWALK_MISMATCH",
ART_PATH,
first_crosswalk_line,
1,
"recruit field crosswalk is incomplete or incompatible: {}".format("; ".join(sorted(set(errors)))),
)
)
def validate_audio_screen_inventory(
documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int]
) -> None:
audio = document_by_path(documents, AUDIO_PATH)
if audio is None:
counts["pages.audio_bound"] = 0
return
page_ids = {
definition.identifier
for definition in definitions
if definition.path == SCREEN_PATH and PAGE_ID_RE.fullmatch(definition.identifier)
}
binding_row: Optional[Tuple[int, Tuple[str, ...]]] = None
for line_number, line in enumerate(audio.text.splitlines(), 1):
cells = split_markdown_row(line)
if cells is None or not cells:
continue
label = normalize_cell(cells[0])
if "screen" in label and "action" in label and "id" in label:
binding_row = (line_number, cells)
break
audio_ids: Set[str] = set()
declared_total: Optional[int] = None
if binding_row is not None:
line_number, cells = binding_row
for cell in cells[1:]:
for match in CODE_SPAN_RE.finditer(cell):
expression = match.group(1).strip()
expanded = expand_compact_id_expression(expression)
if expanded is None:
continue
audio_ids.update(identifier for identifier in expanded if PAGE_ID_RE.fullmatch(identifier))
row_text = " | ".join(cells)
total_match = re.search(r"\ball\s+(\d+)\s+(?:final\s+)?screens?\b", row_text, re.IGNORECASE)
if total_match is None:
total_match = re.search(r"\b(\d+)\s+(?:final\s+)?screens?\b", row_text, re.IGNORECASE)
if total_match:
declared_total = int(total_match.group(1))
else:
line_number = 1
counts["pages.audio_bound"] = len(audio_ids)
if (
"OUT-006" in page_ids
and audio_ids == page_ids
and declared_total == len(page_ids)
):
return
details: List[str] = []
if "OUT-006" not in page_ids:
details.append("screen authority lacks OUT-006")
missing = page_ids - audio_ids
extra = audio_ids - page_ids
if missing:
details.append("audio missing [{}]".format(format_id_sample(missing)))
if extra:
details.append("audio has unexpected [{}]".format(format_id_sample(extra)))
if declared_total != len(page_ids):
details.append("audio declares {} screens; screen registry has {}".format(declared_total, len(page_ids)))
if binding_row is None:
details.append("Screen and action IDs binding row is missing")
issues.append(
issue(
"AUDIO_SCREEN_INVENTORY_MISMATCH",
AUDIO_PATH,
line_number,
1,
"audio screen binding must equal the complete screen registry: {}".format("; ".join(details)),
)
)
REFERENCE_RELATIONSHIP_SCOPE_RE = re.compile(
r"\b(?:profession\s+affinit(?:y|ies)|relationships?\s+hooks?|"
r"relationships?\s+(?:system|scene|meter|level|arc|reward|prompt|link)s?|"
r"affinity\s+(?:system|meter|level|rank|scene)s?|romance|personal[- ]arc)\b",
re.IGNORECASE,
)
REFERENCE_FIXED_CAST_RE = re.compile(
r"\b(?:fixed[- ]cast|fixed\s+(?:named\s+)?(?:protagonists?|characters?|heroes?))\b",
re.IGNORECASE,
)
REFERENCE_NAMED_THIRTY_RE = re.compile(
r"(?:\b30\b.{0,80}\bnamed\s+recruitable\s+characters?\b|"
r"\bnamed\s+recruitable\s+characters?\b.{0,80}\b30\b)",
re.IGNORECASE,
)
REFERENCE_HISTORY_RE = re.compile(
r"\b(?:historical|history|earlier|prior|superseded|observed\s+reference|reference\s+census)\b",
re.IGNORECASE,
)
REFERENCE_EXPLICIT_REJECTION_RE = re.compile(
r"(?:\b(?:no|without|reject(?:s|ed|ing)?|retire(?:s|d|ing)?|"
r"exclude(?:s|d|ing)?|forbid(?:s|den|ding)?|do\s+not|must\s+not|"
r"not\s+(?:approved|required))\b.{0,100}$|"
r"^.{0,100}\b(?:is|are|remain)?\s*(?:rejected|retired|excluded|forbidden|"
r"not\s+(?:approved|required))\b)",
re.IGNORECASE,
)
REFERENCE_POSITIVE_AUTHORITY_RE = re.compile(
r"\b(?:approved|require(?:s|d)?|must|shall|direct\s+specification|"
r"exact\s+(?:scope|commitment)|commitment|authoritative)\b",
re.IGNORECASE,
)
def reference_retirement_context(value: str, start: int, end: int) -> bool:
window = value[max(0, start - 120) : min(len(value), end + 120)]
relative_start = min(start, 120)
relative_end = relative_start + (end - start)
before = window[:relative_start]
after = window[relative_end:]
if REFERENCE_EXPLICIT_REJECTION_RE.search(before) or REFERENCE_EXPLICIT_REJECTION_RE.search(after):
return True
return (
REFERENCE_HISTORY_RE.search(window) is not None
and REFERENCE_POSITIVE_AUTHORITY_RE.search(window) is None
)
def validate_reference_retired_scope(document: Optional[Document], issues: List[Issue]) -> None:
if document is None:
return
observed: Set[Tuple[int, int, str]] = set()
for line_number, line in enumerate(document.text.splitlines(), 1):
cells = split_markdown_row(line)
fragments = cells if cells is not None else (line,)
for fragment in fragments:
for pattern in (REFERENCE_RELATIONSHIP_SCOPE_RE, REFERENCE_FIXED_CAST_RE):
for match in pattern.finditer(fragment):
if reference_retirement_context(fragment, match.start(), match.end()):
continue
column = max(1, line.find(match.group(0)) + 1)
observed.add((line_number, column, match.group(0)))
for match in REFERENCE_NAMED_THIRTY_RE.finditer(line):
if reference_retirement_context(line, match.start(), match.end()):
continue
observed.add((line_number, match.start() + 1, match.group(0)))
for line_number, column, matched in sorted(observed):
issues.append(
issue(
"REFERENCE_RETIRED_SCOPE",
REFERENCE_SCOPE_PATH,
line_number,
column,
"reference authority positively prescribes retired fixed-cast/relationship scope: {}".format(
matched
),
)
)
def require_text_coverage(
text: str,
requirements: Sequence[Tuple[str, re.Pattern]],
@@ -1328,6 +1980,7 @@ def validate_audio_coverage(
def audit(root: Path) -> AuditResult:
root = root.resolve()
documents, issues = collect_documents(root)
reference_scope = read_scoped_document(root, REFERENCE_SCOPE_PATH, issues)
definitions, definition_issues = collect_definitions(documents)
issues.extend(definition_issues)
counts: Dict[str, int] = {}
@@ -1343,6 +1996,9 @@ def audit(root: Path) -> AuditResult:
validate_declared_bindings(documents, issues, counts)
validate_pending_bindings(documents, issues)
validate_retired_concepts(documents, issues)
validate_provisional_selection_presentation(documents, definitions, issues)
validate_party_direction(documents, issues)
validate_recruit_art_crosswalk(documents, issues)
validate_screen_coverage(documents, definitions, issues, counts)
validate_art_coverage(documents, references, issues)
validate_audio_coverage(
@@ -1351,6 +2007,8 @@ def audit(root: Path) -> AuditResult:
issues,
counts,
)
validate_audio_screen_inventory(documents, definitions, issues, counts)
validate_reference_retired_scope(reference_scope, issues)
unique_issues = {item.sort_key(): item for item in issues}
return AuditResult(sorted(unique_issues.values(), key=Issue.sort_key), dict(sorted(counts.items())))
@@ -1361,7 +2019,10 @@ def write_fixture(root: Path) -> None:
(root / directory).mkdir(parents=True, exist_ok=True)
product_texts = {
"GAME_PRODUCT_CONTRACT.md": "# Product\n\nGenerated recruits form an ordered horizontal party line.\n",
"GAME_PRODUCT_CONTRACT.md": (
"# Product\n\nGenerated recruits form an ordered horizontal party line.\n"
"Choose two of four provisional generated Recruits at no cost.\n"
),
"SYSTEMS_AND_BATTLE.md": (
"# Systems\n\nAutomatic battle supports pause, speed, inspect, and retreat.\n"
"No relationship system, six-unit cap, three-lane grid, Directive, Anchor, or Cache remains.\n"
@@ -1459,6 +2120,33 @@ def write_fixture(root: Path) -> None:
"# Content Index\n\n```sh\n! rg -ni 'relationship|romance|six-unit|Directive|Anchor|Cache|three-lane' docs/content\n```\n",
encoding="utf-8",
)
recruit = """# Generated Recruit Authority
## 1. Recruit Record
```text
recruit_instance_id, generation_seed, offer_index
display_name_tokens, pronoun_set, voice_effort_set
body_frame, face_shape, skin_palette, hair_shape, hair_palette
garment_cut, garment_palette, profession_overlay, detail_mark
```
## 4. Modular Visual Grammar
| Part | Options | Rule |
|---|---:|---|
| Body frame | 8 | deterministic option |
| Face shape | 12 | deterministic option |
| Skin palette | 12 | deterministic option |
| Hair/head shape | 24 | deterministic option |
| Hair palette | 10 | deterministic option |
| Garment cut | 12 | deterministic option |
| Garment palette | 16 | deterministic option |
| Profession overlay | 42 | deterministic option |
| Detail mark | 20 | deterministic option |
| Voice effort set | 8 | audio-owned option |
"""
(root / RECRUIT_PATH).write_text(recruit, encoding="utf-8")
page_rows = (
("SHP-001", "Shop offers", "Buy recruit", "empty, error, confirmation"),
@@ -1468,7 +2156,14 @@ def write_fixture(root: Path) -> None:
("BAT-001", "Automatic battle", "Start battle", "pause, speed, inspect, retreat"),
("OUT-001", "Victory result and reward", "Take reward", "empty, error, confirmation"),
("OUT-002", "Failure death and recovery", "Continue", "empty, error, confirmation"),
("OUT-006", "Run abandonment review", "Abandon run", "empty, error, confirmation"),
("SYS-001", "Save and cloud", "Manage save", "empty, error, confirmation"),
(
"SYS-005",
"New run provisional choice",
"Choose two provisional Recruits from four generated offers",
"Selection is revisable before Run creation; cancel creates no run",
),
("SET-001", "Settings and controls", "Apply", "input, empty, error, confirmation"),
("SET-002", "Accessibility assists", "Apply", "screen reader, reduced motion, captions"),
("SET-003", "Language localization", "Apply language", "locale, text expansion"),
@@ -1490,11 +2185,22 @@ def write_fixture(root: Path) -> None:
screen_lines.extend(
"| `{}` | {} | stable landscape composition | {} | {} |".format(*row) for row in page_rows
)
screen_lines.extend(
(
"",
"## 7. Onboarding Map",
"",
"| Moment | Base screen | Required action | Safe boundary |",
"|---|---|---|---|",
"| Opening choice | `SYS-005` | Choose two provisional Recruits from four generated offers | Choice is revisable before Run creation |",
)
)
(root / "docs/presentation/SCREEN_AND_STATE_MAP.md").write_text(
"\n".join(screen_lines) + "\n", encoding="utf-8"
)
(root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text(
"# UI\n\nLandscape input, localization, and accessibility rules are shared.\n"
"The party cord runs from First exposed at left to Last at right.\n"
"Progression coverage is `PRO-001..PST-003`.\n"
"| Binding | Use | Owner |\n|---|---|---|\n"
"| `BIND:AUDIO_EVENT_CATALOG` | semantic events | audio catalog |\n",
@@ -1505,6 +2211,7 @@ def write_fixture(root: Path) -> None:
Modular generated recruits use a landscape composition.
Equipment readability uses silhouettes and icons. The animation matrix and VFX language are production-countable.
The player party reads left-to-right from exposed front to protected rear.
Profession coverage: `ART-PRO-B01..B12`, `ART-PRO-A01..A24`, `ART-PRO-H01..H06`.
Trait coverage: `ART-TRT-01..36`.
@@ -1512,6 +2219,37 @@ Equipment coverage: `ART-EQP-001..320`.
Artifact coverage: `ART-AFT-01..60`.
Enemy coverage: `ART-ENM-001..100`; boss coverage: `ART-BOS-01..16`.
Region coverage: `ART-REG-01..08`.
## 4. Generated Recruit Visual System
### 4.2 Modular Source Inventory
| Library | Exact masters | Runtime states |
|---|---:|---:|
| Body foundations | 8 | 1 |
| Face structures | 12 | 1 |
| Skin ramps | 12 | 1 |
| Hair families | 24 | 1 |
| Hair ramps | 10 | 1 |
| Underlayers | 12 | 1 |
| Underlayer ramps | 16 | 1 |
| Profession kits | 42 | 1 |
| Face detail sets | 20 | 1 |
### 4.3 Recruit Content/Art Crosswalk
| Content field | Content options | Art source | Art masters | Mapping rule |
|---|---:|---|---:|---|
| `body_frame` | 8 | Body foundations | 8 | same stable index |
| `face_shape` | 12 | Face structures | 12 | same stable index |
| `skin_palette` | 12 | Skin ramps | 12 | same stable index |
| `hair_shape` | 24 | Hair families | 24 | same stable index |
| `hair_palette` | 10 | Hair ramps | 10 | same stable index |
| `garment_cut` | 12 | Underlayers | 12 | same stable index |
| `garment_palette` | 16 | Underlayer ramps | 16 | same stable index |
| `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 |
"""
(root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8")
@@ -1564,6 +2302,10 @@ Region coverage: `ART-REG-01..08`.
"Shop offers, recruit hire, equipment, party reorder, battle, pause, speed, retreat, permanent casualty death, dismissal, reward, failure and defeat are covered.",
"Generated recruits use bounded voice. Accessibility uses captions, visual cues, haptics, and mono review.",
"",
"| Dependency | Resolved source and value | Audio use |",
"|---|---|---|",
"| Screen and action IDs | `SYS-001`, `SYS-005`, `SHP-001`, `REC-001`, `EQP-001`, `PTY-001`, `BAT-001`, `OUT-001..002`, `OUT-006`, `SET-001..003`, `PRO-001`, `PST-003` | bind shared events to all 15 final screens |",
"",
"## 5. Music Plan",
"",
"### 5.1 Scope And Count",
@@ -1609,6 +2351,13 @@ Region coverage: `ART-REG-01..08`.
audio, encoding="utf-8"
)
(root / "docs/reference").mkdir(parents=True, exist_ok=True)
(root / REFERENCE_SCOPE_PATH).write_text(
"# Transferable Structure Lessons\n\n"
"Historical evidence mentioned profession affinities, relationship hooks, and 30 named recruitable characters; this authority rejects those retired concepts.\n",
encoding="utf-8",
)
def mutate_remove_line(path: Path, needle: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
@@ -1645,6 +2394,8 @@ def run_self_test() -> None:
"audio.ambience": 76,
"audio.sfx": 228,
"audio.voice": 32,
"pages": 15,
"pages.audio_bound": 15,
}
for name, expected in expected_counts.items():
if passing.counts.get(name) != expected:
@@ -1783,6 +2534,67 @@ def run_self_test() -> None:
),
"ID_DUPLICATE",
),
(
"provisional-selection-presentation",
lambda root: (root / SCREEN_PATH).write_text(
(root / SCREEN_PATH)
.read_text(encoding="utf-8")
.replace(
"Choose two provisional Recruits from four generated offers",
"Choose one provisional Recruit from four generated offers",
1,
),
encoding="utf-8",
),
"PROVISIONAL_SELECTION_PRESENTATION_MISMATCH",
),
(
"party-direction",
lambda root: (root / ART_PATH).write_text(
(root / ART_PATH)
.read_text(encoding="utf-8")
.replace(
"left-to-right from exposed front to protected rear",
"left-to-right from protected rear to exposed front",
1,
),
encoding="utf-8",
),
"PARTY_DIRECTION_MISMATCH",
),
(
"recruit-art-crosswalk",
lambda root: (root / ART_PATH).write_text(
(root / ART_PATH)
.read_text(encoding="utf-8")
.replace(
"| `body_frame` | 8 | Body foundations | 8 |",
"| `body_frame` | 8 | Body foundations | 12 |",
1,
),
encoding="utf-8",
),
"RECRUIT_ART_CROSSWALK_MISMATCH",
),
(
"audio-screen-inventory",
lambda root: (root / AUDIO_PATH).write_text(
(root / AUDIO_PATH)
.read_text(encoding="utf-8")
.replace("`OUT-006`, ", "", 1),
encoding="utf-8",
),
"AUDIO_SCREEN_INVENTORY_MISMATCH",
),
(
"reference-retired-scope",
lambda root: (root / REFERENCE_SCOPE_PATH).write_text(
(root / REFERENCE_SCOPE_PATH).read_text(encoding="utf-8")
+ "Approved scope: 30 named recruitable characters require profession affinities and relationship hooks.\n",
encoding="utf-8",
),
"REFERENCE_RETIRED_SCOPE",
),
)
for name, mutate, expected_code in cases:
with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary: