Files
settled-reach/tooling/economy-db/economy_import/traits.py
T
jpmschweitzerandClaude Fable 5 4f73624eff refactor(db): split import_economics.py; single generator-source registry (T-1067)
import_economics.py 2,620 → 309 lines — a thin orchestrator keeping the
exact CLI, single-transaction/rollback contract, and exit codes. The 16
import steps, MIGRATION_SQL, brands shell-out, validators, and stamp
write now live in tooling/economy-db/economy_import/ (db, migration,
economy, corporations, brands, bodies, atlas, specialization, traits,
validators, stamp, paths, errors). Full type hints throughout.

tooling/generator_sources.py replaces the triplicated source registry
(importer / stamp checker / pr-process watch list — the skill now derives
its list via --list). The registry stamps itself, and economy_import/
modules are globbed fail-closed, so a future module is stamped the moment
it exists — closing the silently-weakened-stamp failure mode.

Rider: connector config helpers centralized in tooling/db/common.py.

Byte-identical behavior proven: full-import table dump diff EMPTY over
107,843 lines / 37 tables (volatile timestamp fields excluded); dry-run
output parity; generated_brands.toml sha unchanged. make test-tooling
PASS; ruff clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:13:28 +02:00

171 lines
8.1 KiB
Python

"""Architecture-flavor trait templates (D-232, #993): catalog baker + hero bias."""
import json
import sqlite3
import tomllib
from .errors import ImportAborted
from .paths import ARCHITECTURE_TRAIT_BIAS_TOML, ARCHITECTURE_TRAIT_CATALOG_TOML
_TRAIT_CORRIDOR_POOLS: set[str] = {"baseline", "heritage", "cross_corridor"}
_TRAIT_BIAS_KINDS: set[str] = {"pin", "boost", "suppress"}
# JSON-encoded list/map columns on trait_templates (TOML inline arrays/tables ->
# JSON text the generator parses).
_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")
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 not ARCHITECTURE_TRAIT_CATALOG_TOML.exists():
if not dry_run:
conn.execute("DELETE FROM atlas_body_trait_bias")
conn.execute("DELETE FROM trait_templates")
return 0
with open(ARCHITECTURE_TRAIT_CATALOG_TOML, "rb") as f:
data = tomllib.load(f)
templates = data.get("templates", {})
errors: list[str] = []
rows: list[tuple] = []
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()
# CI guardrails (D-232, Nigel): >=5 templates eligible per BulkClass (a
# template with no bulk_class_gate is eligible for all); no single template
# may exceed 60% of its eligible pool's base weight. Skipped when the catalog
# is empty (the bootstrap/absent-source case). Integer math (no float).
if templates:
gerrors: list[str] = []
for bc in ("BulkSolid", "BulkLiquid", "PrecisionDense", "Perishable", "NonPhysical"):
elig = [t for t in templates.values()
if not t.get("bulk_class_gate") or bc in t["bulk_class_gate"]]
if len(elig) < 5:
gerrors.append(
f"V-TT-01: only {len(elig)} template(s) eligible for BulkClass "
f"{bc} (need >=5)"
)
total = sum(int(t.get("base_weight", 10000)) for t in elig)
for t in elig:
w = int(t.get("base_weight", 10000))
if total and w * 5 > total * 3: # w/total > 0.60
gerrors.append(
f"V-TT-02: template '{t.get('label')}' is "
f"{w * 100 // total}% of the {bc} pool weight (>60%)"
)
if gerrors:
print(f" TRAIT TEMPLATE GUARDRAIL FAILURES ({len(gerrors)}):")
for e in gerrors:
print(f" - {e}")
raise ImportAborted()
# Validation/guardrails run on dry-run too; only mutate when committing.
if not dry_run:
conn.execute("DELETE FROM atlas_body_trait_bias")
conn.execute("DELETE FROM trait_templates")
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.
"""
if not ARCHITECTURE_TRAIT_BIAS_TOML.exists():
if not dry_run:
conn.execute("DELETE FROM atlas_body_trait_bias")
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: list[tuple] = []
seen: set[tuple] = 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}")
if kind == "pin" and mult is not None:
errors.append(f"{loc}: pin is mandatory and must not carry weight_multiplier_bps (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()
if not dry_run:
conn.execute("DELETE FROM atlas_body_trait_bias")
conn.executemany(
"""INSERT INTO atlas_body_trait_bias
(body_id, template_tag, bias_kind, weight_multiplier_bps, note)
VALUES (?,?,?,?,?)""",
rows,
)
return len(rows)