Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e128f630 |
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user