diff --git a/governance/questions/content.md b/governance/questions/content.md index df33f4b3d..d9951c045 100644 --- a/governance/questions/content.md +++ b/governance/questions/content.md @@ -118,7 +118,8 @@ Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller me - **Assigned to:** Tyre + Gestalt ### Q-049: ObjectTag vocabulary co-maintenance — Miri and Araminta shared dependency -- **Status:** Open +- **Status:** Resolved 2026-07-08 (T-995) → [D-235](../decisions/architecture.md#d-235-building-exterior-visual-grammar-and-material-vocabulary) (amended 2026-07-07) +- **Resolution:** The canonical tag list is the shipped catalog's 28-tag palette, ratified and formalized as a machine-readable registry at `wiki/economics/object_tag_vocabulary.toml` (a shared data file, not a Rust struct — the `HeritageGrammarOverlay` framing was retired with D-READY-9 by D-232/D-235). Ownership: Miri authors cultural meaning, Araminta the visual expression, both editing the one registry file. Coordination is enforced, not conventional: importer validation (`economy_import/traits.py` V-TT-03/V-TT-04) hard-fails the build when the catalog references a tag missing from the registry, a tag's axis mismatches, or a fallback chain fails to reach its generic parent — the "silent correctness failure" this question feared is now a loud build failure. Additions land as registry + catalog edits in one commit; deprecations fail validation until every referencing template is updated. - **Priority:** Medium - **Question:** The `ObjectTag` vocabulary must be co-maintained between Miri's `HeritageGrammarOverlay` (cultural grammar, Rust struct) and Araminta's asset categorization (visual expression, TOML files). What is the governance model? Who owns the canonical tag list? How are additions and deprecations coordinated? Does the vocabulary live in the Rust struct definition or in a shared data file? - **Context:** If the vocabulary diverges, the generator will reference tags that don't exist in asset categories, or assets will be authored that the grammar never references. This is a silent correctness failure. diff --git a/server/data/systems.db b/server/data/systems.db index 649a1df42..808054ae0 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/tooling/economy-db/economy_import/paths.py b/tooling/economy-db/economy_import/paths.py index e702274b7..69d65370e 100644 --- a/tooling/economy-db/economy_import/paths.py +++ b/tooling/economy-db/economy_import/paths.py @@ -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", diff --git a/tooling/economy-db/economy_import/traits.py b/tooling/economy-db/economy_import/traits.py index f686c120b..39c2ede76 100644 --- a/tooling/economy-db/economy_import/traits.py +++ b/tooling/economy-db/economy_import/traits.py @@ -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"), diff --git a/tooling/generator_sources.py b/tooling/generator_sources.py index 77105b8db..d62aa8a6b 100644 --- a/tooling/generator_sources.py +++ b/tooling/generator_sources.py @@ -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, ) # --------------------------------------------------------------------------- diff --git a/wiki/economics/object_tag_vocabulary.toml b/wiki/economics/object_tag_vocabulary.toml new file mode 100644 index 000000000..fd40654f8 --- /dev/null +++ b/wiki/economics/object_tag_vocabulary.toml @@ -0,0 +1,209 @@ +# ========================================================================== +# ObjectTag vocabulary registry (T-995, parent T-977; decisions D-232/D-235; +# resolves Q-049). +# +# Machine-readable canonical registry for every material/form tag referenced +# by wiki/economics/architecture_trait_catalog.toml's `allow_tags`, +# `block_tags`, and `visual_bundle` fields (T-1005). This file RATIFIES the +# shipped 4-axis / 32-specific-tag + 4-generic-placeholder palette documented +# informally in that catalog's header comment as the canonical ObjectTag +# vocabulary. D-235's original example WallMaterial/RoofForm/FacadeRhythm/ +# StreetSurface token lists are superseded by the tags actually shipped here +# (see the dated amendment on D-235 in governance/decisions/architecture.md). +# +# OWNERSHIP (answers Q-049): co-maintained, split by concern, not by file -- +# - Miri owns CULTURAL MEANING: which tag a template's allow_tags/block_tags +# draws on, and why (the `cultural_description` prose lives in the +# catalog, not here). +# - Araminta owns VISUAL EXPRESSION: what a tag looks like as a shipped +# asset, and the fallback parent it degrades to before that asset exists +# (D-235's incremental-content mechanism). +# Additions/deprecations: propose a new [tags..] entry here AND +# wire it into at least one catalog template's allow_tags/visual_bundle in +# the same change -- the two sides move together. Importer validation +# (tooling/economy-db/economy_import/traits.py, V-TT-03/V-TT-04) is the +# mechanical enforcement of that co-maintenance contract: it hard-fails the +# import if the catalog references a tag absent from this registry, or if +# this registry's fallback graph doesn't resolve to a generic placeholder -- +# the exact silent-divergence failure mode Q-049 raised. +# +# LOCATION (Q-107): this file is generator-facing -- it is read by the same +# importer, at the same time, for the same purpose as +# architecture_trait_catalog.toml. It is therefore Q-107-invariant: wherever +# the wiki-authored source tree consolidation lands the catalog, this +# registry rides with it unchanged; the generator contract does not depend on +# the answer. +# +# STRUCTURE: one [tags..] table per tag, grouped under the +# four axes the catalog's visual_bundle actually keys on (wall / roof / +# facade / street -- no other ObjectTag axis is in use; `color_register` is a +# free-form palette cue, not an ObjectTag). Each entry carries: +# description -- short, plain-language, asset-facing (not cultural prose) +# fallback -- the tag (ultimately a generic) this one degrades to when +# its specific asset hasn't shipped yet (D-235). Chains are +# supported (specific -> specific -> generic) for future +# tags like the D-232 `temple_wall_wood` example; today +# every tag resolves in a single hop. +# generic -- true only for the four always-present placeholders +# (generic_wall/roof/facade/street). Generics are +# fallback-terminal: they never carry a `fallback` key. +# ========================================================================== + + +# ---- wall (10) ----------------------------------------------------------- + +[tags.wall.concrete_wall] +description = "Cast or poured concrete -- the corridor-neutral structural default." +fallback = "generic_wall" + +[tags.wall.steel_frame] +description = "Exposed structural steel frame, typically infilled -- industrial/heavy-process register." +fallback = "generic_wall" + +[tags.wall.brick_wall] +description = "Fired clay brick masonry." +fallback = "generic_wall" + +[tags.wall.rendered_wall] +description = "Masonry or block wall under a smooth painted or plastered render finish." +fallback = "generic_wall" + +[tags.wall.stone_wall] +description = "Cut or coursed stone masonry." +fallback = "generic_wall" + +[tags.wall.timber_wall] +description = "Timber-frame or solid-timber wall construction." +fallback = "generic_wall" + +[tags.wall.stucco_wall] +description = "Lime- or cement-based stucco render, typically whitewashed." +fallback = "generic_wall" + +[tags.wall.glass_curtain_wall] +description = "Non-structural glass curtain-wall cladding hung on a hidden frame." +fallback = "generic_wall" + +[tags.wall.composite_panel] +description = "Prefabricated composite/sandwich cladding panel." +fallback = "generic_wall" + +[tags.wall.rammed_earth_wall] +description = "Compacted rammed-earth wall construction." +fallback = "generic_wall" + + +# ---- roof (7) ------------------------------------------------------------- + +[tags.roof.flat_roof] +description = "Flat or near-flat roof deck." +fallback = "generic_roof" + +[tags.roof.pitched_roof] +description = "Sloped roof of moderate pitch, gabled or hipped." +fallback = "generic_roof" + +[tags.roof.corrugated_roof] +description = "Corrugated metal sheet roofing -- utilitarian, fast to erect." +fallback = "generic_roof" + +[tags.roof.clay_tile_roof] +description = "Fired clay tile roof covering." +fallback = "generic_roof" + +[tags.roof.terraced_roof] +description = "Stepped/terraced roof profile, usable as an outdoor deck." +fallback = "generic_roof" + +[tags.roof.vaulted_roof] +description = "Masonry vault or barrel-vault roof form." +fallback = "generic_roof" + +[tags.roof.green_roof] +description = "Vegetated roof surface over a waterproofed deck." +fallback = "generic_roof" + + +# ---- facade (8) ------------------------------------------------------------ + +[tags.facade.regular_facade] +description = "Even, unornamented punched-window facade rhythm -- the neutral default." +fallback = "generic_facade" + +[tags.facade.ornamental_facade] +description = "Decorative masonry or molding detail applied to the facade plane." +fallback = "generic_facade" + +[tags.facade.industrial_glazing] +description = "Utilitarian steel-framed multi-pane glazing (factory-sash register)." +fallback = "generic_facade" + +[tags.facade.arcade_facade] +description = "Ground-floor arcade -- a colonnaded walkway fronting the street." +fallback = "generic_facade" + +[tags.facade.shuttered_facade] +description = "Operable exterior shutters over window/door openings." +fallback = "generic_facade" + +[tags.facade.screen_facade] +description = "Perforated or louvred screen layer over the structural facade (solar/privacy control)." +fallback = "generic_facade" + +[tags.facade.colonnade] +description = "A row of columns supporting an entablature or roof, freestanding from the wall plane." +fallback = "generic_facade" + +[tags.facade.lattice_screen] +description = "Fine timber or metal lattice screening (mashrabiya/jalousie register)." +fallback = "generic_facade" + + +# ---- street (7) ------------------------------------------------------------ + +[tags.street.paved] +description = "Poured or laid hard pavement -- the neutral default street surface." +fallback = "generic_street" + +[tags.street.cobble] +description = "Cobblestone or sett paving." +fallback = "generic_street" + +[tags.street.packed_earth] +description = "Compacted, unpaved earth surface." +fallback = "generic_street" + +[tags.street.canal_way] +description = "Water-channel frontage in place of, or alongside, a dry street." +fallback = "generic_street" + +[tags.street.elevated_walkway] +description = "Raised pedestrian walkway above street or ground level." +fallback = "generic_street" + +[tags.street.heavy_haul] +description = "Reinforced surface engineered for heavy freight/vehicle loads." +fallback = "generic_street" + +[tags.street.boardwalk] +description = "Raised timber-plank walking surface, typically coastal or wetland." +fallback = "generic_street" + + +# ---- generic parents (4, fallback-terminal placeholders) ----------------- + +[tags.wall.generic_wall] +description = "Placeholder wall material -- renders until the specific wall asset ships (D-235 fallback)." +generic = true + +[tags.roof.generic_roof] +description = "Placeholder roof form -- renders until the specific roof asset ships (D-235 fallback)." +generic = true + +[tags.facade.generic_facade] +description = "Placeholder facade treatment -- renders until the specific facade asset ships (D-235 fallback)." +generic = true + +[tags.street.generic_street] +description = "Placeholder street surface -- renders until the specific street asset ships (D-235 fallback)." +generic = true