diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index 4ef3b1db2..ff9a67eee 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -539,6 +539,50 @@ CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_ CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id); -- END ATLAS INDEX (D-191 §8, #832) +-- ── Architecture-flavor trait templates (D-232, #993) ────────────────────── +-- trait_templates: the shared catalog of holistic architecture-flavor template +-- bundles (NOT per-axis traits). Baked from +-- wiki/economics/architecture_trait_catalog.toml. List/map fields are JSON text +-- (the generator parses). All numerics are integer basis-points (D-010). +-- Catalog content is authored in #1005; this table is the mechanism (#993). +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', -- baseline | heritage | cross_corridor + geographic_sector TEXT, -- corridor; NULL = shared/cross-corridor + bulk_class_gate TEXT, -- JSON list of eligible BulkClass; NULL/[] = any + production_ubiquity_gate TEXT, -- JSON list; NULL/[] = any + min_prosperity_bps INTEGER NOT NULL DEFAULT 0, -- hard gate (basis points) + base_weight INTEGER NOT NULL DEFAULT 10000, -- basis points + weight_mods TEXT, -- JSON {dimension: {value: multiplier_bps}} + zone_affinity TEXT, -- JSON {DistrictType: weight_bps} + allow_tags TEXT, -- JSON list of ObjectTag + block_tags TEXT, -- JSON list of ObjectTag + era_scope TEXT, + visual_bundle TEXT, -- JSON (D-235 bundle + fallback parents) + 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); + +-- atlas_body_trait_bias: sparse per-body bias on the catalog draw (hero bodies +-- only, ~30-40). pin = mandatory (counts toward K); boost = weight up (<=3x); +-- suppress = weight down (>=0.33x, never 0). Hero-pin content authored in #1017. +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, -- pin | boost | suppress + weight_multiplier_bps INTEGER, -- boost 10001..30000; suppress 3300..9999; pin NULL + 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); +-- END TRAIT TEMPLATES (D-232, #993) + -- Indexes -- astronomical_id removed: system_id IS the GJ catalog number CREATE INDEX IF NOT EXISTS idx_star_systems_sector ON star_systems(geographic_sector); diff --git a/server/data/systems.db b/server/data/systems.db index bc0a6efd6..36bc2162e 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/tooling/check-systems-db-stamp b/tooling/check-systems-db-stamp index 86077d3d5..9aa3226ae 100644 --- a/tooling/check-systems-db-stamp +++ b/tooling/check-systems-db-stamp @@ -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", ], } diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 727b99781..7c7dab9ce 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -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.]` 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...") diff --git a/wiki/economics/architecture_trait_bias.toml b/wiki/economics/architecture_trait_bias.toml new file mode 100644 index 000000000..e16cce66f --- /dev/null +++ b/wiki/economics/architecture_trait_bias.toml @@ -0,0 +1,33 @@ +# ========================================================================== +# Architecture-flavor per-body trait bias (D-232) — sparse hero pins. +# Source of truth for the `atlas_body_trait_bias` baked table. +# +# Bias applies ONLY to hero bodies (~30-40 of ~300). Non-hero bodies run the +# identical draw with no bias (economics + corridor weight + seed). Bias is +# PER-BODY, never per-corridor. +# +# Three kinds: +# pin mandatory template — counts toward K, the body WILL use it. +# (no weight_multiplier_bps) +# boost non-mandatory weight increase, <= 3x. +# weight_multiplier_bps in 10001..30000 +# suppress weight decrease, >= 0.33x, NEVER 0 (preserves second-playthrough +# surprise). weight_multiplier_bps in 3300..9999 +# +# Each entry FK-validates: body_id -> bodies, template_tag -> trait_templates. +# +# SCOPE NOTE (#993 vs #1017): #993 builds the table + baker. The ~30-40 hero +# pins (and the Groombridge GSH clearing_house_landmark, which also needs the +# D-222 named-landmark reservation from #957) are authored in #1017. This file +# ships empty-but-documented; add `[[bias]]` entries in #1017. +# +# Example (commented — uncomment + point at a real body/template in #1017): +# [[bias]] +# body_id = "GJ680d" # a bodies.body_id +# template_tag = "terraced_agrarian" # a trait_templates.tag +# bias_kind = "boost" +# weight_multiplier_bps = 20000 # 2.0x +# note = "why this body leans this way" +# ========================================================================== + +# (no hero pins yet — #1017) diff --git a/wiki/economics/architecture_trait_catalog.toml b/wiki/economics/architecture_trait_catalog.toml new file mode 100644 index 000000000..0d754d257 --- /dev/null +++ b/wiki/economics/architecture_trait_catalog.toml @@ -0,0 +1,84 @@ +# ========================================================================== +# Architecture-flavor trait-template catalog (D-232) +# Source of truth for the `trait_templates` baked table (import_economics.py). +# +# Each template is a HOLISTIC bundle (a coherent relationship between its axes), +# never decomposed per-axis. The draw (D-232, three phases) hard-gates the pool +# by economics, weights it by modifiers + wiki bias, then seed-draws K templates +# per body (K = complexity_tier: Full 5 / Moderate 3 / Minimal 1 / Empty 0). +# +# Channel separation (D-233): economics decides WHAT a building is; this catalog +# decides only HOW it is characterized. `geographic_sector`/corridor is ALWAYS a +# soft weight, never a hard gate ("corridors are tendencies, not borders"). +# +# All numeric eligibility is INTEGER BASIS-POINTS (10000 = 1.0x), never f32 +# (D-010 determinism; save-critical under D-227). +# +# SCOPE NOTE (#993 vs #1005): this file is the BAKE SOURCE. #993 builds the +# table + baker + the bootstrap seed below. The full ~35-template catalog +# (40-45 target; baseline + heritage sub-pool per corridor + a shared +# cross-corridor pool; CI guardrails >=5 eligible per economic class, no +# template >60% pool weight) is authored in #1005 by Miri (cultural/eligibility) +# + Araminta (visual_bundle). The seed entries below are minimal bootstraps to +# prove the pipeline end-to-end — expand/replace them in #1005. +# +# Stanza: [templates.] (tag = stable id, referenced by atlas_body_trait_bias) +# Fields: +# label human label (required) +# cultural_description Miri's cultural meaning +# corridor_pool baseline | heritage | cross_corridor +# geographic_sector corridor this belongs to (omit for cross_corridor) +# bulk_class_gate list of eligible BulkClass (omit = any) — HARD gate +# production_ubiquity_gate list of eligible ProductionUbiquity (omit = any) — HARD gate +# min_prosperity_bps hard gate (basis points) +# base_weight draw weight (basis points; 10000 = 1.0) +# weight_mods {dimension = {value = multiplier_bps}} — SOFT weights +# dimensions: economic_role | dominant_faction | +# founding_age | geographic_sector | morphology_zone +# zone_affinity {DistrictType = weight_bps} — district-dominant draw +# allow_tags / block_tags ObjectTag allow/block lists +# era_scope era applicability (era = maintenance/wear, not tech) +# visual_bundle D-235 bundle (wall/roof/facade/street + colors + fallback parents) +# ========================================================================== + +# --- BOOTSTRAP SEED (#993) — expand/replace in #1005 ----------------------- + +[templates.generic_baseline] +label = "Generic Baseline" +cultural_description = "The cohesive default look of a settlement with no divergent founding heritage — functional, unremarkable, corridor-neutral. The fallback the draw can always reach." +corridor_pool = "baseline" +min_prosperity_bps = 0 +base_weight = 10000 +zone_affinity = { residential = 10000, commercial = 10000, civic = 10000, industrial = 10000 } +allow_tags = ["concrete_wall", "flat_roof", "regular_facade"] +block_tags = [] +era_scope = "any" +visual_bundle = { wall = ["concrete_wall"], roof = ["flat_roof"], facade = ["regular_facade"], street = ["paved"], color_register = "neutral_grey", fallback = {} } + +[templates.industrial_utilitarian] +label = "Industrial Utilitarian" +cultural_description = "Heavy-industry vernacular — large-span sheds, exposed structure, infrastructure-as-aesthetic. Reads as a place that makes things at scale." +corridor_pool = "baseline" +bulk_class_gate = ["BulkSolid", "BulkLiquid"] +min_prosperity_bps = 0 +base_weight = 12000 +weight_mods = { economic_role = { mining = 18000, manufacturing = 16000 }, dominant_faction = { syndic_dominant = 13000 } } +zone_affinity = { industrial = 18000, commercial = 8000 } +allow_tags = ["steel_frame", "corrugated_roof", "gantry"] +block_tags = ["ornamental_cornice"] +era_scope = "any" +visual_bundle = { wall = ["steel_frame", "brick_infill"], roof = ["corrugated_roof"], facade = ["industrial_glazing"], street = ["heavy_haul"], color_register = "oxide_and_steel", fallback = { steel_frame = "generic_wall" } } + +[templates.terraced_agrarian] +label = "Terraced Agrarian" +cultural_description = "A farming-community register — low, clustered dwellings around shared harvest infrastructure, oriented to the land rather than the street. Common where agriculture founds the settlement." +corridor_pool = "baseline" +bulk_class_gate = ["Perishable", "BulkSolid"] +min_prosperity_bps = 0 +base_weight = 11000 +weight_mods = { economic_role = { agricultural = 18000 }, morphology_zone = { lowland_plain = 13000 } } +zone_affinity = { residential = 14000, civic = 9000 } +allow_tags = ["rendered_wall", "pitched_roof", "courtyard"] +block_tags = ["glass_curtain_wall"] +era_scope = "any" +visual_bundle = { wall = ["rendered_wall"], roof = ["pitched_roof", "clay_tile"], facade = ["shuttered_window"], street = ["packed_earth", "cobble"], color_register = "warm_earth", fallback = { rendered_wall = "generic_wall" } }