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>
171 lines
8.1 KiB
Python
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)
|