feat(schema): trait_templates + atlas_body_trait_bias tables + bake (#993)

D-232 architecture-flavor storage mechanism (content is #1005/#1017):

- trait_templates: shared catalog of holistic template bundles — two-tier
  eligibility (hard gates bulk_class/prosperity_bps/ubiquity + soft weight
  mods), zone_affinity, allow/block tags, visual_bundle. List/map fields
  are JSON; all numerics integer basis-points (D-010).
- atlas_body_trait_bias: sparse per-body hero pins — pin/boost/suppress
  with basis-point multiplier ranges (boost ≤3×, suppress ≥0.33× never 0).
- Baked at import (steps 14/15) from wiki/economics/architecture_trait_
  catalog.toml + architecture_trait_bias.toml. FK-validated (body_id →
  bodies, template_tag → trait_templates), enum + multiplier-range checks,
  graceful on absent source. Source location provisional pending Q-107;
  the baked tables are invariant per D-232. Retires the round-2
  atlas_body_culture tables.
- Catalog ships a 3-template bootstrap seed to prove the bake end-to-end;
  the full ~35-template catalog is #1005, hero pins #1017.
- Stamp sources updated (IMPORT_ECONOMICS_SOURCES + GENERATOR_SOURCES).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 10:47:35 +02:00
co-authored by Claude Opus 4.8
parent 00c8574e11
commit a133b6416e
6 changed files with 351 additions and 0 deletions
+3
View File
@@ -56,6 +56,9 @@ GENERATOR_SOURCES: dict[str, list[Path]] = {
# with IMPORT_ECONOMICS_SOURCES in tooling/economy-db/import_economics.py.
REPO_ROOT / "wiki" / "economics" / "specialization_vocabulary.toml",
REPO_ROOT / "wiki" / "economics" / "system_specialization.toml",
# D-232 architecture-flavor trait catalog + hero bias (#993).
REPO_ROOT / "wiki" / "economics" / "architecture_trait_catalog.toml",
REPO_ROOT / "wiki" / "economics" / "architecture_trait_bias.toml",
],
}
+187
View File
@@ -45,6 +45,10 @@ COMMODITIES_TOML = REPO_ROOT / "wiki" / "economics" / "commodities.toml"
CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml"
SPECIALIZATION_VOCAB_TOML = REPO_ROOT / "wiki" / "economics" / "specialization_vocabulary.toml"
SYSTEM_SPECIALIZATION_TOML = REPO_ROOT / "wiki" / "economics" / "system_specialization.toml"
# D-232 architecture-flavor trait-template catalog + sparse hero bias (#993).
# Source-location is provisional pending Q-107; the baked tables are invariant.
ARCHITECTURE_TRAIT_CATALOG_TOML = REPO_ROOT / "wiki" / "economics" / "architecture_trait_catalog.toml"
ARCHITECTURE_TRAIT_BIAS_TOML = REPO_ROOT / "wiki" / "economics" / "architecture_trait_bias.toml"
SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql"
CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations"
WIKI_STAR_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
@@ -90,6 +94,8 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
# GENERATOR_SOURCES["import_economics"] in tooling/check-systems-db-stamp.
SPECIALIZATION_VOCAB_TOML,
SYSTEM_SPECIALIZATION_TOML,
ARCHITECTURE_TRAIT_CATALOG_TOML,
ARCHITECTURE_TRAIT_BIAS_TOML,
)
@@ -371,6 +377,45 @@ CREATE TABLE IF NOT EXISTS atlas_city_positions (
);
CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id);
-- Architecture-flavor trait templates (D-232, #993). trait_templates = the
-- shared catalog (baked from architecture_trait_catalog.toml); atlas_body_trait_bias
-- = sparse per-body hero pins (#1017). List/map fields are JSON; numerics are
-- integer basis-points (D-010). Retires the round-2 atlas_body_culture tables.
DROP TABLE IF EXISTS atlas_body_culture_era;
DROP TABLE IF EXISTS atlas_body_culture;
CREATE TABLE IF NOT EXISTS trait_templates (
tag TEXT PRIMARY KEY,
label TEXT NOT NULL,
cultural_description TEXT,
corridor_pool TEXT NOT NULL DEFAULT 'baseline',
geographic_sector TEXT,
bulk_class_gate TEXT,
production_ubiquity_gate TEXT,
min_prosperity_bps INTEGER NOT NULL DEFAULT 0,
base_weight INTEGER NOT NULL DEFAULT 10000,
weight_mods TEXT,
zone_affinity TEXT,
allow_tags TEXT,
block_tags TEXT,
era_scope TEXT,
visual_bundle TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_trait_templates_pool ON trait_templates(corridor_pool);
CREATE INDEX IF NOT EXISTS idx_trait_templates_sector ON trait_templates(geographic_sector);
CREATE TABLE IF NOT EXISTS atlas_body_trait_bias (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
template_tag TEXT NOT NULL REFERENCES trait_templates(tag) ON DELETE CASCADE,
bias_kind TEXT NOT NULL,
weight_multiplier_bps INTEGER,
note TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (body_id, template_tag)
);
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_body ON atlas_body_trait_bias(body_id);
CREATE INDEX IF NOT EXISTS idx_atlas_body_trait_bias_tag ON atlas_body_trait_bias(template_tag);
-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911).
-- Idempotent: each UPDATE is a no-op if the old value is already gone.
UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture');
@@ -2015,6 +2060,139 @@ def _specialization_checks(conn, vocab, systems, strict):
)
# ---------------------------------------------------------------------------
# Architecture-flavor trait templates (D-232, #993)
# ---------------------------------------------------------------------------
_TRAIT_CORRIDOR_POOLS = {"baseline", "heritage", "cross_corridor"}
_TRAIT_BIAS_KINDS = {"pin", "boost", "suppress"}
# JSON-encoded list/map columns on trait_templates (TOML inline arrays/tables ->
# JSON text the generator parses).
_TRAIT_JSON_LIST = ("bulk_class_gate", "production_ubiquity_gate", "allow_tags", "block_tags")
_TRAIT_JSON_MAP = ("weight_mods", "zone_affinity", "visual_bundle")
def populate_trait_templates(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Bake the D-232 architecture-flavor catalog into trait_templates (#993).
Reads ARCHITECTURE_TRAIT_CATALOG_TOML (`[templates.<tag>]` stanzas) and
rebuilds the table. List/map fields are stored as JSON text; numeric
eligibility is integer basis-points (D-010). The catalog *content* is
authored in #1005 — this baker is the mechanism. Absent source -> 0 rows
(the table still exists for the downstream pipeline). Deterministic rebuild:
clears trait_templates (cascading atlas_body_trait_bias) first.
"""
if dry_run:
# Still surface row count that WOULD be baked.
pass
conn.execute("DELETE FROM atlas_body_trait_bias")
conn.execute("DELETE FROM trait_templates")
if not ARCHITECTURE_TRAIT_CATALOG_TOML.exists():
return 0
with open(ARCHITECTURE_TRAIT_CATALOG_TOML, "rb") as f:
data = tomllib.load(f)
templates = data.get("templates", {})
errors: list[str] = []
rows = []
for tag, t in templates.items():
pool = t.get("corridor_pool", "baseline")
if pool not in _TRAIT_CORRIDOR_POOLS:
errors.append(f"trait_templates '{tag}': corridor_pool '{pool}' invalid")
if "label" not in t:
errors.append(f"trait_templates '{tag}': missing required 'label'")
for k in (*_TRAIT_JSON_LIST, *_TRAIT_JSON_MAP):
# any provided list/map field must JSON-encode cleanly
if k in t:
try:
json.dumps(t[k])
except (TypeError, ValueError):
errors.append(f"trait_templates '{tag}': field '{k}' not JSON-serialisable")
rows.append((
tag, t.get("label", ""), t.get("cultural_description"),
pool, t.get("geographic_sector"),
json.dumps(t["bulk_class_gate"]) if t.get("bulk_class_gate") else None,
json.dumps(t["production_ubiquity_gate"]) if t.get("production_ubiquity_gate") else None,
int(t.get("min_prosperity_bps", 0)),
int(t.get("base_weight", 10000)),
json.dumps(t["weight_mods"]) if t.get("weight_mods") else None,
json.dumps(t["zone_affinity"]) if t.get("zone_affinity") else None,
json.dumps(t["allow_tags"]) if t.get("allow_tags") else None,
json.dumps(t["block_tags"]) if t.get("block_tags") else None,
t.get("era_scope"),
json.dumps(t["visual_bundle"]) if t.get("visual_bundle") else None,
))
if errors:
print(f" TRAIT TEMPLATE ERRORS ({len(errors)}):")
for e in errors:
print(f" - {e}")
raise _ImportAborted()
conn.executemany(
"""INSERT INTO trait_templates
(tag, label, cultural_description, corridor_pool, geographic_sector,
bulk_class_gate, production_ubiquity_gate, min_prosperity_bps,
base_weight, weight_mods, zone_affinity, allow_tags, block_tags,
era_scope, visual_bundle)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
rows,
)
return len(rows)
def populate_atlas_body_trait_bias(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Bake the sparse per-body hero pins into atlas_body_trait_bias (#993).
Reads ARCHITECTURE_TRAIT_BIAS_TOML (`[[bias]]` array). FK-validates body_id
against bodies and template_tag against trait_templates (which must be baked
first), and validates bias_kind + the basis-point multiplier ranges
(boost 10001..30000 = <=3x; suppress 3300..9999 = >=0.33x never 0; pin: no
multiplier). Hero-pin *content* is authored in #1017. Absent source -> 0
rows. Must run AFTER populate_trait_templates.
"""
conn.execute("DELETE FROM atlas_body_trait_bias")
if not ARCHITECTURE_TRAIT_BIAS_TOML.exists():
return 0
with open(ARCHITECTURE_TRAIT_BIAS_TOML, "rb") as f:
data = tomllib.load(f)
entries = data.get("bias", [])
body_ids = {r[0] for r in conn.execute("SELECT body_id FROM bodies")}
tags = {r[0] for r in conn.execute("SELECT tag FROM trait_templates")}
errors: list[str] = []
rows = []
seen = set()
for i, b in enumerate(entries):
bid = b.get("body_id")
tag = b.get("template_tag")
kind = b.get("bias_kind")
mult = b.get("weight_multiplier_bps")
loc = f"bias[{i}] ({bid}/{tag})"
if bid not in body_ids:
errors.append(f"{loc}: body_id not in bodies")
if tag not in tags:
errors.append(f"{loc}: template_tag not in trait_templates")
if kind not in _TRAIT_BIAS_KINDS:
errors.append(f"{loc}: bias_kind '{kind}' invalid (pin|boost|suppress)")
if (bid, tag) in seen:
errors.append(f"{loc}: duplicate (body_id, template_tag)")
seen.add((bid, tag))
if kind == "boost" and not (mult and 10001 <= mult <= 30000):
errors.append(f"{loc}: boost weight_multiplier_bps must be 10001..30000 (<=3x), got {mult}")
if kind == "suppress" and not (mult and 3300 <= mult <= 9999):
errors.append(f"{loc}: suppress weight_multiplier_bps must be 3300..9999 (>=0.33x, never 0), got {mult}")
rows.append((bid, tag, kind, mult, b.get("note")))
if errors:
print(f" TRAIT BIAS ERRORS ({len(errors)}):")
for e in errors:
print(f" - {e}")
raise _ImportAborted()
conn.executemany(
"""INSERT INTO atlas_body_trait_bias
(body_id, template_tag, bias_kind, weight_multiplier_bps, note)
VALUES (?,?,?,?,?)""",
rows,
)
return len(rows)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@@ -2193,6 +2371,15 @@ def main():
n_updated, n_inserted = populate_atlas_city_names_corps(conn, args.dry_run)
print(f" {n_updated} rows updated, {n_inserted} reserved rows inserted")
# 14. Architecture-flavor trait templates (D-232, #993). Catalog first,
# then sparse per-body hero bias (FK -> trait_templates + bodies).
print(" [14/15] Baking trait_templates catalog (D-232)...")
n_templates = populate_trait_templates(conn, args.dry_run)
print(f" {n_templates} trait templates")
print(" [15/15] Baking atlas_body_trait_bias hero pins (D-232)...")
n_bias = populate_atlas_body_trait_bias(conn, args.dry_run)
print(f" {n_bias} body trait-bias rows")
# Validate structural integrity (FK, chain refs, chain completeness).
# These errors indicate broken imported data — do NOT commit.
print("\n Validating structural integrity...")