Compare commits

...
Author SHA1 Message Date
wushenghua 49e128f630 fix(validation): accept integrated cross-document schemas 2026-08-11 10:38:03 +08:00
2 changed files with 415 additions and 112 deletions
+36 -25
View File
@@ -1,8 +1,8 @@
# Pre-development Design Validation Contract
> Contract revision: `abg-design-validation-v2-crossdoc-r3`
> Contract revision: `abg-design-validation-v2-crossdoc-r3-h1`
> Frozen input: `.agent-taskgraph/spec.md` revision `design-spec-r3`
> Integrated schema evidence: `999034a`
> Integrated schema evidence: `b65646c`
> Executable: `tools/validate_predevelopment_design.py`
> Scope: merged Markdown design authorities plus one scoped reference gate; no runtime or product authority
@@ -238,40 +238,46 @@ inspection coverage.
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.
consistent pair. A primary stable-screen row must expose the same offer and
selection counts plus the revisable pre-creation boundary. Onboarding may keep
those facts in one row or split them across staged rows only when every row is
bound to that same primary screen ID. Counts in invalid-state prose, such as
zero/one/three/four selected or two unchosen offers, are not affirmative values.
### 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.
`UI_AND_VISUAL_SYSTEM.md` owns the left/right placement of exposed-front and
protected-rear endpoints; `First exposed` and `Last` are accepted equivalent
endpoint labels. `ART_ASSET_AND_ANIMATION_CATALOG.md` must independently
declare the same endpoints. The validator accepts endpoint-before-side,
`screen-left is ...`/`screen-right is ...`, and cross-line left-to-right
declarations, normalizes each 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:
that supplies these semantic values:
| 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 |
| Content cardinality | exact count in a dedicated column or in a stored-authority cell |
| Art binding | declared source library, bounded stable-ID range, or explicit nonvisual owner |
| Art cardinality | explicit count or bounded range cardinality matching content and the source inventory |
| Deterministic resolution | explicit rule, equal bounded endpoints, or one exact profession package binding |
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.
must explicitly identify audio ownership or a nonvisual disposition. Both a
dedicated five-column count/mapping table and the integrated four-column
`Recruit record field | Stored authority | Art binding | Derived assembly`
shape are valid when they carry the same semantics. A prose claim that
generation is deterministic cannot replace this field-level table.
### 8.4 Screen/Audio Identity
@@ -345,20 +351,25 @@ 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
- an alternate integrated-shape pass using staged `SYS-005` onboarding,
side-first endpoint clauses, embedded stored-authority counts, and bounded
art-binding ranges
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.
The success terminal is:
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:
```text
ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=20
```
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.
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.
+379 -87
View File
@@ -283,6 +283,35 @@ PROVISIONAL_SELECTION_RE = re.compile(
),
re.IGNORECASE,
)
PROVISIONAL_SELECTED_COUNT_RES = (
re.compile(
r"\b(?:choose|select)\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
re.IGNORECASE,
),
re.compile(
r"\b(?:with\s+)?(?:exactly\s+)?(?P<count>{})\s+selected\b".format(COUNT_TOKEN),
re.IGNORECASE,
),
re.compile(
r"\bselected\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
re.IGNORECASE,
),
)
PROVISIONAL_OFFERED_COUNT_RES = (
re.compile(
r"\b(?P<count>{})\b(?:\s+[a-z][a-z-]*){{0,6}}\s+offers?\b".format(
COUNT_TOKEN
),
re.IGNORECASE,
),
re.compile(
r"\b(?:of|from)\s+(?:the\s+)?(?P<count>{})\b"
r"[^\n|.]{{0,100}}\bprovisional\b[^\n|.]{{0,60}}\brecruits?\b".format(
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,
@@ -290,6 +319,7 @@ REVISABLE_SELECTION_RE = re.compile(
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"\bbefore\s+(?:the\s+)?commit\b|\buntil\s+(?:the\s+)?commit\b|"
r"\bno\s+run\s+(?:is\s+)?created\b)",
re.IGNORECASE,
)
@@ -346,6 +376,40 @@ RECRUIT_PART_FIELDS = (
re.compile(r"\b(?:voice\s+effort\s+sets?|audio)\b", re.IGNORECASE),
),
)
RECRUIT_ART_BINDING_PATTERNS = {
"body_frame": re.compile(
r"\bART-REC-BODY-|\bbody\s+(?:foundations?|frames?)\b", re.IGNORECASE
),
"face_shape": re.compile(
r"\bART-REC-FACE-|\bface\s+(?:structures?|shapes?)\b", re.IGNORECASE
),
"skin_palette": re.compile(
r"\bART-REC-SKINPAL-|\bskin\s+(?:ramps?|palettes?)\b", re.IGNORECASE
),
"hair_shape": re.compile(
r"\bART-REC-HAIR-|\bhair\s+(?:famil(?:y|ies)|shapes?)\b", re.IGNORECASE
),
"hair_palette": re.compile(
r"\bART-REC-HAIRPAL-|\bhair\s+(?:ramps?|palettes?)\b", re.IGNORECASE
),
"garment_cut": re.compile(
r"\bART-REC-GARMENT-|\b(?:garment\s+cuts?|underlayers?)\b", re.IGNORECASE
),
"garment_palette": re.compile(
r"\bART-REC-GARMENTPAL-|\b(?:garment\s+palettes?|underlayer\s+ramps?)\b",
re.IGNORECASE,
),
"profession_overlay": re.compile(
r"\bART-PRO-|\bprofession\s+(?:kits?|overlays?)\b", re.IGNORECASE
),
"detail_mark": re.compile(
r"\bART-REC-DETAIL-|\b(?:face\s+detail\s+sets?|detail\s+marks?)\b",
re.IGNORECASE,
),
"voice_effort_set": re.compile(
r"\b(?:audio|no\s+art|nonvisual)\b", re.IGNORECASE
),
}
def relative_path(path: Path, root: Path) -> str:
@@ -1197,11 +1261,46 @@ def count_value(token: str) -> Optional[int]:
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):
direct_matches = list(PROVISIONAL_SELECTION_RE.finditer(line))
for match in direct_matches:
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))
if direct_matches or not re.search(r"\bprovisional\b", line, re.IGNORECASE):
continue
if not re.search(r"\b(?:recruits?|offers?|company)\b", line, re.IGNORECASE):
continue
selected_matches: List[re.Match] = []
for pattern in PROVISIONAL_SELECTED_COUNT_RES:
selected_matches = list(pattern.finditer(line))
if selected_matches:
break
offered_matches = []
for pattern in PROVISIONAL_OFFERED_COUNT_RES:
offered_matches = [
match
for match in pattern.finditer(line)
if not re.search(
r"\b(?:unchosen|selected|remaining|other)\b",
match.group(0),
re.IGNORECASE,
)
]
if offered_matches:
break
selected_values = {
count_value(match.group("count")) for match in selected_matches
} - {None}
offered_values = {
count_value(match.group("count")) for match in offered_matches
} - {None}
if len(selected_values) == 1 and len(offered_values) == 1:
column = selected_matches[0].start() + 1 if selected_matches else 1
pairs.append(
(next(iter(selected_values)), next(iter(offered_values)), line_number, column)
)
return pairs
@@ -1241,19 +1340,39 @@ def validate_provisional_selection_presentation(
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)}
primary_valid_ids = {
definition.identifier
for definition, row_text in primary_candidates
if 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
)
}
primary_valid = bool(primary_valid_ids)
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
)
onboarding_by_screen: Dict[str, List[str]] = {
identifier: [] for identifier in primary_valid_ids
}
for _, line in onboarding:
for identifier in primary_valid_ids:
if re.search(r"`{}`".format(re.escape(identifier)), line):
onboarding_by_screen[identifier].append(line)
onboarding_valid = False
for lines in onboarding_by_screen.values():
grouped_text = "\n".join(lines)
if (
expected
in {
(selected, offered)
for selected, offered, _, _ in provisional_selection_pairs(grouped_text)
}
and selection_is_revisable_before_creation(grouped_text)
):
onboarding_valid = True
break
if primary_valid and onboarding_valid:
return
@@ -1283,39 +1402,77 @@ def endpoint_kind(value: str) -> Optional[str]:
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
PARTY_ENDPOINT_EXPRESSION = (
r"(?:first\s+exposed|exposed(?:\s+(?:position\s+0\s+)?front)?|"
r"protected(?:\s+rear)?|rear|last|front)"
)
PARTY_SIDE_EXPRESSION = r"(?:screen[- ]?)?(?:left|right)"
def extract_art_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
def extract_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)
concept_then_side_re = re.compile(
r"\b(?P<concept>{})\b[^.;|]{{0,40}}?\b(?P<side>{})\b".format(
PARTY_ENDPOINT_EXPRESSION, PARTY_SIDE_EXPRESSION
),
re.IGNORECASE,
)
side_then_concept_re = re.compile(
r"\b(?P<side>{})\b\s*(?:is|=|:)\s*(?:the\s+)?"
r"(?P<concept>{})\b".format(
PARTY_SIDE_EXPRESSION, PARTY_ENDPOINT_EXPRESSION
),
re.IGNORECASE,
)
lines = document.text.splitlines()
for index in range(len(lines)):
window_lines: List[str] = []
for candidate in lines[index : index + 3]:
if not candidate.strip() and window_lines:
break
window_lines.append(candidate.strip())
window = re.sub(r"[`*]", "", " ".join(window_lines))
match = direction_re.search(window)
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
return left, right, index + 1, 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
assignments: Dict[str, Set[str]] = {"left": set(), "right": set()}
for pattern in (concept_then_side_re, side_then_concept_re):
for endpoint_match in pattern.finditer(window):
concept = endpoint_kind(endpoint_match.group("concept"))
side_match = re.search(
r"\b(left|right)\b", endpoint_match.group("side"), re.IGNORECASE
)
if concept is not None and side_match is not None:
assignments[side_match.group(1).lower()].add(concept)
if (
len(assignments["left"]) == 1
and len(assignments["right"]) == 1
and assignments["left"] != assignments["right"]
):
return (
next(iter(assignments["left"])),
next(iter(assignments["right"])),
index + 1,
1,
)
return None
def extract_ui_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
return extract_party_direction(document)
def extract_art_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
return extract_party_direction(document)
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)
@@ -1430,17 +1587,26 @@ def find_crosswalk_rows(
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"):
if "field" in cell and (
"content" in cell
or "recruit" in cell
or "record" in cell
or cell == "field"
):
candidate_columns["field"] = index
elif "content" in cell and re.search(r"\b(?:count|options?|values?)\b", cell):
elif "stored" in cell and "authority" in cell:
candidate_columns["content_count"] = 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):
elif re.search(r"\b(?:mapping|resolution|rule|derived\s+assembly)\b", cell):
candidate_columns["mapping"] = index
if set(candidate_columns) == {"field", "content_count", "art_source", "art_count", "mapping"}:
if {"field", "content_count", "art_source"} <= set(candidate_columns):
header = cells
columns = candidate_columns
continue
@@ -1457,7 +1623,7 @@ def find_crosswalk_rows(
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"
"crosswalk table requires recruit field, content authority/count, and art binding columns"
]
return rows, []
@@ -1472,6 +1638,22 @@ def mapping_is_deterministic(value: str) -> bool:
) is not None
def embedded_count(value: str) -> Optional[int]:
counts = {int(match.group(0)) for match in re.finditer(r"(?<![A-Z0-9-])\d+(?![A-Z0-9-])", value)}
return next(iter(counts)) if len(counts) == 1 else None
def bounded_art_binding_count(value: str) -> Optional[int]:
identifiers: Set[str] = set()
for match in CODE_SPAN_RE.finditer(value):
expression = match.group(1).strip()
expanded = expand_compact_id_expression(expression)
if expanded is None or not all(identifier.startswith("ART-") for identifier in expanded):
continue
identifiers.update(expanded)
return len(identifiers) if identifiers else 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)
@@ -1492,29 +1674,40 @@ def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[I
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)
content_authority = normalize_cell(cells[columns["content_count"]])
content_count = embedded_count(content_authority)
source = normalize_cell(cells[columns["art_source"]])
mapping = normalize_cell(cells[columns["mapping"]])
explicit_art_count = (
embedded_count(normalize_cell(cells[columns["art_count"]]))
if "art_count" in columns
else None
)
binding_count = bounded_art_binding_count(cells[columns["art_source"]])
art_count = explicit_art_count if "art_count" in columns else binding_count
mapping = (
normalize_cell(cells[columns["mapping"]])
if "mapping" in columns
else " | ".join(normalize_cell(cell) for cell in cells)
)
binding_pattern = RECRUIT_ART_BINDING_PATTERNS[field]
matching_sources = {
count for label, count in source_counts if source_pattern.search(label)
}
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):
if not binding_pattern.search("{} {}".format(content_authority, source)):
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):
if not binding_pattern.search(source):
errors.append("{} maps to an unrecognized art source".format(field))
if art_count != expected:
if art_count is not None and 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(
@@ -1523,7 +1716,18 @@ def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[I
expected,
)
)
if not mapping_is_deterministic(mapping):
if art_count is None and matching_sources != {expected}:
errors.append("{} lacks a bounded art count".format(field))
deterministic_mapping = (
field == "voice_effort_set"
or mapping_is_deterministic(mapping)
or binding_count == expected
or (
re.search(r"\bone\b", source, re.IGNORECASE) is not None
and matching_sources == {expected}
)
)
if not deterministic_mapping:
errors.append("{} lacks a deterministic mapping rule".format(field))
if not errors:
@@ -2359,6 +2563,108 @@ Region coverage: `ART-REG-01..08`.
)
def replace_fixture_text(path: Path, old: str, new: str) -> None:
text = path.read_text(encoding="utf-8")
if old not in text:
raise AssertionError("fixture rewrite target not found in {}: {}".format(path, old[:80]))
path.write_text(text.replace(old, new, 1), encoding="utf-8")
def rewrite_fixture_with_integrated_shapes(root: Path) -> None:
screen_path = root / SCREEN_PATH
replace_fixture_text(
screen_path,
"Choose two provisional Recruits from four generated offers",
"Compare four stable provisional generated Recruit offers and choose exactly two before Run creation",
)
replace_fixture_text(
screen_path,
"| Opening choice | `SYS-005` | Choose two provisional Recruits from four generated offers | Choice is revisable before Run creation |",
"| Provisional Company | `SYS-005` stages `A-C` | inspect four stable offers and choose exactly two, then review both | selection alone creates no run |\n"
"| Run creation | `SYS-005` stages `D-E` | confirm selected two and the unchosen consequence | `Revise Recruits` remains legal before commit |",
)
replace_fixture_text(
root / UI_PATH,
"The party cord runs from First exposed at left to Last at right.",
"The party cord runs from `Protected rear` at left to `Exposed front` at right.",
)
art_path = root / ART_PATH
replace_fixture_text(
art_path,
"The player party reads left-to-right from exposed front to protected rear.",
"Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.",
)
old_crosswalk = """### 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 |
"""
integrated_crosswalk = """### 4.3 Recruit Record To Art Crosswalk
| Recruit record field | Stored authority | Art binding | Derived assembly and runtime output |
|---|---|---|---|
| `voice_effort_set` | Audio field with 8 content options | No art master | Audio binds independently to the recruit ID. |
| `body_frame` | Visual grammar: 8 options | `ART-REC-BODY-01..08` | Shared body layers use the stored field. |
| `face_shape` | Visual grammar: 12 options | `ART-REC-FACE-01..12` | Portrait assembly uses the stored field. |
| `skin_palette` | Visual grammar: 12 options | `ART-REC-SKINPAL-01..12` | Palette data uses the stored field. |
| `hair_shape` | Visual grammar: 24 options | `ART-REC-HAIR-01..24` | Hair assemblies use the stored field. |
| `hair_palette` | Visual grammar: 10 options | `ART-REC-HAIRPAL-01..10` | Palette data uses the stored field. |
| `garment_cut` | Visual grammar: 12 options | `ART-REC-GARMENT-01..12` | Garment layers use the stored field. |
| `garment_palette` | Visual grammar: 16 options | `ART-REC-GARMENTPAL-01..16` | Palette data uses the stored field. |
| `profession_overlay` | Visual grammar: 42 options | One `ART-PRO-B*`, `ART-PRO-A*`, or `ART-PRO-H*` package | The selected profession owns one binding. |
| `detail_mark` | Visual grammar: 20 options | `ART-REC-DETAIL-01..20` | Detail assembly uses the stored field. |
"""
replace_fixture_text(art_path, old_crosswalk, integrated_crosswalk)
def mutate_integrated_provisional_fixture(root: Path) -> None:
rewrite_fixture_with_integrated_shapes(root)
replace_fixture_text(
root / SCREEN_PATH,
"choose exactly two before Run creation",
"choose exactly one before Run creation",
)
def mutate_integrated_direction_fixture(root: Path) -> None:
rewrite_fixture_with_integrated_shapes(root)
replace_fixture_text(
root / ART_PATH,
"Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.",
"Screen-left is `First exposed` and the party front; screen-right is the protected rear and `Last`.",
)
def mutate_integrated_crosswalk_fixture(root: Path) -> None:
rewrite_fixture_with_integrated_shapes(root)
replace_fixture_text(root / ART_PATH, "`ART-REC-BODY-01..08`", "`ART-REC-BODY-01..07`")
def mutate_integrated_audio_screen_fixture(root: Path) -> None:
rewrite_fixture_with_integrated_shapes(root)
replace_fixture_text(root / AUDIO_PATH, "`OUT-006`, ", "")
def mutate_integrated_reference_scope_fixture(root: Path) -> None:
rewrite_fixture_with_integrated_shapes(root)
path = root / REFERENCE_SCOPE_PATH
path.write_text(
path.read_text(encoding="utf-8")
+ "Approved scope: 30 named recruitable characters require profession affinities and relationship hooks.\n",
encoding="utf-8",
)
def mutate_remove_line(path: Path, needle: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
for index, line in enumerate(lines):
@@ -2416,6 +2722,15 @@ def run_self_test() -> None:
if expanded is None or len(expanded) != expected:
raise AssertionError("range regression failed: {}".format(expression))
rewrite_fixture_with_integrated_shapes(root)
integrated_shape = audit(root)
if not integrated_shape.ok:
rendered = "\n".join(
"{} {}:{} {}".format(item.code, item.path, item.line, item.message)
for item in integrated_shape.issues
)
raise AssertionError("integrated-shape passing fixture failed:\n{}".format(rendered))
cases: Sequence[Tuple[str, Callable[[Path], None], str]] = (
(
"exact-count",
@@ -2536,66 +2851,37 @@ def run_self_test() -> None:
),
(
"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",
),
mutate_integrated_provisional_fixture,
"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",
),
mutate_integrated_direction_fixture,
"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",
),
mutate_integrated_crosswalk_fixture,
"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",
),
mutate_integrated_audio_screen_fixture,
"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",
),
mutate_integrated_reference_scope_fixture,
"REFERENCE_RETIRED_SCOPE",
),
)
isolated_semantic_cases = {
"provisional-selection-presentation",
"party-direction",
"recruit-art-crosswalk",
"audio-screen-inventory",
"reference-retired-scope",
}
for name, mutate, expected_code in cases:
with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary:
root = Path(temporary)
@@ -2607,6 +2893,12 @@ def run_self_test() -> None:
raise AssertionError(
"{} did not produce {}; got {}".format(name, expected_code, sorted(codes))
)
if name in isolated_semantic_cases and codes != {expected_code}:
raise AssertionError(
"{} was not isolated to {}; got {}".format(
name, expected_code, sorted(codes)
)
)
print("ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases={}".format(len(cases) + 1))