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:
2026-07-08 09:16:20 +02:00
co-authored by Claude Fable 5
parent a6c2ff4740
commit 766ceb436c
6 changed files with 359 additions and 2 deletions
@@ -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",
+138 -1
View File
@@ -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"),
+8
View File
@@ -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,
)
# ---------------------------------------------------------------------------