feat(db): ObjectTag registry ratified + importer validation — resolves Q-049 (T-995)
The shipped 28-tag catalog palette is canonical (user ratification, /whats-next refinement 2026-07-07). New machine-readable registry wiki/economics/object_tag_vocabulary.toml (wall/roof/facade/street axes + 4 generic fallback terminals, Miri/Araminta co-owned header). Importer validation in economy_import/traits.py: V-TT-03 (every catalog allow/block/visual_bundle tag exists in the registry, axis-checked) and V-TT-04 (fallback graph: non-generics declare a parent, chains acyclic, resolve to a generic — absorbs T-1004's Phase-4 slice). Registry added to generator_sources (stamp coverage); systems.db regenerated + stamped. Q-049 marked Resolved — divergence is now a loud build failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from generator_sources import (
|
||||
ARCHITECTURE_TRAIT_BIAS_TOML,
|
||||
ARCHITECTURE_TRAIT_CATALOG_TOML,
|
||||
GENERATE_BRANDS_WRAPPER,
|
||||
OBJECT_TAG_VOCABULARY_TOML,
|
||||
REPO_ROOT,
|
||||
SPECIALIZATION_VOCAB_TOML,
|
||||
SYSTEM_SPECIALIZATION_TOML,
|
||||
@@ -28,6 +29,7 @@ __all__ = [
|
||||
"DB_PATH",
|
||||
"GENERATED_BRANDS_TOML",
|
||||
"GENERATE_BRANDS_WRAPPER",
|
||||
"OBJECT_TAG_VOCABULARY_TOML",
|
||||
"REPO_ROOT",
|
||||
"SCHEMA_SQL",
|
||||
"SPECIALIZATION_VOCAB_TOML",
|
||||
|
||||
@@ -5,7 +5,11 @@ import sqlite3
|
||||
import tomllib
|
||||
|
||||
from .errors import ImportAborted
|
||||
from .paths import ARCHITECTURE_TRAIT_BIAS_TOML, ARCHITECTURE_TRAIT_CATALOG_TOML
|
||||
from .paths import (
|
||||
ARCHITECTURE_TRAIT_BIAS_TOML,
|
||||
ARCHITECTURE_TRAIT_CATALOG_TOML,
|
||||
OBJECT_TAG_VOCABULARY_TOML,
|
||||
)
|
||||
|
||||
_TRAIT_CORRIDOR_POOLS: set[str] = {"baseline", "heritage", "cross_corridor"}
|
||||
_TRAIT_BIAS_KINDS: set[str] = {"pin", "boost", "suppress"}
|
||||
@@ -14,6 +18,110 @@ _TRAIT_BIAS_KINDS: set[str] = {"pin", "boost", "suppress"}
|
||||
_TRAIT_JSON_LIST: tuple[str, ...] = ("bulk_class_gate", "production_ubiquity_gate", "allow_tags", "block_tags")
|
||||
_TRAIT_JSON_MAP: tuple[str, ...] = ("weight_mods", "zone_affinity", "visual_bundle")
|
||||
|
||||
# ObjectTag vocabulary (T-995, Q-049, D-235): the four visual_bundle axes the
|
||||
# catalog keys on. `color_register` is a free-form palette cue, not an
|
||||
# ObjectTag, and is intentionally not one of these.
|
||||
_TAG_AXES: tuple[str, ...] = ("wall", "roof", "facade", "street")
|
||||
# The four always-present fallback-terminal placeholders (D-232/D-235).
|
||||
_EXPECTED_GENERIC_TAGS: frozenset[str] = frozenset(
|
||||
{"generic_wall", "generic_roof", "generic_facade", "generic_street"}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_tag_fallback_chain(tag: str, registry: dict[str, dict], errors: list[str]) -> None:
|
||||
"""Walk one registry tag's fallback chain to a generic parent (V-TT-04).
|
||||
|
||||
Appends a finding to `errors` if the chain references an unknown tag,
|
||||
cycles back on itself, or a non-generic tag never reaches a generic
|
||||
parent. Generics are terminal by construction (checked at load time, not
|
||||
here) so walking stops the moment a generic entry is reached.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
cur = tag
|
||||
while True:
|
||||
entry = registry.get(cur)
|
||||
if entry is None:
|
||||
errors.append(
|
||||
f"V-TT-04: object_tag_vocabulary '{tag}': fallback chain references "
|
||||
f"unknown tag '{cur}'"
|
||||
)
|
||||
return
|
||||
if entry["generic"]:
|
||||
return
|
||||
if cur in seen:
|
||||
chain = " -> ".join((*seen, cur))
|
||||
errors.append(f"V-TT-04: object_tag_vocabulary '{tag}': fallback chain cycles ({chain})")
|
||||
return
|
||||
seen.append(cur)
|
||||
nxt = entry.get("fallback")
|
||||
if not nxt:
|
||||
errors.append(
|
||||
f"V-TT-04: object_tag_vocabulary '{tag}': non-generic tag has no 'fallback' "
|
||||
"and never reaches a generic parent"
|
||||
)
|
||||
return
|
||||
cur = nxt
|
||||
|
||||
|
||||
def _load_object_tag_vocabulary(errors: list[str]) -> dict[str, dict]:
|
||||
"""Parse + self-validate the ObjectTag registry (T-995, Q-049, D-235).
|
||||
|
||||
Returns a flat {tag_name: {"axis", "fallback", "generic"}} lookup spanning
|
||||
every axis (a tag name is unique across axes). Appends malformed-registry
|
||||
findings (unknown axis, duplicate tag, missing description, a generic
|
||||
declaring a fallback, a non-generic missing one, or a broken fallback
|
||||
chain) to the shared `errors` list — same accumulate-then-abort pattern as
|
||||
the trait_templates checks below. Absent registry with a present catalog
|
||||
is itself a hard error (the catalog now depends on this file to validate
|
||||
against); returns {} in that case so downstream per-tag lookups no-op
|
||||
rather than raising a second, redundant error.
|
||||
"""
|
||||
if not OBJECT_TAG_VOCABULARY_TOML.exists():
|
||||
errors.append(
|
||||
"V-TT-03: object_tag_vocabulary.toml not found — required to validate "
|
||||
"architecture_trait_catalog.toml's ObjectTag references (T-995)"
|
||||
)
|
||||
return {}
|
||||
with open(OBJECT_TAG_VOCABULARY_TOML, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
axes = data.get("tags", {})
|
||||
registry: dict[str, dict] = {}
|
||||
for axis, tags in axes.items():
|
||||
if axis not in _TAG_AXES:
|
||||
errors.append(f"V-TT-03: object_tag_vocabulary axis '{axis}' not one of {_TAG_AXES}")
|
||||
continue
|
||||
for tag, entry in tags.items():
|
||||
if tag in registry:
|
||||
errors.append(
|
||||
f"V-TT-03: object_tag_vocabulary tag '{tag}' declared in both "
|
||||
f"'{registry[tag]['axis']}' and '{axis}' axes"
|
||||
)
|
||||
continue
|
||||
if not entry.get("description"):
|
||||
errors.append(f"V-TT-03: object_tag_vocabulary '{tag}': missing 'description'")
|
||||
is_generic = bool(entry.get("generic", False))
|
||||
fallback = entry.get("fallback")
|
||||
if is_generic and fallback:
|
||||
errors.append(
|
||||
f"V-TT-04: object_tag_vocabulary '{tag}': generic tag must not declare "
|
||||
f"'fallback' (got '{fallback}') — generics are fallback-terminal"
|
||||
)
|
||||
elif not is_generic and not fallback:
|
||||
errors.append(
|
||||
f"V-TT-04: object_tag_vocabulary '{tag}': non-generic tag must declare "
|
||||
"a 'fallback' parent"
|
||||
)
|
||||
registry[tag] = {"axis": axis, "fallback": fallback, "generic": is_generic}
|
||||
found_generics = {t for t, e in registry.items() if e["generic"]}
|
||||
if found_generics != _EXPECTED_GENERIC_TAGS:
|
||||
errors.append(
|
||||
f"V-TT-04: object_tag_vocabulary generic placeholders {sorted(found_generics)} "
|
||||
f"!= expected {sorted(_EXPECTED_GENERIC_TAGS)}"
|
||||
)
|
||||
for tag in registry:
|
||||
_resolve_tag_fallback_chain(tag, registry, errors)
|
||||
return registry
|
||||
|
||||
|
||||
def populate_trait_templates(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
"""Bake the D-232 architecture-flavor catalog into trait_templates (#993).
|
||||
@@ -34,6 +142,10 @@ def populate_trait_templates(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
data = tomllib.load(f)
|
||||
templates = data.get("templates", {})
|
||||
errors: list[str] = []
|
||||
# ObjectTag registry (T-995, Q-049): loaded once, self-validated (V-TT-04
|
||||
# fallback-graph checks happen inside), then used below to check every
|
||||
# template's tag references actually exist (V-TT-03).
|
||||
tag_registry = _load_object_tag_vocabulary(errors)
|
||||
rows: list[tuple] = []
|
||||
for tag, t in templates.items():
|
||||
pool = t.get("corridor_pool", "baseline")
|
||||
@@ -48,6 +160,31 @@ def populate_trait_templates(conn: sqlite3.Connection, dry_run: bool) -> int:
|
||||
json.dumps(t[k])
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"trait_templates '{tag}': field '{k}' not JSON-serialisable")
|
||||
# V-TT-03: every ObjectTag the template references must exist in the
|
||||
# object_tag_vocabulary.toml registry. Skipped when the registry
|
||||
# itself failed to load (one clear error above beats N spurious ones).
|
||||
if tag_registry:
|
||||
for used in (*(t.get("allow_tags") or []), *(t.get("block_tags") or [])):
|
||||
if used not in tag_registry:
|
||||
errors.append(
|
||||
f"V-TT-03: trait_templates '{tag}' ({t.get('label')}): tag '{used}' "
|
||||
"(allow_tags/block_tags) not in object_tag_vocabulary.toml"
|
||||
)
|
||||
vb = t.get("visual_bundle") or {}
|
||||
for axis in _TAG_AXES:
|
||||
for used in vb.get(axis) or []:
|
||||
entry = tag_registry.get(used)
|
||||
if entry is None:
|
||||
errors.append(
|
||||
f"V-TT-03: trait_templates '{tag}' ({t.get('label')}): tag '{used}' "
|
||||
f"(visual_bundle.{axis}) not in object_tag_vocabulary.toml"
|
||||
)
|
||||
elif entry["axis"] != axis:
|
||||
errors.append(
|
||||
f"V-TT-03: trait_templates '{tag}' ({t.get('label')}): tag '{used}' "
|
||||
f"used as visual_bundle.{axis} but registered under axis "
|
||||
f"'{entry['axis']}' in object_tag_vocabulary.toml"
|
||||
)
|
||||
rows.append((
|
||||
tag, t.get("label", ""), t.get("cultural_description"),
|
||||
pool, t.get("geographic_sector"),
|
||||
|
||||
@@ -81,6 +81,13 @@ ARCHITECTURE_TRAIT_CATALOG_TOML: Path = (
|
||||
ARCHITECTURE_TRAIT_BIAS_TOML: Path = (
|
||||
REPO_ROOT / "wiki" / "economics" / "architecture_trait_bias.toml"
|
||||
)
|
||||
# ObjectTag vocabulary registry (T-995, Q-049): read by traits.py alongside the
|
||||
# catalog above to validate allow_tags/block_tags/visual_bundle references and
|
||||
# the fallback-graph (V-TT-03/V-TT-04), so a registry edit must flip the stamp
|
||||
# exactly like a catalog edit does.
|
||||
OBJECT_TAG_VOCABULARY_TOML: Path = (
|
||||
REPO_ROOT / "wiki" / "economics" / "object_tag_vocabulary.toml"
|
||||
)
|
||||
|
||||
|
||||
def _economy_import_modules() -> tuple[Path, ...]:
|
||||
@@ -116,6 +123,7 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
|
||||
SYSTEM_SPECIALIZATION_TOML,
|
||||
ARCHITECTURE_TRAIT_CATALOG_TOML,
|
||||
ARCHITECTURE_TRAIT_BIAS_TOML,
|
||||
OBJECT_TAG_VOCABULARY_TOML,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user