diff --git a/Makefile b/Makefile index bc30cdfaf..9565ae389 100644 --- a/Makefile +++ b/Makefile @@ -236,8 +236,11 @@ test-tooling: elif [ $$rc -ne 0 ]; then \ echo " FAIL: planet_simulation determinism drift (exit $$rc)"; exit $$rc; \ fi - @echo " [test-tooling] import_economics --dry-run (committed DB)..." + @echo " [test-tooling] economy_import.traits validation units (T-995/PR #173 H2)..." @mkdir -p .cache + @python3 tooling/economy-db/test_traits.py 2> .cache/test-tooling-traits.log || \ + { echo " FAIL: traits validation units — log follows:"; cat .cache/test-tooling-traits.log; exit 1; } + @echo " [test-tooling] import_economics --dry-run (committed DB)..." @rc=0; python3 tooling/economy-db/import_economics.py --dry-run \ > .cache/test-tooling-dryrun.log 2>&1 || rc=$$?; \ if [ $$rc -eq 2 ]; then \ diff --git a/docs/gemma-architecture-flavor-methodology.md b/docs/gemma-architecture-flavor-methodology.md new file mode 100644 index 000000000..25dc5b4e0 --- /dev/null +++ b/docs/gemma-architecture-flavor-methodology.md @@ -0,0 +1,330 @@ +# Gemma Architecture-Flavor Methodology (D-232, T-992) + +**Status:** design methodology, not yet implemented. Analogous in structure and +intent to [`docs/gemma-naming-methodology.md`](gemma-naming-methodology.md) +(D-223) — the retired pipeline that named atlas features from wiki prose — but +the two are solving different problems. Naming filled thousands of empty +`name` fields at generation time. This pass proposes a **small number of new +rows in an authored, frozen catalog**, once, with a human in the loop before +anything ships. There is no runtime Gemma call anywhere in the architecture- +flavor draw (D-232 phase 1-3): `trait_selection` is a deterministic +`SeedChain`-seeded pick from the catalog. Gemma's only role is helping grow the +catalog file between authoring passes. + +--- + +## 1. Scope — bounded corpus-distillation, not per-body generation + +D-232 round 3 retired the round-2 per-body Gemma-array model in favor of a +shared trait-template catalog. The catalog's **content** is split into two +tracks (T-977 scope note, 2026-05-26): + +- **Core hand-authored set** (T-1005, shipped) — Miri (cultural meaning + + eligibility) and Araminta (visual_bundle) authored **28 templates** directly: + 10 `cross_corridor`, 8 `baseline`, 10 `heritage` (see the corridor-pool + breakdown in `wiki/economics/architecture_trait_catalog.toml`). This sits + within D-232's stated floor (~25-35) but below its target (40-45). +- **This pass** — a *bounded* Gemma corpus-distillation run: **one read of all + wiki prose → propose candidate templates → human-gated review**, growing the + catalog from 28 toward the 40-45 target. It runs **after** the core set + exists and reads it as context (see §4.1). + +**What this explicitly is not:** + +- Not per-body open-vocabulary generation. There is no Gemma call in the + skeleton-generation or chunk-fill path. The round-2 model (a Gemma call per + body, producing a bespoke culture) is the thing D-232 retired. +- Not a recurring or CI-triggered job. It runs manually, whenever the catalog + is judged to need growth (new corridor, new archetype, a corpus content + addition worth distilling) — not on every wiki edit. +- Not a source of any numeric field. See §5. + +**Superseded stage-2 schema note:** the original T-977/T-992 ticket text (round +2) described a stage-2 output of `{tag, allow, block, weight, +trauma_decay_rate}`. Neither `weight` nor `trauma_decay_rate` survive to D-232 +round 3 — there is no per-template decay rate anywhere in the shipped schema +or `traits.py`, and Gemma never emits a weight (§5-§6 explain why). Treat any +reference to `trauma_decay_rate` outside this note as stale round-2 prose. + +--- + +## 2. Model & runtime — reuse the naming pipeline's binary split + +`docs/architecture/atlas-naming-pipeline.md` §2 documents the split the retired +naming pipeline used, and the same reasoning applies here, more strongly: + +- **Two binaries exist.** The in-repo `sr-voice serve --model --stdio` + (`server/sr-voice/`) is the **game runtime** — Gemma 2, small context + (`--ctx-size`, historically 1024 tokens for naming), tuned for low-latency + in-game NPC dialogue, no network port (Gemma 2 T&C compliance). The naming + pipeline instead used **`sr-voice-tooling`** — a separate binary built + against Gemma 4 (GGUF), compiled for ROCm/HIP (`gfx1201`), living outside the + repo (`~/Projects/settled-reach/binaries/sr-voice-tooling`) and run inside + the `reach-build` distrobox. The split exists because tooling work trades + latency for richer output and a bigger context window — exactly the + trade this pass wants. +- **Recommendation: reuse `sr-voice-tooling` / Gemma 4, not the in-game + runtime.** This pass needs to read a full `index.md` page (several hundred + to ~1000+ words — see §3), not the naming pipeline's hand-compressed 30-45 + word GTTR hook. Gemma 2's ~1024-token context was already tight for the + *compressed* naming case; reading full system pages needs the larger + context the tooling binary provides. +- **Protocol: identical stdio JSONL, unchanged.** Both binaries speak the same + newline-delimited JSON contract (`server/sr-voice/src/stdio.rs`, + `docs/architecture/atlas-naming-pipeline.md` §2): one request per line, + `{"prompt": "...", "seed": N}`, one response per line, `{"text": "..."}` or + `{"error": "..."}`. No new protocol work is needed to point a new script at + either binary. +- **Call volume is much smaller, so KV-cache bleed matters less — but the fix + is the same if it shows up.** The naming pipeline processed ~2,400 bodies × + 5 feature types and needed `--refresh`-driven subprocess restarts to stop + a long-lived model echoing earlier completions. This pass makes on the + order of ~300 stage-1 calls (one per system) plus one stage-2 call per + surfaced candidate (expected: a small fraction of 300, since most systems + should say "no distinct tradition" — see §4.1). If output diversity + degrades across a long run, apply the same lesson: restart the subprocess + periodically (e.g. every system boundary) to flush context. +- **Few-shot over instructions carries over unchanged.** The naming + pipeline's model-fit lesson — *Gemma is far better at pattern completion + than instruction-following* — applies to both stages here. Prompts should + be worked examples ("system prose → verdict + name", "flagged prose → + structured JSON"), not bare instructions. + +--- + +## 3. Source material — the wiki tree, and the Q-107 assumption + +**Stage 1 reads `wiki/star-systems/*/index.md`** — the system-level wiki page +(System Profile, cultural/economic prose, Faction Notes, Silence Topic, +Narrative Hook). All 302 systems have one. + +> **This is an explicit assumption, not a permanent contract.** Q-107 (open — +> wiki → Atlas content-set consolidation) proposes moving all wiki content +> (pages, heightmaps, markers, architecture-flavor source) into one +> consolidated, first-class content tree. If Q-107 resolves and the content +> moves, **only stage 1's file glob needs updating** — the extraction prompt, +> the structured-output schema, and the human-review gate are all +> location-invariant, per D-232's own note that "the generator-facing tables +> are invariant to [source location]." + +Companion `gttr.md` / per-body `gttr-.md` files (present for ~301 of 302 +systems) carry additional narrative color and were the naming pipeline's +secondary source for register selection. **This pass scopes stage 1 to +`index.md` only** — per the T-992 refinement. Folding `gttr*.md` in as a +secondary source is a plausible future expansion, not required for the first +pass. + +Q-107's audit note (2026-06-12) already flags the kind of prose this pass +exists to consume — **distinctive material/craft traditions that currently +exist only as prose and have no catalog counterpart**: + +| System | index.md location | Tradition | +|---|---|---| +| GJ-4053 (Dzimbahwe) | lines 22-26 | granite dry-stack, mortarless masonry | +| GJ-1248 | line 30 | peat-wood carving | +| GJ-285 | lines 28-38 | basalt crater farms | +| GJ-588 | lines 30-34 | structural timber | + +None of these four appear as a distinct template in the shipped 28 (see the +worked example, §8). + +--- + +## 4. Two-stage flow + +### 4.1 Stage 1 — candidate surfacing (per-system read → shortlist) + +This differs from the naming pipeline's stage 1 (pick one of N predefined +cultural registers). Here Gemma performs **signal detection**, not +classification: for each system, it reads the `index.md` prose plus a compact +context block listing the **existing catalog's tag + label + +cultural_description** (28 entries — enough to ground "already covered" but +short enough to fit context), and is asked one question — *does this system's +prose describe a coherent, distinctive architectural/material tradition not +already represented in the catalog, and if so, name it and quote the +grounding passage.* + +The expected answer for most systems is **no** — most systems should draw from +an existing corridor baseline or heritage template, not spawn their own. A +system earning a "yes" needs a real, holistic, catalog-worthy signal (the +Dzimbahwe stone tradition — not "this system likes blue paint"). + +Output: a shortlist of `(system_id, candidate_name, grounding_quote)` tuples, +much smaller than the 302-system corpus. + +### 4.2 Stage 2 — structured proposal (shortlist → schema) + +For each stage-1 hit, a second call turns the flagged prose into the +structured proposal (§5). Candidate `allow_tags`/`block_tags` are drawn **only +from the catalog's existing shared material palette** (the `ObjectTag` +vocabulary enumerated in the catalog file's header comment — `walls`, `roofs`, +`facade`, `street` lists) so proposals stay pre-constrained to compatible +vocabulary. Minting a genuinely new `ObjectTag` is a separate, human-only +vocabulary-authoring step (T-977(d), Miri+Araminta co-maintained) — Gemma +proposes combinations of existing tags, never new tags. + +--- + +## 5. Structured output schema — text fields only + +```json +{ + "tag": "granite_drystack_vernacular", + "label": "Granite Dry-Stack Vernacular", + "cultural_description": "...", + "candidate_allow_tags": ["stone_wall", "..."], + "candidate_block_tags": ["glass_curtain_wall", "..."], + "source_system": "GJ-4053", + "grounding_quote": "..." +} +``` + +Gemma **never** sets a numeric or eligibility field. This is not a scoping +choice made for simplicity — it's forced by the shape of the guardrails: the +catalog-wide CI checks (`V-TT-01`: ≥5 eligible templates per `BulkClass` +post-gate; `V-TT-02`: no template >60% of its eligible pool's weight, +`tooling/economy-db/economy_import/traits.py`) are properties of the **whole +catalog**, not of any one template in isolation. A per-template extraction +pass, looking at one system's prose, has no way to know whether adding this +candidate at `base_weight = 9000` would push some `BulkClass`'s weighted pool +over the 60% line — only a human with the whole catalog in view can safely +assign eligibility gates and weights. This is the same channel-separation +principle D-232/D-233 already establish for economics vs. culture (economics +hard-gates *what* a building is; the catalog only *weights* how it's +characterized), extended one level further: + +| Field(s) | Set by | Why | +|---|---|---| +| `tag`, `label`, `cultural_description` | Gemma, human-reviewed | text extraction is the point of this pass | +| candidate `allow_tags` / `block_tags` | Gemma, human-corrected | pre-constrained to the existing `ObjectTag` palette | +| `corridor_pool`, `geographic_sector` | Miri | needs whole-pool view (baseline vs. heritage; D-237 heritage-value alignment) | +| `bulk_class_gate`, `production_ubiquity_gate`, `min_prosperity_bps` | Miri | hard-gate eligibility — guardrail-sensitive | +| `base_weight`, `weight_mods` | Miri (Nigel spot-checks V-TT-02) | pool-weight math the guardrails enforce | +| `zone_affinity` | Miri | district fit, informed by `cultural_description` | +| `era_scope` | Miri | maintenance/wear scope (D-217), not a tech ladder | +| `visual_bundle` | Araminta | D-235 fallback-hierarchy authoring | + +**Field-naming note:** D-232's decision text names these fields `allow` / +`block`; the shipped catalog TOML and `traits.py` use `allow_tags` / +`block_tags` (T-1005 implementation). This doc uses the shipped names +throughout, since that's the schema any accepted proposal has to match — the +decision record's prose is the one out of step here and could use a tidy-up +note pointing at the implemented names. + +--- + +## 6. The human-review gate + +**Reviewers:** Miri (cultural meaning, eligibility, weights — same role as +T-1005), Araminta (`visual_bundle`), Nigel (spot-checks CI guardrail math on +request; not required per-proposal since the importer re-validates +automatically at bake time — see §7). + +**Acceptance criteria**, applied to every candidate before it becomes a +`[templates.*]` stanza: + +1. **Genuine distinctiveness** — `cultural_description` reads as a coherent, + holistic relationship between wall/roof/facade/street (never an + axis-scramble), and isn't a restatement of an existing template under a + new name. +2. **Dedup against the existing catalog** — check both the `tag` (mechanical, + unique key — `traits.py` will already reject a collision) and the + `label`/`cultural_description` (semantic — a near-duplicate cultural voice + under a different label doesn't earn a second template; fold the + distinguishing detail into an existing template's `allow_tags` instead, or + reject outright). +3. **Vocabulary compatibility** — candidate `allow_tags`/`block_tags` + reference only tags already in the shared `ObjectTag` palette; a genuinely + new material/facade/roof tag routes to the separate vocabulary-authoring + step, not this pass. +4. **Corridor fit** — Miri assigns `corridor_pool` + `geographic_sector`; + "corridors are tendencies, not borders" (D-232) — a candidate doesn't need + to be exclusive to one corridor to be accepted as a heritage sub-pool + entry. +5. **CI guardrails as a post-authoring gate, not a pass/fail on Gemma's + output.** Once Miri has assigned `base_weight`/`weight_mods`/gates, + `make regen-db` re-runs `populate_trait_templates()` + (`tooling/economy-db/economy_import/traits.py`), which re-validates + `V-TT-01` and `V-TT-02` across the **whole** catalog, not just the new + entry. A proposal can clear 1-4 and still get rebalanced or rejected at + bake time if it happens to push a `BulkClass` pool over the 60% line — + that's expected, not a process failure, and it's exactly why Gemma never + sets these numbers itself (§5). + +Nothing is committed to `architecture_trait_catalog.toml` until it clears 1-4 +**and** a `make regen-db` / `make check-systems-db` dry run comes back clean. + +--- + +## 7. Flow into the catalog + +1. Stage 1 + 2 produce N candidate proposals (one JSON object per stage-1 hit + that survived stage 2). +2. Miri reviews against §6's criteria 1-4; rejects, merges, or accepts each. +3. Accepted proposals become new `[templates.]` TOML stanzas, hand-added + to `wiki/economics/architecture_trait_catalog.toml` in the matching section + (the file's existing sections: **A** cross-corridor pool, **B** + per-corridor baseline, **C** heritage sub-pools) with Miri's numeric + fields filled in. +4. Araminta adds the matching `visual_bundle`, including the D-235 fallback + parent for every new specific tag (a new template ships with a working + generic-parent fallback from day one, same as the shipped 28). +5. `make regen-db` bakes the change via `import_economics.py` → + `traits.py:populate_trait_templates()`; `V-TT-01`/`V-TT-02` re-run + automatically (§6.5). `make check-systems-db` confirms the meta stamp. +6. Commit the TOML change and the regenerated `systems.db` together — the + ordinary asset-pipeline rule (`.claude/rules/asset-pipeline.md`): edit + sources, regen, commit both. + +No path lets this pass write to `systems.db` directly. The golden rule is +unchanged: edit sources, never the DB. + +--- + +## 8. Worked example — GJ-4053 (Dzimbahwe) + +- **Stage 1** flags GJ-4053: the prose describes a 400-year granite dry-stack + tradition ("great house of stone... dry-stacked without mortar") and a + distinct sculptural tradition, neither represented by an existing template. + The closest existing templates — `civic_monumental` (`stone_wall, + vaulted_roof, colonnade`, institutional/ceremonial) and the south_reach + heritage pool (`iberian_hacienda`, `atlantic_creole_maritime` — Iberian and + Afro-Atlantic, not southern-African stone) — don't fit. +- **Stage 2** produces a proposal along these lines: `tag: + south_african_stone_vernacular`, `label: "Southern African Stone + Vernacular"`, `cultural_description` drawn from the "great house of stone" + language, `candidate_allow_tags: [stone_wall, ...]`, `candidate_block_tags: + [glass_curtain_wall, ...]`, `source_system: GJ-4053`, plus the grounding + quote. +- **Miri reviews:** distinct from the existing south_reach heritage pool + (criterion 2 passes) — accepts as a new south_reach heritage sub-pool entry, + assigns `min_prosperity_bps`, `weight_mods` (e.g. `founding_age.ancient`), + `zone_affinity` (Residential-leaning, matching the domestic/sculptural + framing). +- **Araminta** authors the `visual_bundle` (wall = `stone_wall`, roof/facade + choices, fallback chain to `generic_wall`/`generic_roof`). + +The other three systems Q-107's audit note flags are useful for calibrating +**stage 1's expected "no" rate**, not as three more automatic templates: GJ-285 +(basalt crater farms) and GJ-1248 (peat-wood carving / Moorlesen) are +agricultural and craft-object traditions, not building-architecture ones — a +correctly-run stage 1 would surface them as signal for the *materials/crafts* +side of Q-107's consolidation (feeding `atlas_body_trait_bias` hero-pin +authoring against an **existing** template), not propose a new +`[templates.*]` stanza. GJ-588 (structural timber) is architecture-adjacent, +but its `outer north_reach` setting already has a timber-forward baseline +(`north_anglo_frontier`: `timber_wall`, `pitched_roof`, `regular_facade`) — the +expected stage-1 outcome there is "no distinct tradition, corridor baseline +already covers it," which is the **correct, common** result, not a missed +opportunity. Only GJ-4053's stone tradition clears the bar because no existing +template — baseline or heritage, any corridor — matches it. + +--- + +## 9. Open follow-ups + +- **Q-107** (open) — source-location consolidation. If it resolves, update + §3's file glob; nothing else in this doc changes. +- **Filing the actual pass** — this doc defines the methodology only (T-992's + scope). No ticket yet schedules an execution run; file one under T-977 when + the catalog is judged to need growth toward the 40-45 target. diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index 51e30b2f6..85c9ae8e4 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1605,7 +1605,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - `footprint: TileRect` — integer tile-space rect within the 128-tile block (D-010 integer-only). - `extent: FloorExtent` — floor/basement extent; **resolves Q-104** (below). - `entry_class: BuildingEntryClass` — physical access character: `Public | Commercial | Restricted | BreachOnly`. **Named `BuildingEntryClass`, not "access tier"**, to avoid colliding with D-028's relational dialogue layers. Derived from `zone_type × layout_mode (D-096) × prosperity (D-197)`: Commission-Grid → formal/logged/corporate-or-authority credentials; Organic → social/reputation/unlogged credentials. U-curve degrade: low prosperity on a normally-Commercial zone → `BreachOnly` (derelict). - - `flavor_ref: ArchitectureFlavorRef { flavor_index: u8 }` — index into the body's flavor profile (D-232); deterministic `(seed + zone_type) → index`; no rolling-economy read. (The selection mechanism is D-232's weighted `allow`/`block` filter; the tag records the chosen index for Phase-6 to read cold.) + - `flavor_ref: ArchitectureFlavorRef` — which trait template characterizes this building (D-232); no rolling-economy read. *(Amended 2026-07-08, T-994/T-1003: now an enum, not a bare index — `InVocabulary(u8)` (index into the body's closed `trait_selection`, resolved via the phase-2 district-dominant pick) `| Swerve(tag)` (the rare out-of-vocabulary deviation draw, or the sparsity escape hatch). The original `(seed + zone_type) → index` mechanism was the pre-three-phase-draw stopgap.)* - `era: ConstructionEra` (`Founding | Established | Modern | Derelict`) + `era_cause: EraCause` — feeds ZonePalette modifier axis C (D-101) and sets the D-217 condition floor. Derived from `founding_age_years + prosperity_baseline + seed`; a body carries mixed-era buildings (founding period anchors the distribution; seed scatters outliers). - `initial_condition: TileCondition` — frozen-amber snapshot from `prosperity_baseline` (D-197/D-217). The rolling condition overlay (D-198) paints *over* this; it never mutates the tag. - **`FloorExtent { base_floor: i8, floor_count: u8, heights: FloorHeightProfile }`** where `FloorHeightProfile = Uniform(u8) | Variable(Vec)`. **Q-104 resolution (the D-110 ↔ D-227 bridge):** two pure functions — `floor_at_voxel_z(z) -> Option` and `voxel_range_for_floor(f) -> Option<(i32,i32)>` — map D-110 floor-index addressing onto D-227 physical voxel-z. Default `Uniform(3)` (3 voxels ≈ 3 m/floor, per Jeroen); a cathedral/hangar is `Uniform(10)`; a mixed-use stack is `Variable([5,3,3,3,3])`. The `Variable` branch carries per-floor memory only when floors actually differ. @@ -1660,6 +1660,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Catalog population:** ~25–35 templates at floor, 40–45 target. Core hand-curated (Miri authors cultural meaning + eligibility; Araminta authors the matching `visual_bundle`), then a *bounded* Gemma corpus-distillation pass (one read of all wiki, propose new templates, human-gated) — not per-body generation. - **Channel separation from D-233 (held):** D-233 decides **what** a building is (vocabulary, coverage); D-232 decides **how** it is characterized. Same inputs (e.g. `bulk_class`) used non-conflictingly — D-233 as hard function gate, D-232 as soft cultural weight. They compose at fill. - **Amended 2026-05-31 ([D-237](#d-237) — authored specialization layer):** the template draw now reads an authored `cultural_specialization` (new column on `system_economy`) that **selects/biases the template pool** when a system's cultural character diverges from its corridor baseline. The field carries two value sub-types in one column — *activity/character* values (`agrarian`, `industrial_heritage`, `institutional`, `scholarly`, etc.) and *heritage* values (`scottish`, `vietnamese`, `zulu`, `french_provencal`, etc.); heritage values take precedence where present. This is a Phase-4 correctness fix, not just flavor: without it, a system whose founding heritage diverges from the corridor (e.g. Vietnamese-founded Dài Lộ in the east_reach Korean/Japanese corridor) draws the wrong cultural templates. `NULL` = use the existing corridor-pool algorithm unchanged. Consistent with the held channel separation — `cultural_specialization` is a D-232 cultural-weight input, never a D-233 function gate. Singular landmarks (e.g. Groombridge's GSH within a `financial_hub` district) are expressed via D-222 multi-block reservation + an `atlas_body_trait_bias` hero pin, not the system-level field. +- **Amended 2026-07-08 (T-1003 — deviation/swerve system implemented, driver inputs pinned):** the two opposed drivers now map to real fields (`server/src/atlas/trait_swerve.rs`): *foreign-import* scaled up by `WorldTier::Epicenter`/`Passage` (transit), `dominant_faction = "mixed"` (cosmopolitanism), and road/rail-graph node degree (centrality); *heritage-callback* scaled up by road-graph isolation (degree ≤ 1), remote tier (Waypoint/Backwater), and `founding_age_years` bands (conservatism). Rates are integer bps: base **100 bps/building** per driver, hard cap **300 bps** — placeholder constants pending Nigel/Burnelli calibration; a `dist_ly`-percentile remoteness input is deferred until the read-set carries it. The wildcard result is recorded as `ArchitectureFlavorRef::Swerve(tag)` (out-of-vocabulary by construction); pools are hard-gate-eligible only (cultural-only rule held). The sparsity escape hatch is implemented as the same mechanism, necessity-triggered at the phase-2 district-dominant pick (deterministic max-weight, no dice). The passive past-vogue holdover stays on the D-217 condition layer, as decided. +- **Amended 2026-07-08 (T-994 — phase-2 "district" pinned to the D-243 tier):** the word "district" in the phase-2 *district-dominant* draw means the **D-243 2 048 m District cell** (4 quarters), **not** the 512 m Quarter — this record was written after D-222 renamed the 512 m unit to Quarter, but the pin was never made explicit and the shipped code had no District-tier representation at all (caught in the 2026-07-07 /whats-next refinement; the "one template per district, applied whole" composition rule reads at 2 048 m). Implementation (T-994): the dominant template per `(DistrictType)` is pre-resolved at L3→L4 dispatch time, seeded by `(SeedChain::for_body, district-cell position, district type)` — so any settlements whose quarters share a District cell independently derive the identical dominant template with no cross-settlement coordination, and `assign_block_tags` is a pure lookup. Phase 1's body K-draw is likewise seeded from `SeedChain::for_body` (never the per-settlement chain), preserving the closed-vocabulary invariant. - **Supersedes (architecture/generator domain only):** **D-104** (`HeritageGrammarOverlay` + per-root data → the catalog + `allow`/`block`), **D-105** (heritage-root→informal-zone lookup → flavor-filtered selection; the three zone *types* survive), **D-101 modifier axis A** (`HeritageRoot` → catalog draw; axes B/C + faction/climate/condition/season unchanged), **D-107** (per-root trauma decay → per-template/condition; the "trauma intensifies culture" principle survives). Also retires the round-2 `atlas_body_culture` / `atlas_body_culture_era` tables. - **Storage:** `trait_templates` (the catalog) + `atlas_body_trait_bias` (sparse, hero bodies). The per-body draw result lives as `trait_selection: Vec` on the skeleton — **re-derivable** from catalog + economics + bias + `SeedChain`, no Gemma in the hot path. `CityGenerationContext` carries `trait_selection` + `morphology_zone` (replacing the round-2 `flavor_profile`; D-199 amend). - **Source-location deferred:** the human-authored *source* home (catalog file + `bias.json`) rides on **Q-107** (wiki → Atlas content-set consolidation). The generator-facing tables are invariant to it, so the fill seam is unblocked regardless. @@ -1713,6 +1715,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser - **Exterior vocabulary** (extends D-228's FloorMaterial axis): `WallMaterial` (stone_cut/stone_rough/fired_brick/clay_render/heavy_timber/pile_timber/timber_frame/reinforced_concrete/corrugated_metal/steel_panel/composite_panel/precision_glass/smart_facade), `RoofForm` (pitched_steep/pitched_shallow/flat/composite_curved/corrugate_shed/dome), `FacadeRhythm` (bay_window/grid_panel/solid_punched/arcade/open_front/blind_wall), `StreetSurface` (cobble/packed_earth/poured_slab/elevated_boardwalk/dock_plank/rail_embedded — derives from district + density + template, the same filter as buildings, for a consistent world). **Color** is template-bounded (cultural palette cue), seed-selected within range — always within the template's register. - **Fallback hierarchy (the incremental-content mechanism, D-232).** Every specific texture/material token declares a **generic parent it degrades to**: `temple_wall_wood` → generic `wood_wall` placeholder until the specific asset ships, then it **upgrades in place**. The *logical* token a building uses is fixed at generation (deterministic, frozen); only its rendered *fidelity* sharpens as themes/textures are patched in. This is where incremental content lives — an asset-resolution concern, not a generation one — so no catalog versioning is needed. (`era_fallback` from the round-2 draft survives only in this generalized form — a fallback chain, not a tech ladder.) - Worked example: a fjord port (template `fjord_maritime` → stone base + steep roof + zero-lot + solid-punched + cobble) and a delta port (template `river_delta` → pile-timber + shallow roof + arcade + boardwalk) share density and zone types yet read as completely different cities. + - **Amended 2026-07-07 (T-995 — ObjectTag vocabulary ratified, resolves Q-049):** the `WallMaterial`/`RoofForm`/`FacadeRhythm`/`StreetSurface` example token lists above never shipped. The canonical ObjectTag vocabulary is the shipped 28-template `architecture_trait_catalog.toml` (T-1005) material palette, now formalized as a machine-readable registry at `wiki/economics/object_tag_vocabulary.toml`, importer-validated by `economy_import/traits.py` (V-TT-03 existence, V-TT-04 fallback-graph). The ratified tags, by axis: **wall** (10) — `concrete_wall stone_wall brick_wall rendered_wall stucco_wall timber_wall rammed_earth_wall steel_frame glass_curtain_wall composite_panel`; **roof** (7) — `flat_roof pitched_roof corrugated_roof clay_tile_roof terraced_roof vaulted_roof green_roof`; **facade** (8) — `regular_facade ornamental_facade industrial_glazing arcade_facade shuttered_facade screen_facade colonnade lattice_screen`; **street** (7) — `paved cobble packed_earth canal_way elevated_walkway heavy_haul boardwalk`; plus the four fallback-terminal **generic** placeholders — `generic_wall generic_roof generic_facade generic_street`. Every specific tag's registry entry declares its generic fallback parent directly (the per-template `fallback` maps in the catalog remain illustrative/non-exhaustive documentation, not the validated source). - **Formally retires** Araminta's generator-architecture Round-4 `D-READY-9` ten-root heritage-modifier TOML system — superseded by the D-232 trait-template catalog (body-specific draw, allow/block-bounded, no root taxonomy). - **Rationale:** The template is the differentiator, but it needs a concrete visual vocabulary to act on, filtered consistently across walls, roofs, facades, *and* streets or the world reads incoherent. The "old quarter vs new development" texture comes from **wear + occasional past-vogue holdover** (D-232), not from a material-technology ladder — because in a post-space-travel setting there is no such ladder. The fallback hierarchy lets the logical world be complete and frozen at launch while the art catches up over patches. - **Implementation:** Phase 4+ (the token logic + Atlas-level data); textured render + the bulk of the theme library are Phase 5+ and post-launch, behind the fallback chain. `ObjectTag`/material vocabulary is Miri + Araminta co-maintained. 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/Cargo.lock b/server/Cargo.lock index 36c81a93a..1b549affb 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -534,9 +534,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/server/data/systems.db b/server/data/systems.db index 649a1df42..aaf68bf8c 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index be7358e6d..088196f27 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -23,9 +23,18 @@ //! - `founding_orientation` — Cardinal stub (attractor matching deferred) //! - `world_tier` — Waypoint default (`system_economy.economic_tier` derivation deferred) //! - `morphology_zone` — AlluvialPlain default (D-228 deferred) -//! - `trait_selection` — empty (trait catalog #1005 deferred) +//! - `trait_selection` — empty (the D-232 phase-1 K-draw runs at L3→L4 dispatch +//! time, T-994 — see `atlas::plugin::build_skeleton_work_item` — this reader +//! has no body-wide coverage view) //! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked) //! - `dominant_production_ubiquity` — Common default (#982 design-blocked) +//! - `body_district_type_mix` / `settlement_district_pos` / `district_dominant_by_type` +//! — T-994 additions, all require body-wide aggregation this per-city reader +//! doesn't have; overridden at L3→L4 dispatch time +//! +//! **Field read, not deferred (T-994 addition):** `geographic_sector` — from +//! `star_systems.geographic_sector` (via `bodies.system_id`). A single +//! per-system value, unlike the four fields above. //! //! **Prosperity derivation (D-197, partial — integer basis points, D-010):** //! `prosperity_baseline_bps = clamp(role_base_bps + pop_bonus_bps + noise_bps, 1000, 9500)` @@ -38,6 +47,7 @@ //! Read-only `systems.db` access follows the same pattern as //! [`crate::atlas::source_resolver::BodySourceResolver`]. +use std::collections::BTreeMap; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -90,6 +100,10 @@ pub struct CityEconomicReadSet { pub founding_age_years: u32, /// D-199 field 6. pub settlement_class: SettlementClass, + /// Not a D-199 field — T-994 (D-232) addition. This city's system corridor + /// (`star_systems.geographic_sector`). `None` if unset. A soft weight on the + /// trait-template draw only, never a gate. + pub geographic_sector: Option, } // --------------------------------------------------------------------------- @@ -136,26 +150,31 @@ impl CityContextReader { // atlas_city_names carries economic_role, population, settlement_class. // bodies carries founding_age_years (via the city's body_id). // system_factions carries dominant_faction (via bodies.system_id). + // star_systems carries geographic_sector (via bodies.system_id, T-994). // // settlement_class is nullable (NULL until attractor placement runs). // dominant_faction is nullable (some systems have no faction data). // founding_age_years is nullable (uninhabited bodies). + // geographic_sector is nullable (some systems have no recorded corridor). let row: rusqlite::Result<( Option, // acn.economic_role i64, // acn.population Option, // acn.settlement_class Option, // b.founding_age_years Option, // sf.dominant_faction + Option, // ss.geographic_sector )> = conn.query_row( "SELECT acn.economic_role, acn.population, acn.settlement_class, b.founding_age_years, - sf.dominant_faction + sf.dominant_faction, + ss.geographic_sector FROM atlas_city_names AS acn JOIN bodies AS b ON b.body_id = acn.body_id LEFT JOIN system_factions AS sf ON sf.system_id = b.system_id + LEFT JOIN star_systems AS ss ON ss.system_id = b.system_id WHERE acn.id = ?1", [city_id as i64], |row| { @@ -165,6 +184,7 @@ impl CityContextReader { row.get(2)?, row.get(3)?, row.get(4)?, + row.get(5)?, )) }, ); @@ -183,6 +203,7 @@ impl CityContextReader { settlement_class_opt, founding_age_opt, dominant_faction, + geographic_sector, ) = row; let economic_role = economic_role_opt.ok_or(CityContextReadError::MissingField { @@ -209,6 +230,7 @@ impl CityContextReader { dominant_faction, founding_age_years, settlement_class, + geographic_sector, }) } @@ -486,12 +508,33 @@ pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGener world_tier: WorldTier::Waypoint, // morphology_zone: Layer-1 output, D-228 deferred. morphology_zone: MorphologyZone::AlluvialPlain, - // trait_selection: trait catalog #1005 deferred. + // trait_selection: the D-232 phase-1 K-draw runs at L3→L4 dispatch time + // (build_skeleton_work_item, T-994) — this reader has no body-wide view + // (coverage aggregate, catalog reader) so it is left empty here and + // overridden by the caller, same pattern as morphology_zone/political_archetype. trait_selection: Vec::new(), // dominant_bulk_class: dominant-commodity derivation #982 design-blocked. dominant_bulk_class: BulkClass::NonPhysical, // dominant_production_ubiquity: same blocker as above. dominant_production_ubiquity: ProductionUbiquity::Common, + + // ── T-994 additions ──────────────────────────────────────────────── + // geographic_sector: the one D-199-style read-set field this ticket adds + // (star_systems.geographic_sector via the city's system_id) — real value, + // not a stub. + geographic_sector: rs.geographic_sector, + // body_district_type_mix / settlement_district_pos / district_dominant_by_type: + // all three require body-wide aggregation (every settlement's district + // mix, the settlement's world position) that this per-city reader doesn't + // have. Overridden at L3→L4 dispatch time (build_skeleton_work_item). + body_district_type_mix: Vec::new(), + settlement_district_pos: (0, 0), + district_dominant_by_type: BTreeMap::new(), + // ── T-1003 additions — same dispatch-time override story as above: + // driver rates need the road graph, pools need the resolved catalog. + swerve_rates_bps: (0, 0), + swerve_foreign_pool: Vec::new(), + swerve_heritage_pool: Vec::new(), } } @@ -539,7 +582,7 @@ mod tests { let conn = Connection::open(&path).expect("create db"); conn.execute_batch( - "CREATE TABLE star_systems (system_id TEXT PRIMARY KEY); + "CREATE TABLE star_systems (system_id TEXT PRIMARY KEY, geographic_sector TEXT); CREATE TABLE bodies ( body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, @@ -734,6 +777,37 @@ mod tests { assert_eq!(rs.founding_age_years, 450); // Field 6 — settlement_class assert!(matches!(rs.settlement_class, SettlementClass::NameLocked)); + // geographic_sector: absent in this fixture (no UPDATE below) -> None. + assert_eq!(rs.geographic_sector, None); + } + + // ─── geographic_sector (T-994) ──────────────────────────────────────────── + + #[test] + fn read_set_threads_geographic_sector_from_star_systems() { + let (db, city_id) = make_test_db( + "GJ8d", + "GJ-8", + "financial", + 1_000_000, + Some("NameLocked"), + Some(100), + None, + ); + let conn = Connection::open(&db).expect("reopen"); + conn.execute( + "UPDATE star_systems SET geographic_sector = 'east_reach' WHERE system_id = 'GJ-8'", + [], + ) + .expect("set sector"); + drop(conn); + + let reader = CityContextReader::open(&db).expect("open"); + let rs = reader.read_set(city_id as u64, 42).expect("read set"); + assert_eq!(rs.geographic_sector.as_deref(), Some("east_reach")); + + let ctx = context_from_read_set(city_id as u64, rs); + assert_eq!(ctx.geographic_sector.as_deref(), Some("east_reach")); } #[test] diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index ef58c3382..78479da6d 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -578,6 +578,13 @@ mod tests { trait_selection: vec![], dominant_bulk_class: BulkClass::NonPhysical, dominant_production_ubiquity: ProductionUbiquity::Common, + geographic_sector: None, + body_district_type_mix: vec![], + settlement_district_pos: (0, 0), + district_dominant_by_type: Default::default(), + swerve_rates_bps: (0, 0), + swerve_foreign_pool: vec![], + swerve_heritage_pool: vec![], }), quarter_id: city_id * 10, chain: SeedChain::root(42 + city_id), @@ -710,7 +717,7 @@ mod tests { heights: FloorHeightProfile::Uniform(3), }, entry_class: BuildingEntryClass::Public, - flavor_ref: ArchitectureFlavorRef { flavor_index: 0 }, + flavor_ref: ArchitectureFlavorRef::InVocabulary(0), era: ConstructionEra::Founding, era_cause: EraCause::Original, initial_condition: TileCondition::Intact, diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 8bec7dcf2..220c05628 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -31,6 +31,9 @@ pub mod skeleton_gen; pub mod source_resolver; pub mod subbiome; pub mod tile_condition; +pub mod trait_catalog_reader; +pub mod trait_draw; +pub mod trait_swerve; pub mod voxel; pub use plugin::GenerationPlugin; diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index bc7a39c4f..f307ffcfa 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -19,15 +19,27 @@ use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY}; use crate::atlas::city_context_reader::{ context_from_read_set, CityContextReaderResource, CityEconomicReadSet, }; +use crate::atlas::district_mix::{compute_district_mix, population_tier}; use crate::atlas::district_profile::{DistrictPos, DistrictProfile}; use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue}; use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus}; use crate::atlas::road_graph::{RoadGraph, RoadNode}; use crate::atlas::scale; +use crate::atlas::skeleton_gen::derive_complexity; use crate::atlas::source_resolver::BodySourceResolverResource; +use crate::atlas::trait_catalog_reader::{TraitBias, TraitCatalogReaderResource, TraitTemplate}; +use crate::atlas::trait_draw::{ + complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type, + VocabularyDrawInputs, +}; +use crate::atlas::trait_swerve::{ + build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools, +}; use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer}; use crate::seed::{SeedChain, SeedDomain}; -use crate::simulation::generator::{MaintenanceAuthority, MorphologyZone}; +use crate::simulation::generator::{ + BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier, +}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use crate::tick_phases::TickPhase; @@ -100,6 +112,7 @@ fn drain_generation_completions( queue: Res, mut cache: ResMut, city_reader: Option>, + trait_catalog: Option>, rng: Option>, ) { for completion in queue.drain_completions() { @@ -116,9 +129,20 @@ fn drain_generation_completions( let reader = &city_reader.0; let world_seed = rng.seed(); let body_id = state.body_id.clone(); + + // ── T-994 (D-232): body-level aggregation for the phase-1 + // trait-vocabulary K-draw ─────────────────────────────────── + // Read each placement's D-199 read-set once — reused both to + // build the body-wide coverage aggregate here and to build + // that placement's own skeleton work item below, so this + // dispatch pass makes exactly one `read_set` DB round trip per + // settlement (same as before this ticket). The aggregation + // math itself is the pure `aggregate_body_dispatch_inputs` + // (unit-tested directly, PR #173 review H1). + let mut resolved: Vec<(&CityPlacement, CityEconomicReadSet)> = Vec::new(); for placement in &state.placements { - let read_set = match reader.read_set(placement.city_id, world_seed) { - Ok(rs) => rs, + match reader.read_set(placement.city_id, world_seed) { + Ok(rs) => resolved.push((placement, rs)), Err(e) => { tracing::warn!( city_id = placement.city_id, @@ -126,9 +150,67 @@ fn drain_generation_completions( error = %e, "L3→L4 dispatch: read_set failed — skipping placement" ); - continue; } + } + } + let BodyDispatchAggregates { + body_district_type_mix, + max_prosperity_bps, + max_k, + } = aggregate_body_dispatch_inputs(&resolved, world_seed, &body_id); + + // Phase-1 K-draw (D-232): computed once, shared by every + // settlement on this body — the closed-vocabulary invariant. + // An absent reader (tests) degrades to an empty catalog: + // draw/pools/eligible all no-op identically. + let (catalog, bias): (Vec, Vec) = + match trait_catalog.as_ref() { + Some(tc) => ( + tc.0.read_catalog().unwrap_or_else(|e| { + tracing::warn!(body_id = %body_id, error = %e, "trait catalog read failed — empty vocabulary"); + Vec::new() + }), + tc.0.read_body_bias(&body_id).unwrap_or_else(|e| { + tracing::warn!(body_id = %body_id, error = %e, "trait bias read failed — no bias applied"); + Vec::new() + }), + ), + None => (Vec::new(), Vec::new()), }; + let body_sector: Option<&str> = resolved + .first() + .and_then(|(_, rs)| rs.geographic_sector.as_deref()); + // dominant_bulk_class/dominant_production_ubiquity are + // #982 design-blocked stubs (always NonPhysical/Common) — + // see CityGenerationContext's field docs. + let inputs = VocabularyDrawInputs { + k: max_k, + dominant_bulk_class: &BulkClass::NonPhysical, + dominant_production_ubiquity: &ProductionUbiquity::Common, + max_prosperity_bps, + geographic_sector: body_sector, + coverage_district_types: &body_district_type_mix, + }; + // Hard-gate-eligible pool — shared by the K-draw, the T-1003 + // swerve pools, and the phase-2 necessity escape hatch (the + // swerve is cultural-only; the D-233 gates always hold). + let eligible = hard_gate_eligible(&catalog, &inputs); + let trait_selection = draw_body_vocabulary( + &catalog, + &bias, + &inputs, + SeedChain::for_body(world_seed, &body_id), + ); + let swerve_pools = build_swerve_pools(&eligible, &trait_selection, body_sector); + let vocab = BodyVocabularyContext { + trait_selection: &trait_selection, + body_district_type_mix: &body_district_type_mix, + catalog: &catalog, + eligible: &eligible, + swerve_pools: &swerve_pools, + }; + + for (placement, read_set) in resolved { queue.submit( build_skeleton_work_item( &body_id, @@ -137,6 +219,7 @@ fn drain_generation_completions( read_set, &state.districts, &state.road_graph, + &vocab, ), GenPriority::Low, ); @@ -195,6 +278,112 @@ fn drain_generation_completions( } } +/// Body-level aggregates feeding the phase-1 K-draw (T-994), computed over the +/// successfully-resolved placements of one body. +struct BodyDispatchAggregates { + /// Every `DistrictType` any settlement on the body will produce, deduped + /// and deterministically ordered (BTreeSet iteration, D-010). + body_district_type_mix: Vec, + /// MAX prosperity across settlements — the vocabulary gate is + /// coverage-aware (see `VocabularyDrawInputs::max_prosperity_bps`). + max_prosperity_bps: u32, + /// MAX `complexity_k` across settlements (see the `trait_draw` + /// module-level note on body-vs-settlement K). + max_k: usize, +} + +/// The pure aggregation math behind the L3→L4 dispatch (T-994) — split out of +/// `drain_generation_completions` so it unit-tests without a DB, queue, or +/// Bevy world (PR #173 review H1). +fn aggregate_body_dispatch_inputs( + resolved: &[(&CityPlacement, CityEconomicReadSet)], + world_seed: u64, + body_id: &str, +) -> BodyDispatchAggregates { + let mut body_district_type_mix: std::collections::BTreeSet = Default::default(); + let mut max_prosperity_bps: u32 = 0; + let mut max_k: usize = 0; + for (placement, read_set) in resolved { + max_prosperity_bps = max_prosperity_bps.max(read_set.prosperity_baseline_bps); + // Re-derives the identical DistrictType mix `generate_quarter_skeleton` + // computes later for this same placement (same chain, same inputs) — + // cheap (a 16-draw seeded LCG) and gives the aggregate the *actual* + // district-type mix rather than a proxy. + let mix_chain = SeedChain::for_body(world_seed, body_id) + .derive(SeedDomain::Layer4Quarter, placement.city_id); + let mix = compute_district_mix( + read_set.population, + &read_set.economic_role, + &placement.political_archetype, + 16, + mix_chain, + ); + body_district_type_mix.extend(mix.districts); + // world_tier has no real derivation yet (city_context_reader's #TBD + // stub, always Waypoint) — tracked here via population tier alone so a + // future world_tier derivation slots into this MAX-K aggregate without + // revisiting this loop. + let tier = derive_complexity( + &WorldTier::Waypoint, + population_tier(read_set.population), + read_set.population, + ); + max_k = max_k.max(complexity_k(&tier)); + } + BodyDispatchAggregates { + body_district_type_mix: body_district_type_mix.into_iter().collect(), + max_prosperity_bps, + max_k, + } +} + +/// Body-level D-232 trait-vocabulary draw outputs, threaded into +/// [`build_skeleton_work_item`] (T-994). Bundled into one struct purely to keep +/// that function's argument count under the clippy `too_many_arguments` +/// threshold — see its doc comment. +struct BodyVocabularyContext<'a> { + /// Phase-1 K-draw result (D-232), computed once per body by the caller — + /// identical for every settlement on the body (the closed-vocabulary + /// invariant). Empty when no `TraitCatalogReaderResource` is wired (tests) + /// or the body's K is 0 (`ComplexityTier::Empty` everywhere on the body). + trait_selection: &'a [String], + /// Every `DistrictType` present anywhere on the body (T-994 coverage + /// aggregate) — threaded onto `CityGenerationContext.body_district_type_mix` + /// verbatim (design point 4: visible on the context, not just consumed + /// internally by the draw). + body_district_type_mix: &'a [DistrictType], + /// The full trait-template catalog, needed to resolve phase 2 + /// (`district_dominant_by_type`) for each settlement's District cell — + /// `zone_affinity` lives on the catalog row, not on `trait_selection`'s tags. + catalog: &'a [TraitTemplate], + /// Hard-gate-eligible subset of `catalog` (T-1003) — the phase-2 necessity + /// escape hatch reaches this pool when the vocabulary can't serve a district + /// type (the swerve is cultural-only; D-233 gates always hold). + eligible: &'a [&'a TraitTemplate], + /// Body-level T-1003 swerve candidate pools (foreign-import / + /// heritage-callback), cloned onto each settlement's context — the + /// per-building wildcard draws from these at `assign_block_tags` time. + swerve_pools: &'a SwervePools, +} + +/// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's +/// centrality (high) / isolation (low) driver input. 0 when the city has no +/// node or no edges (matching `road_entry_directions_for_city`'s fallback). +fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 { + let Some(idx) = road_graph + .nodes + .iter() + .position(|n: &RoadNode| n.city_id == Some(city_id)) + else { + return 0; + }; + road_graph + .edges + .iter() + .filter(|e| e.from == idx || e.to == idx) + .count() as u32 +} + /// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement /// (T-1022, T-1039, T-1043, D-234). Builds the D-199 context from the read-set /// (mirroring @@ -223,6 +412,12 @@ fn drain_generation_completions( /// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body, /// city)` — not the `city_id * 10` placeholder. /// +/// `vocab` carries the D-232 three-phase draw's body-level outputs (T-994): +/// `trait_selection` (phase 1, computed once per body by the caller) and the +/// `catalog` needed to resolve phase 2 (`district_dominant_by_type`) for this +/// specific settlement's District cell. Bundled into one struct to keep this +/// function's argument count under the clippy `too_many_arguments` threshold. +/// /// Pure (no queue/cache access) so it unit-tests without a `systems.db`. fn build_skeleton_work_item( body_id: &str, @@ -231,6 +426,7 @@ fn build_skeleton_work_item( read_set: CityEconomicReadSet, districts: &BTreeMap, road_graph: &RoadGraph, + vocab: &BodyVocabularyContext, ) -> GenWorkItem { // The D-199 raw fields ride alongside the context (generate_quarter_skeleton // takes them separately), so capture them before context_from_read_set consumes @@ -238,6 +434,8 @@ fn build_skeleton_work_item( let economic_role = read_set.economic_role.clone(); let population = read_set.population; let founding_age_years = read_set.founding_age_years; + // T-1003 driver input (cosmopolitanism) — captured here for the same reason. + let faction_mixed = read_set.dominant_faction.as_deref() == Some("mixed"); let mut context = context_from_read_set(placement.city_id, read_set); @@ -276,6 +474,44 @@ fn build_skeleton_work_item( } }; + // ── T-994 / D-232: three-phase trait-template draw ────────────────────────── + // Phase 1 (trait_selection) and its inputs (body_district_type_mix) were + // computed once per body by the caller (drain_generation_completions) — the + // closed-vocabulary invariant requires every settlement on the body to carry + // the identical `trait_selection`, so this function only threads it through, + // never re-derives it. Phase 2 (district_dominant_by_type) IS settlement- + // specific (keyed by this settlement's own District cell) and is resolved + // here, at dispatch time — not inside the GenerateSkeleton Rayon task, and + // never inside FillChunk (T-987 keeps fill pure/cache-free). + context.trait_selection = vocab.trait_selection.to_vec(); + context.body_district_type_mix = vocab.body_district_type_mix.to_vec(); + context.settlement_district_pos = district_pos; + context.district_dominant_by_type = pick_district_dominant_by_type( + vocab.catalog, + vocab.eligible, + vocab.trait_selection, + SeedChain::for_body(world_seed, body_id), + district_pos, + ); + + // ── T-1003 / D-232: deviation/swerve driver rates + candidate pools ───────── + // Pools are body-level (same eligible catalog + vocabulary everywhere on the + // body); the driver RATES are per-settlement — centrality/isolation from the + // road graph, cosmopolitanism from the faction read, conservatism from + // founding age. `context.world_tier` rides the reader's Waypoint stub today + // (same caveat as the K aggregation) — Epicenter/Passage multipliers activate + // once a real derivation lands. + let drivers = SwerveDrivers { + world_tier: &context.world_tier, + faction_mixed, + road_degree: road_degree_for_city(placement.city_id, road_graph), + founding_age_years, + }; + let rates = compute_swerve_rates(&drivers); + context.swerve_rates_bps = (rates.foreign_bps, rates.heritage_bps); + context.swerve_foreign_pool = vocab.swerve_pools.foreign.clone(); + context.swerve_heritage_pool = vocab.swerve_pools.heritage.clone(); + // ── T-1043: road_entry_directions from road_graph ─────────────────────────── // Find this city's settlement node index in the road graph (O(n) scan on a // small slice — settlement counts are single-digit to low hundreds per body). @@ -542,6 +778,24 @@ mod tests { dominant_faction: None, founding_age_years: 200, settlement_class: SettlementClass::PopulationBudget, + geographic_sector: None, + } + } + + /// Empty D-232 draw context (T-994/T-1003) — no catalog reader wired, + /// matching the production behaviour when `TraitCatalogReaderResource` is + /// absent. + fn empty_vocab() -> BodyVocabularyContext<'static> { + static EMPTY_POOLS: SwervePools = SwervePools { + foreign: Vec::new(), + heritage: Vec::new(), + }; + BodyVocabularyContext { + trait_selection: &[], + body_district_type_mix: &[], + catalog: &[], + eligible: &[], + swerve_pools: &EMPTY_POOLS, } } @@ -576,6 +830,177 @@ mod tests { } } + // ── T-994 body-level aggregation + threading (PR #173 review H1) ───────── + + fn read_set_with( + prosperity_bps: u32, + population: i64, + founding_age: u32, + ) -> CityEconomicReadSet { + CityEconomicReadSet { + prosperity_baseline_bps: prosperity_bps, + population, + founding_age_years: founding_age, + ..sample_read_set() + } + } + + #[test] + fn aggregate_body_dispatch_inputs_unions_mixes_and_takes_maxes() { + let p1 = sample_placement(1, FoundingOrientation::Cardinal); + let p2 = sample_placement(2, FoundingOrientation::Cardinal); + // City 1: ghost-stub population (< 5K on Waypoint → ComplexityTier::Empty, K=0). + // City 2: normal city (Waypoint → Minimal, K=1). + let rs1 = read_set_with(6_000, 3_000, 200); + let rs2 = read_set_with(8_500, 500_000, 200); + let resolved = vec![(&p1, rs1.clone()), (&p2, rs2.clone())]; + + let agg = aggregate_body_dispatch_inputs(&resolved, 42, "BodyAgg"); + assert_eq!( + agg.max_prosperity_bps, 8_500, + "MAX prosperity across settlements" + ); + assert_eq!(agg.max_k, 1, "MAX complexity K across settlements (0 vs 1)"); + + // The coverage mix must be exactly the union of each placement's own + // deterministic district mix (same chains generate_quarter_skeleton uses). + let mut expected: std::collections::BTreeSet = Default::default(); + for (p, rs) in [(&p1, &rs1), (&p2, &rs2)] { + let chain = + SeedChain::for_body(42, "BodyAgg").derive(SeedDomain::Layer4Quarter, p.city_id); + expected.extend( + compute_district_mix( + rs.population, + &rs.economic_role, + &p.political_archetype, + 16, + chain, + ) + .districts, + ); + } + assert!(!expected.is_empty()); + assert_eq!( + agg.body_district_type_mix, + expected.into_iter().collect::>() + ); + } + + #[test] + fn dispatched_contexts_share_vocabulary_but_carry_per_settlement_swerve_rates() { + use crate::atlas::trait_catalog_reader::TraitTemplate; + use std::collections::BTreeMap; + + fn tmpl(tag: &str) -> TraitTemplate { + TraitTemplate { + tag: tag.to_string(), + corridor_pool: "baseline".to_string(), + geographic_sector: None, + bulk_class_gate: Vec::new(), + production_ubiquity_gate: Vec::new(), + min_prosperity_bps: 0, + base_weight: 10_000, + weight_mods: BTreeMap::new(), + zone_affinity: [(DistrictType::MixedUse, 10_000)].into_iter().collect(), + } + } + let catalog = vec![tmpl("temp_a"), tmpl("temp_b")]; + let eligible: Vec<&TraitTemplate> = catalog.iter().collect(); + let trait_selection = vec!["temp_a".to_string(), "temp_b".to_string()]; + let mix = vec![DistrictType::MixedUse]; + let pools = SwervePools { + foreign: vec![("foreign_x".to_string(), 10_000)], + heritage: vec![("herit_y".to_string(), 10_000)], + }; + let vocab = BodyVocabularyContext { + trait_selection: &trait_selection, + body_district_type_mix: &mix, + catalog: &catalog, + eligible: &eligible, + swerve_pools: &pools, + }; + + // City 1 sits in the road graph with degree 2; city 2 has no node (degree 0). + let road_graph = RoadGraph { + nodes: vec![ + RoadNode { + city_id: Some(1), + position: (10, 20), + kind: RoadNodeKind::Settlement, + degree: 2, + parent_edge: None, + }, + RoadNode { + city_id: Some(90), + position: (10, 4), + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + RoadNode { + city_id: Some(91), + position: (26, 20), + kind: RoadNodeKind::Settlement, + degree: 1, + parent_edge: None, + }, + ], + edges: vec![ + RoadEdge { + from: 0, + to: 1, + path: vec![(10, 20), (10, 4)], + length_cells: 16, + maintenance: MaintenanceAuthority::Administrative, + named_route_id: None, + is_rail: false, + }, + RoadEdge { + from: 0, + to: 2, + path: vec![(10, 20), (26, 20)], + length_cells: 16, + maintenance: MaintenanceAuthority::Administrative, + named_route_id: None, + is_rail: false, + }, + ], + }; + + let build = |city_id: u64, founding_age: u32| { + let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item( + "BodyThread", + 42, + &sample_placement(city_id, FoundingOrientation::Cardinal), + read_set_with(6_000, 500_000, founding_age), + &BTreeMap::new(), + &road_graph, + &vocab, + ) else { + panic!("expected GenerateSkeleton"); + }; + context + }; + let ctx1 = build(1, 50); // connected (degree 2), young settlement + let ctx2 = build(2, 400); // off-graph (degree 0), old settlement + + // The body-level draw outputs are identical on both settlements — the + // closed-vocabulary invariant threaded through dispatch. + assert_eq!(ctx1.trait_selection, ctx2.trait_selection); + assert_eq!(ctx1.trait_selection, trait_selection); + assert_eq!(ctx1.body_district_type_mix, ctx2.body_district_type_mix); + assert_eq!(ctx1.swerve_foreign_pool, ctx2.swerve_foreign_pool); + assert_eq!(ctx1.swerve_heritage_pool, ctx2.swerve_heritage_pool); + assert_eq!(ctx1.swerve_foreign_pool, pools.foreign); + + // The swerve RATES are per-settlement (T-1003 drivers): city 1 gets the + // road-degree centrality bump on foreign (100 → 120) and no isolation + // multiplier on heritage (Waypoint remote ×1.5 only → 150); city 2 is + // isolated (×2.0) + remote (×1.5) + old (×1.5) → capped at 300. + assert_eq!(ctx1.swerve_rates_bps, (120, 150)); + assert_eq!(ctx2.swerve_rates_bps, (100, 300)); + } + #[test] fn build_skeleton_work_item_threads_orientation_and_canonical_quarter_id() { let placement = sample_placement( @@ -601,6 +1026,7 @@ mod tests { sample_read_set(), &BTreeMap::new(), &RoadGraph::default(), + &empty_vocab(), ) else { panic!("expected GenerateSkeleton"); @@ -639,6 +1065,7 @@ mod tests { sample_read_set(), &BTreeMap::new(), &RoadGraph::default(), + &empty_vocab(), ) else { unreachable!() }; @@ -699,6 +1126,7 @@ mod tests { sample_read_set(), &districts, &RoadGraph::default(), + &empty_vocab(), ) else { panic!("expected GenerateSkeleton"); }; @@ -731,6 +1159,7 @@ mod tests { sample_read_set(), &BTreeMap::new(), &RoadGraph::default(), + &empty_vocab(), ) else { panic!("expected GenerateSkeleton") }; @@ -918,6 +1347,7 @@ mod tests { sample_read_set(), &districts, &RoadGraph::default(), + &empty_vocab(), ) else { panic!("expected GenerateSkeleton"); @@ -1188,6 +1618,7 @@ mod tests { sample_read_set(), &BTreeMap::new(), &road_graph, + &empty_vocab(), ) else { panic!("expected GenerateSkeleton"); @@ -1251,6 +1682,7 @@ mod tests { sample_read_set(), &BTreeMap::new(), &RoadGraph::default(), + &empty_vocab(), ) else { panic!("expected GenerateSkeleton"); diff --git a/server/src/atlas/shell.rs b/server/src/atlas/shell.rs index bd61bbfd9..4d91d80fa 100644 --- a/server/src/atlas/shell.rs +++ b/server/src/atlas/shell.rs @@ -308,7 +308,7 @@ mod tests { heights: FloorHeightProfile::Uniform(3), }, entry_class: BuildingEntryClass::Public, - flavor_ref: ArchitectureFlavorRef { flavor_index: 0 }, + flavor_ref: ArchitectureFlavorRef::InVocabulary(0), era: ConstructionEra::Founding, era_cause: EraCause::Original, initial_condition: TileCondition::Intact, diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 4742df2d8..4040cb89a 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -21,6 +21,7 @@ use crate::atlas::block_irregularity::block_irregularity; use crate::atlas::district_mix::{compute_district_mix, population_tier}; use crate::atlas::tile_condition::{tile_condition, TileCondition}; +use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates}; use crate::seed::splitmix64; use crate::seed::{SeedChain, SeedDomain}; use std::collections::BTreeMap; @@ -199,7 +200,15 @@ fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> Setti /// | Backwater | Full | Moderate | /// | Passage | Moderate | Minimal | /// | Waypoint | Minimal | Minimal (→ Empty <5K)| -fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier { +/// +/// `pub(crate)` (T-994): also called from `atlas::plugin`'s body-level dispatch +/// aggregation to compute each settlement's D-232 phase-1 K contribution +/// (`trait_draw::complexity_k`) ahead of `generate_quarter_skeleton` itself. +pub(crate) fn derive_complexity( + world_tier: &WorldTier, + pop_tier: u8, + population: i64, +) -> ComplexityTier { // Ghost stub threshold: pop < 5000 on Waypoint → Empty. if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) { return ComplexityTier::Empty; @@ -334,6 +343,7 @@ fn build_block_grid( BlockSkeleton { position: (row as u8, col as u8), zoning, + district_type: dt.clone(), reservation, // Density-based spacing; layout_mode offset/rotation applied in // the street-network step (D-234). @@ -863,7 +873,23 @@ fn assign_block_tags( } let prosperity = context.prosperity_baseline_bps; let setting = &context.surrounding_biome; - let flavor_n = context.trait_selection.len().max(1) as u64; + // D-232 phase 2 (T-994): the dominant template for this block's DistrictType + // was already pre-resolved at L3→L4 dispatch time (`context.district_dominant_by_type` + // — see `atlas::trait_draw::pick_district_dominant_by_type`), keyed by the + // settlement's D-243 District cell so every block of this DistrictType across + // the whole quarter (and any sibling quarter in the same District) reads the + // identical template. Falls back to index 0 if the type has no entry (should + // not happen — the map always has all 9 DistrictType keys). Usually + // InVocabulary; already a Swerve when the sparsity escape hatch fired (T-1003). + let district_dominant = context + .district_dominant_by_type + .get(&block.district_type) + .cloned() + .unwrap_or(ArchitectureFlavorRef::InVocabulary(0)); + let swerve_rates = SwerveRates { + foreign_bps: context.swerve_rates_bps.0, + heritage_bps: context.swerve_rates_bps.1, + }; let footprints = subdivide_block_footprints( block.density_pct, @@ -897,17 +923,26 @@ fn assign_block_tags( ); let initial = initial_condition(prosperity, &era_cause); let extent = floor_extent(block.density_pct, fp_chain.derive(SeedDomain::Block, 2)); - // D-232: deterministic (seed + zone_type) → flavor index into the - // body's K-tag trait selection. - let flavor_seed = fp_chain.derive(SeedDomain::Block, 3).seed(); - let flavor_index = - (splitmix64(flavor_seed ^ zone_type_hash(&zone_type_id)) % flavor_n) as u8; + // T-1003 (D-232 deviation system): the rare per-building wildcard — + // its own seed domain off the footprint chain, so the roll can never + // correlate with the zone/era/extent draws above. Overwhelmingly + // None → the district-dominant template applies as usual. + let mut swerve_rng = fp_chain.derive(SeedDomain::TraitSwerve, 0).atlas_rng(); + let flavor_ref = match roll_building_swerve( + swerve_rates, + &context.swerve_foreign_pool, + &context.swerve_heritage_pool, + &mut swerve_rng, + ) { + Some(tag) => ArchitectureFlavorRef::Swerve(tag), + None => district_dominant.clone(), + }; BuildingPropertyTag { zone_type_id, footprint, extent, entry_class, - flavor_ref: ArchitectureFlavorRef { flavor_index }, + flavor_ref, era, era_cause, initial_condition: initial, @@ -917,16 +952,6 @@ fn assign_block_tags( .collect() } -/// Stable hash of a zone-type id for the D-232 flavor draw (FNV-1a over bytes). -fn zone_type_hash(id: &ZoneTypeId) -> u64 { - let mut h: u64 = 0xcbf29ce484222325; - for b in id.as_str().bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); - } - h -} - /// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag /// every non-reserved block's footprints. Keyed by 4×4 block grid position. pub fn assign_all_block_tags( @@ -1225,6 +1250,16 @@ mod tests { trait_selection: Vec::new(), dominant_bulk_class: BulkClass::NonPhysical, dominant_production_ubiquity: ProductionUbiquity::Common, + // T-994 additions — sensible stubs for existing tests. + geographic_sector: None, + body_district_type_mix: Vec::new(), + settlement_district_pos: (0, 0), + district_dominant_by_type: BTreeMap::new(), + // T-1003 additions — zero rates / empty pools: no swerve in + // existing tests. + swerve_rates_bps: (0, 0), + swerve_foreign_pool: Vec::new(), + swerve_heritage_pool: Vec::new(), } } @@ -1685,6 +1720,127 @@ mod tests { assert_eq!(a, b); } + // ── D-232 phase-2 dominant-template lookup (T-994) ─────────────────────── + + #[test] + fn flavor_ref_reads_the_pre_resolved_district_dominant_index() { + // The three-phase draw pre-resolves one dominant trait-selection index + // per DistrictType at dispatch time (context.district_dominant_by_type); + // assign_block_tags must be a pure lookup over it — no independent RNG + // draw of its own. Residential is unconditionally present (D-194 pop-tier + // guarantee, tier_guarantees(0).2 == 1) for a sub-1M-population city, so + // this isn't a vacuous check regardless of seed. + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + ctx.trait_selection = vec!["a".into(), "b".into(), "c".into()]; + ctx.district_dominant_by_type.insert( + DistrictType::Residential, + ArchitectureFlavorRef::InVocabulary(2), + ); + + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + + let mut found_residential = false; + for (pos, block_tags) in &tags { + let block = &sk.blocks[pos.0 as usize][pos.1 as usize]; + if block.district_type != DistrictType::Residential { + continue; + } + found_residential = true; + for tag in block_tags { + assert_eq!( + tag.flavor_ref, + ArchitectureFlavorRef::InVocabulary(2), + "Residential block {pos:?} should read the pre-resolved index" + ); + } + } + assert!( + found_residential, + "expected at least one Residential block (D-194 pop-tier guarantee)" + ); + } + + #[test] + fn flavor_ref_falls_back_to_zero_for_unmapped_district_type() { + // An empty district_dominant_by_type (e.g. no trait catalog reader wired, + // or K=0) must not panic — every block falls back to index 0, matching + // the harmless pre-T-994 degenerate behaviour. + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + for block_tags in tags.values() { + for tag in block_tags { + assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0)); + } + } + } + + // ── D-232 deviation/swerve wildcard (T-1003) ───────────────────────────── + + #[test] + fn swerve_wildcard_is_rare_deterministic_and_draws_from_the_pools() { + // With both pools populated and rates at the 300 bps cap (6% per + // building total), a full quarter must still be overwhelmingly + // district-dominant, any swerved building must carry a pool tag, and + // the whole assignment must be reproducible (D-010). + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + ctx.trait_selection = vec!["own".into()]; + ctx.swerve_rates_bps = (300, 300); + ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)]; + ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; + + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags_a = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + let tags_b = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)"); + + let mut total = 0usize; + let mut swerved = 0usize; + for block_tags in tags_a.values() { + for tag in block_tags { + total += 1; + match &tag.flavor_ref { + ArchitectureFlavorRef::InVocabulary(_) => {} + ArchitectureFlavorRef::Swerve(t) => { + swerved += 1; + assert!( + t == "foreign_temple" || t == "old_hacienda", + "swerve must draw a pool tag, got '{t}'" + ); + } + } + } + } + assert!( + total > 20, + "expected a meaningful building count, got {total}" + ); + assert!( + swerved * 100 < total * 25, + "swerves must stay rare: {swerved}/{total}" + ); + } + + #[test] + fn zero_rates_never_swerve_even_with_populated_pools() { + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + ctx.trait_selection = vec!["own".into()]; + ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)]; + ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)]; + // rates stay (0, 0) from make_context + let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42)); + let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42)); + for block_tags in tags.values() { + for tag in block_tags { + assert!(matches!( + tag.flavor_ref, + ArchitectureFlavorRef::InVocabulary(_) + )); + } + } + } + // ── Street network (#957, D-234) ───────────────────────────────────────── use crate::simulation::generator::AccessKind; diff --git a/server/src/atlas/trait_catalog_reader.rs b/server/src/atlas/trait_catalog_reader.rs new file mode 100644 index 000000000..d9cb97f0a --- /dev/null +++ b/server/src/atlas/trait_catalog_reader.rs @@ -0,0 +1,583 @@ +//! Build-time reader for the D-232 architecture-flavor trait-template catalog +//! (T-994) — `trait_templates` (the shared catalog) + `atlas_body_trait_bias` +//! (sparse per-body hero pins/boosts/suppressions). +//! +//! Read-only `systems.db` access, following the same pattern as +//! [`crate::atlas::city_context_reader::CityContextReader`]: opened once at +//! server startup, queried at L3→L4 dispatch time so the generation cascade +//! stays DB-free downstream (T-987 keeps `GenerateSkeleton`/`FillChunk` pure). +//! +//! The catalog itself (`trait_templates`) is baked by +//! `tooling/economy-db/economy_import/traits.py` from +//! `wiki/economics/architecture_trait_catalog.toml` (#993, #1005) — see +//! `.claude/rules/asset-pipeline.md`. This reader only *consumes* the baked +//! table; it never writes to `systems.db`. +//! +//! **Scope (T-994):** only the fields the three-phase draw mechanism needs are +//! parsed — `allow_tags`/`block_tags`/`era_scope`/`visual_bundle`/ +//! `cultural_description` are D-235 (visual bundle resolution) territory and are +//! left unparsed here. + +use std::path::Path; +use std::sync::{Arc, Mutex, OnceLock}; + +use rusqlite::{Connection, OpenFlags}; +use thiserror::Error; + +use crate::simulation::generator::{BulkClass, DistrictType, ProductionUbiquity}; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +#[derive(Debug, Error)] +pub enum TraitCatalogReadError { + #[error("systems.db error: {0}")] + Db(String), +} + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + +/// One holistic template from the D-232 catalog (`trait_templates` row). +/// +/// Fields mirror `wiki/economics/architecture_trait_catalog.toml` / +/// `server/data/systems-schema.sql`; JSON-text columns are parsed eagerly so +/// downstream draw logic (`atlas::trait_draw`) never touches raw JSON. +#[derive(Debug, Clone, PartialEq)] +pub struct TraitTemplate { + pub tag: String, + /// `baseline` | `heritage` | `cross_corridor` — load-bearing (D-232 corridor + /// two-part pool): `heritage` is excluded from the ordinary phase-1 lottery + /// (`trait_draw`) and reserved for the T-1003 heritage-callback swerve; + /// `cross_corridor` + other-corridor `baseline` feed the foreign-import + /// swerve pool (`trait_swerve::build_swerve_pools`). + pub corridor_pool: String, + /// The corridor this template is authored for; `None` = shared/cross-corridor. + /// A SOFT pool-narrowing hint (D-232) — never a hard gate. + pub geographic_sector: Option, + /// Hard gate: eligible `BulkClass`es. Empty = eligible for all. + pub bulk_class_gate: Vec, + /// Hard gate: eligible `ProductionUbiquity` values. Empty = eligible for all. + pub production_ubiquity_gate: Vec, + /// Hard gate: minimum prosperity in basis points (D-010 integer). + pub min_prosperity_bps: u32, + /// Base weight in basis points (D-010 integer; 10 000 = 1.0×). + pub base_weight: u32, + /// Soft weight modifiers: dimension → value → multiplier_bps. + /// Dimensions per the catalog: `economic_role`, `dominant_faction`, + /// `founding_age`, `geographic_sector`, `morphology_zone`. T-994 applies only + /// `geographic_sector` (the one dimension with an unambiguous single + /// body-level value); the others are per-settlement and D-232 round 3 doesn't + /// specify how a multi-settlement body should combine them for one shared + /// vocabulary draw — parsed here for completeness, left un-applied. + pub weight_mods: std::collections::BTreeMap>, + /// `DistrictType` → weight_bps. The phase-2 dominant-template pick's sole + /// input (D-232: "district-dominant by zone_affinity"). + pub zone_affinity: std::collections::BTreeMap, +} + +/// Bias kind on an `atlas_body_trait_bias` row (D-232 hero-body wiki bias). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BiasKind { + /// Mandatory — counts toward K, forces inclusion in `trait_selection`. + Pin, + /// Weight multiplier > 1×, ≤ 3× (`weight_multiplier_bps` 10001..=30000). + Boost, + /// Weight multiplier < 1×, ≥ 0.33× — never 0 (`weight_multiplier_bps` 3300..=9999). + Suppress, +} + +/// One sparse per-body bias row (hero bodies only, ~30–40 per D-232). +#[derive(Debug, Clone, PartialEq)] +pub struct TraitBias { + pub template_tag: String, + pub bias_kind: BiasKind, + /// `None` for `Pin` (no multiplier — pins are forced, not weighted). + pub weight_multiplier_bps: Option, +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +/// Reads the D-232 trait-template catalog + per-body bias from `systems.db`. +/// +/// Analogous to [`crate::atlas::city_context_reader::CityContextReader`]: a +/// read-only connection opened once at server startup. +pub struct TraitCatalogReader { + conn: Arc>, + /// One-shot cache for the parsed catalog (PR #173 review H5): the + /// `trait_templates` table is immutable for a server run (`make regen-db` + /// replaces the file offline), so the SQL + JSON-parse pass runs once and + /// every later body dispatch clones the parsed rows. Per-body bias is NOT + /// cached — it legitimately varies per body. + catalog_cache: OnceLock>, +} + +impl TraitCatalogReader { + /// Open a read-only connection to `systems_db`. + pub fn open(systems_db: &Path) -> Result { + let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + catalog_cache: OnceLock::new(), + }) + } + + /// Read the full shared catalog, ordered by `tag` (D-010 — deterministic + /// iteration order for the weighted draw's tie-breaks). Cached after the + /// first successful read (H5) — errors are not cached, so a transient + /// failure retries on the next dispatch. + pub fn read_catalog(&self) -> Result, TraitCatalogReadError> { + if let Some(cached) = self.catalog_cache.get() { + return Ok(cached.clone()); + } + let catalog = self.read_catalog_uncached()?; + // First writer wins; a concurrent racer computed the identical value. + let _ = self.catalog_cache.set(catalog.clone()); + Ok(catalog) + } + + fn read_catalog_uncached(&self) -> Result, TraitCatalogReadError> { + let conn = self + .conn + .lock() + .map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT tag, corridor_pool, geographic_sector, bulk_class_gate, + production_ubiquity_gate, min_prosperity_bps, base_weight, + weight_mods, zone_affinity + FROM trait_templates + ORDER BY tag", + ) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + )) + }) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + + let mut out = Vec::new(); + for r in rows { + let ( + tag, + corridor_pool, + geographic_sector, + bulk_class_gate_json, + production_ubiquity_gate_json, + min_prosperity_bps, + base_weight, + weight_mods_json, + zone_affinity_json, + ) = r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + + out.push(TraitTemplate { + bulk_class_gate: parse_bulk_class_gate(bulk_class_gate_json.as_deref(), &tag), + production_ubiquity_gate: parse_production_ubiquity_gate( + production_ubiquity_gate_json.as_deref(), + &tag, + ), + min_prosperity_bps: min_prosperity_bps.max(0) as u32, + base_weight: base_weight.max(0) as u32, + weight_mods: parse_weight_mods(weight_mods_json.as_deref(), &tag), + zone_affinity: parse_zone_affinity(zone_affinity_json.as_deref(), &tag), + tag, + corridor_pool, + geographic_sector, + }); + } + Ok(out) + } + + /// Read the sparse hero-body bias rows for `body_id` (empty for the ~240 + /// non-hero bodies, D-232). + pub fn read_body_bias(&self, body_id: &str) -> Result, TraitCatalogReadError> { + let conn = self + .conn + .lock() + .map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT template_tag, bias_kind, weight_multiplier_bps + FROM atlas_body_trait_bias + WHERE body_id = ?1 + ORDER BY template_tag", + ) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let rows = stmt + .query_map([body_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }) + .map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + + let mut out = Vec::new(); + for r in rows { + let (template_tag, kind_str, mult) = + r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?; + let Some(bias_kind) = parse_bias_kind(&kind_str) else { + tracing::warn!( + body_id, + template_tag, + kind = kind_str, + "unrecognized bias_kind — skipping row" + ); + continue; + }; + out.push(TraitBias { + template_tag, + bias_kind, + weight_multiplier_bps: mult.map(|m| m.max(0) as u32), + }); + } + Ok(out) + } +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +fn parse_bias_kind(s: &str) -> Option { + match s { + "pin" => Some(BiasKind::Pin), + "boost" => Some(BiasKind::Boost), + "suppress" => Some(BiasKind::Suppress), + _ => None, + } +} + +fn parse_bulk_class(s: &str) -> Option { + match s { + "BulkSolid" => Some(BulkClass::BulkSolid), + "BulkLiquid" => Some(BulkClass::BulkLiquid), + "PrecisionDense" => Some(BulkClass::PrecisionDense), + "Perishable" => Some(BulkClass::Perishable), + "NonPhysical" => Some(BulkClass::NonPhysical), + _ => None, + } +} + +fn parse_production_ubiquity(s: &str) -> Option { + match s { + "Ubiquitous" => Some(ProductionUbiquity::Ubiquitous), + "Common" => Some(ProductionUbiquity::Common), + "Specialist" => Some(ProductionUbiquity::Specialist), + "MonopolySource" => Some(ProductionUbiquity::MonopolySource), + _ => None, + } +} + +/// `DistrictType` from its exact catalog string (matches the Rust enum variant +/// names 1:1 — see `architecture_trait_catalog.toml`'s canonical-enum comment). +pub(crate) fn parse_district_type(s: &str) -> Option { + match s { + "LogisticsHub" => Some(DistrictType::LogisticsHub), + "Residential" => Some(DistrictType::Residential), + "Commercial" => Some(DistrictType::Commercial), + "Industrial" => Some(DistrictType::Industrial), + "Administrative" => Some(DistrictType::Administrative), + "Entertainment" => Some(DistrictType::Entertainment), + "MixedUse" => Some(DistrictType::MixedUse), + "Transit" => Some(DistrictType::Transit), + "Specialized" => Some(DistrictType::Specialized), + _ => None, + } +} + +fn parse_bulk_class_gate(json: Option<&str>, tag: &str) -> Vec { + let Some(json) = json else { return Vec::new() }; + // A malformed blob degrading to an empty Vec silently WIDENS the D-233 + // hard gate to "eligible for all" — the consequential direction to fail + // in — so it warns like the map parsers below (PR #173 review H3). + let raw: Vec = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + tracing::warn!(tag, error = %e, "malformed bulk_class_gate JSON — gate widens to all classes"); + Vec::new() + } + }; + raw.iter() + .filter_map(|s| { + let parsed = parse_bulk_class(s); + if parsed.is_none() { + tracing::warn!(tag, value = s, "unrecognized bulk_class_gate value"); + } + parsed + }) + .collect() +} + +fn parse_production_ubiquity_gate(json: Option<&str>, tag: &str) -> Vec { + let Some(json) = json else { return Vec::new() }; + // Same hard-gate-widening concern as parse_bulk_class_gate above (H3). + let raw: Vec = match serde_json::from_str(json) { + Ok(v) => v, + Err(e) => { + tracing::warn!(tag, error = %e, "malformed production_ubiquity_gate JSON — gate widens to all values"); + Vec::new() + } + }; + raw.iter() + .filter_map(|s| { + let parsed = parse_production_ubiquity(s); + if parsed.is_none() { + tracing::warn!( + tag, + value = s, + "unrecognized production_ubiquity_gate value" + ); + } + parsed + }) + .collect() +} + +fn parse_weight_mods( + json: Option<&str>, + tag: &str, +) -> std::collections::BTreeMap> { + let Some(json) = json else { + return Default::default(); + }; + match serde_json::from_str::< + std::collections::BTreeMap>, + >(json) + { + Ok(m) => m, + Err(e) => { + tracing::warn!(tag, error = %e, "malformed weight_mods JSON — treating as empty"); + Default::default() + } + } +} + +fn parse_zone_affinity( + json: Option<&str>, + tag: &str, +) -> std::collections::BTreeMap { + let Some(json) = json else { + return Default::default(); + }; + let raw: std::collections::BTreeMap = match serde_json::from_str(json) { + Ok(m) => m, + Err(e) => { + tracing::warn!(tag, error = %e, "malformed zone_affinity JSON — treating as empty"); + return Default::default(); + } + }; + raw.into_iter() + .filter_map(|(k, v)| match parse_district_type(&k) { + Some(dt) => Some((dt, v)), + None => { + tracing::warn!(tag, key = k, "unrecognized zone_affinity DistrictType key"); + None + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Bevy resource wrapper +// --------------------------------------------------------------------------- + +/// Bevy `Resource` wrapper — `Res` in systems. +/// Mirrors `CityContextReaderResource`. +#[derive(bevy_ecs::prelude::Resource)] +pub struct TraitCatalogReaderResource(pub TraitCatalogReader); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU32, Ordering}; + + static SEQ: AtomicU32 = AtomicU32::new(0); + + fn make_test_db() -> PathBuf { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("sr_traitcat_{}_{n}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let conn = Connection::open(&path).expect("create db"); + conn.execute_batch( + "CREATE TABLE 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 + ); + CREATE TABLE atlas_body_trait_bias ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL, + template_tag TEXT NOT NULL, + bias_kind TEXT NOT NULL, + weight_multiplier_bps INTEGER, + note TEXT + );", + ) + .expect("create tables"); + + conn.execute( + "INSERT INTO trait_templates + (tag, label, corridor_pool, geographic_sector, bulk_class_gate, + production_ubiquity_gate, min_prosperity_bps, base_weight, + weight_mods, zone_affinity) + VALUES ('generic_baseline', 'Generic Baseline', 'cross_corridor', NULL, + NULL, NULL, 0, 8000, NULL, + '{\"Residential\":10000,\"Commercial\":10000,\"MixedUse\":11000}')", + [], + ) + .expect("insert generic_baseline"); + + conn.execute( + "INSERT INTO trait_templates + (tag, label, corridor_pool, geographic_sector, bulk_class_gate, + production_ubiquity_gate, min_prosperity_bps, base_weight, + weight_mods, zone_affinity) + VALUES ('extraction_camp', 'Extraction Camp', 'cross_corridor', NULL, + '[\"BulkSolid\",\"BulkLiquid\"]', '[\"MonopolySource\",\"Specialist\"]', + 0, 12000, + '{\"economic_role\":{\"mining\":20000},\"geographic_sector\":{\"east_reach\":13000}}', + '{\"Industrial\":20000,\"LogisticsHub\":13000}')", + [], + ) + .expect("insert extraction_camp"); + + conn.execute( + "INSERT INTO atlas_body_trait_bias (body_id, template_tag, bias_kind, weight_multiplier_bps) + VALUES ('HeroBody', 'generic_baseline', 'pin', NULL)", + [], + ) + .expect("insert bias"); + conn.execute( + "INSERT INTO atlas_body_trait_bias (body_id, template_tag, bias_kind, weight_multiplier_bps) + VALUES ('HeroBody', 'extraction_camp', 'boost', 20000)", + [], + ) + .expect("insert bias 2"); + + drop(conn); + path + } + + #[test] + fn read_catalog_parses_gates_and_maps() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let catalog = reader.read_catalog().expect("read catalog"); + assert_eq!(catalog.len(), 2); + // Ordered by tag: extraction_camp < generic_baseline. + assert_eq!(catalog[0].tag, "extraction_camp"); + assert_eq!( + catalog[0].bulk_class_gate, + vec![BulkClass::BulkSolid, BulkClass::BulkLiquid] + ); + assert_eq!( + catalog[0].production_ubiquity_gate, + vec![ + ProductionUbiquity::MonopolySource, + ProductionUbiquity::Specialist + ] + ); + assert_eq!( + catalog[0].zone_affinity.get(&DistrictType::Industrial), + Some(&20000) + ); + assert_eq!( + catalog[0] + .weight_mods + .get("geographic_sector") + .and_then(|m| m.get("east_reach")), + Some(&13000) + ); + + assert_eq!(catalog[1].tag, "generic_baseline"); + assert!(catalog[1].bulk_class_gate.is_empty()); + assert_eq!( + catalog[1].zone_affinity.get(&DistrictType::MixedUse), + Some(&11000) + ); + } + + #[test] + fn read_catalog_is_cached_after_first_read() { + // H5 (PR #173): the catalog is immutable per server run — the first + // read is authoritative. Mutating the DB afterwards must NOT change + // what read_catalog returns (proves the SQL/parse pass ran once). + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let first = reader.read_catalog().expect("first read"); + assert_eq!(first.len(), 2); + + let writer = Connection::open(&db).expect("second connection"); + writer + .execute( + "INSERT INTO trait_templates (tag, label) VALUES ('late_row', 'Late Row')", + [], + ) + .expect("insert after first read"); + + let second = reader.read_catalog().expect("second read"); + assert_eq!( + second, first, + "cached catalog must not see post-read DB writes" + ); + } + + #[test] + fn read_body_bias_returns_sparse_rows() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let bias = reader.read_body_bias("HeroBody").expect("read bias"); + assert_eq!(bias.len(), 2); + assert_eq!(bias[0].template_tag, "extraction_camp"); + assert_eq!(bias[0].bias_kind, BiasKind::Boost); + assert_eq!(bias[0].weight_multiplier_bps, Some(20000)); + assert_eq!(bias[1].template_tag, "generic_baseline"); + assert_eq!(bias[1].bias_kind, BiasKind::Pin); + assert_eq!(bias[1].weight_multiplier_bps, None); + } + + #[test] + fn read_body_bias_empty_for_non_hero_body() { + let db = make_test_db(); + let reader = TraitCatalogReader::open(&db).expect("open"); + let bias = reader + .read_body_bias("SomeOrdinaryBody") + .expect("read bias"); + assert!(bias.is_empty()); + } +} diff --git a/server/src/atlas/trait_draw.rs b/server/src/atlas/trait_draw.rs new file mode 100644 index 000000000..5583e465e --- /dev/null +++ b/server/src/atlas/trait_draw.rs @@ -0,0 +1,1055 @@ +//! D-232 three-phase trait-template draw (T-994) — replaces the flat +//! `flavor_index = splitmix64(seed ^ zone_type_hash) % trait_selection.len()` +//! pick that shipped in `skeleton_gen.rs`. +//! +//! **Phase 1 — body vocabulary K-draw** ([`draw_body_vocabulary`]): hard-gate +//! filter (`bulk_class`/`production_ubiquity`/`min_prosperity_bps`) → weight +//! (`base_weight` × soft modifiers × hero-body bias) → a `SeedChain`-seeded +//! weighted draw of K templates (K locked to `ComplexityTier`), coverage-aware +//! over the body's actual district-type mix. Seeded from +//! `SeedChain::for_body` (not the per-settlement chain) so every settlement on +//! the same body draws the identical closed vocabulary (D-232's closed-vocabulary +//! invariant). +//! +//! **Phase 2 — district-dominant pick** ([`pick_district_dominant_by_type`]): +//! for each `DistrictType`, one template from the body vocabulary is chosen by +//! `zone_affinity`, keyed by the settlement's D-243 2 048 m District cell so +//! every settlement sharing a district independently derives the identical +//! answer (no cross-settlement coordination needed — same seed, same key). +//! +//! **Phase 3 — within-template seed picks** is the existing per-building +//! variation in `skeleton_gen.rs` (zone type, era, floor extent) — untouched by +//! this ticket. +//! +//! This module is pure (no DB access) — callers pre-resolve the catalog + +//! per-body bias via [`crate::atlas::trait_catalog_reader::TraitCatalogReader`] +//! at L3→L4 dispatch time (D-225 pattern), then call these functions. + +use std::collections::BTreeMap; + +use crate::atlas::chunk_context::pos_to_id; +use crate::atlas::trait_catalog_reader::{BiasKind, TraitBias, TraitTemplate}; +use crate::atlas::trait_swerve::necessity_swerve; +use crate::seed::{SeedChain, SeedDomain}; +use crate::simulation::generator::{ + ArchitectureFlavorRef, BulkClass, ComplexityTier, DistrictType, ProductionUbiquity, +}; + +// `ComplexityTier` is body-consumed but fundamentally per-settlement (it derives +// from a settlement's own population + the body's WorldTier, D-194/D-218) — a +// body with settlements of mixed complexity has no single "body ComplexityTier". +// `draw_body_vocabulary` therefore takes a raw `k: usize` (see +// `VocabularyDrawInputs::k`) rather than a `&ComplexityTier`; callers aggregating +// across a body's settlements compute `k` as the MAX of `complexity_k(tier)` over +// every settlement (a smaller settlement drawing from a richer shared vocabulary +// is harmless — phase 2's `zone_affinity` weighting still favours what that +// settlement actually needs). + +/// All 9 `DistrictType` variants, in a fixed deterministic order (D-010) — the +/// phase-2 pick resolves one dominant template per entry. +const ALL_DISTRICT_TYPES: [DistrictType; 9] = [ + DistrictType::LogisticsHub, + DistrictType::Residential, + DistrictType::Commercial, + DistrictType::Industrial, + DistrictType::Administrative, + DistrictType::Entertainment, + DistrictType::MixedUse, + DistrictType::Transit, + DistrictType::Specialized, +]; + +/// Stable 0..=8 ordinal for a `DistrictType` — the phase-2 seed's second-level id. +fn district_type_ordinal(dt: &DistrictType) -> u64 { + ALL_DISTRICT_TYPES.iter().position(|d| d == dt).unwrap_or(0) as u64 +} + +/// K locked to `ComplexityTier` (D-232 round 3, Nigel's birthday math). +pub fn complexity_k(complexity: &ComplexityTier) -> usize { + match complexity { + ComplexityTier::Full => 5, + ComplexityTier::Moderate => 3, + ComplexityTier::Minimal => 1, + ComplexityTier::Empty => 0, + } +} + +/// Soft down-weight applied when a template's own `geographic_sector` column +/// names a *different* corridor than the body's (basis points; 10 000 = 1.0×). +/// +/// Pragmatic placeholder — D-232 pins "soft, never a gate" but does not specify +/// a magnitude; 4000 bps (0.4×) meaningfully narrows the pool toward the body's +/// own corridor without approaching exclusion. Needs Nigel/Burnelli calibration +/// once real multi-corridor bodies are authored. +const SECTOR_MISMATCH_BPS: u64 = 4_000; + +// --------------------------------------------------------------------------- +// Phase 1 — body vocabulary K-draw +// --------------------------------------------------------------------------- + +/// Body-level inputs to the phase-1 K-draw (T-994). Grouped into a struct to +/// keep the function signature under the clippy `too_many_arguments` threshold. +pub struct VocabularyDrawInputs<'a> { + /// Number of templates to draw. Callers derive this via [`complexity_k`]; + /// see the module-level note on why this is a raw count, not a + /// `&ComplexityTier`. + pub k: usize, + /// Hard gate. Currently a single settlement-level stub (D-233's dominant + /// commodity derivation, #982, is design-blocked) rather than a body-wide + /// aggregate — see the module-level note in `trait_catalog_reader.rs` on + /// why `weight_mods.economic_role`/`founding_age` are similarly left + /// un-aggregated. Revisit once #982 lands. + pub dominant_bulk_class: &'a BulkClass, + pub dominant_production_ubiquity: &'a ProductionUbiquity, + /// MAX `prosperity_baseline_bps` across every settlement on the body — the + /// gate is coverage-aware (a rich settlement's needs should not be excluded + /// by a poor sibling's economics). + pub max_prosperity_bps: u32, + /// The body's system corridor (`star_systems.geographic_sector`). A soft + /// weight only (D-232, PR #148 review note — two-part join with each + /// template's own `geographic_sector` column). + pub geographic_sector: Option<&'a str>, + /// Every `DistrictType` present anywhere on the body (deduped). The draw + /// guarantees ≥1 eligible template with nonzero `zone_affinity` for each. + pub coverage_district_types: &'a [DistrictType], +} + +/// Effective weight (basis points) for one template, given the body's +/// geographic_sector and this body's hero bias (D-232). Never returns 0 — a +/// zero weight would make the template undrawable by chance alone and unable +/// to satisfy a coverage repair, defeating the "closed but always coherent" +/// invariant; the floor mirrors the authored bias range (suppress ≥ 0.33×). +fn effective_weight_bps( + t: &TraitTemplate, + bias_by_tag: &BTreeMap<&str, &TraitBias>, + geographic_sector: Option<&str>, +) -> u64 { + let mut w = t.base_weight as u64; + + // (a) geographic_sector COLUMN — soft pool-narrowing when the template is + // pinned to a different corridor than the body's own. `None` on either side + // (shared/cross-corridor template, or a body with no recorded sector) never + // narrows (PR #148: the column is a hint, not a gate). + if let (Some(sector), Some(body_sector)) = (t.geographic_sector.as_deref(), geographic_sector) { + if sector != body_sector { + w = (w * SECTOR_MISMATCH_BPS) / 10_000; + } + } + + // (b) weight_mods.geographic_sector — the template's own authored boost/cut + // for this exact sector (two-part join per PR #148: (a) and (b) both apply). + if let Some(body_sector) = geographic_sector { + if let Some(mult) = t + .weight_mods + .get("geographic_sector") + .and_then(|m| m.get(body_sector)) + { + w = (w * (*mult as u64)) / 10_000; + } + } + + // Hero-body wiki bias (boost/suppress; pin is handled separately as forced + // inclusion, not a weight multiplier). + if let Some(b) = bias_by_tag.get(t.tag.as_str()) { + if let Some(mult) = b.weight_multiplier_bps { + w = (w * (mult as u64)) / 10_000; + } + } + + w.max(1) +} + +/// Whether `tag` (looked up in `catalog`) has nonzero `zone_affinity` for `dt`. +fn covers_district_type(catalog: &[TraitTemplate], tag: &str, dt: &DistrictType) -> bool { + catalog + .iter() + .find(|t| t.tag == tag) + .and_then(|t| t.zone_affinity.get(dt)) + .is_some_and(|w| *w > 0) +} + +/// The D-233 hard-gate filter (D-232 two-tier eligibility, tier 1): a template +/// excluded here is out of the pool entirely — for the phase-1 vocabulary draw +/// AND the T-1003 swerve pools (the swerve is *cultural only*; a building's +/// function still passes the economic hard gates, D-232). +pub fn hard_gate_eligible<'a>( + catalog: &'a [TraitTemplate], + inputs: &VocabularyDrawInputs, +) -> Vec<&'a TraitTemplate> { + catalog + .iter() + .filter(|t| { + t.bulk_class_gate.is_empty() || t.bulk_class_gate.contains(inputs.dominant_bulk_class) + }) + .filter(|t| { + t.production_ubiquity_gate.is_empty() + || t.production_ubiquity_gate + .contains(inputs.dominant_production_ubiquity) + }) + .filter(|t| t.min_prosperity_bps <= inputs.max_prosperity_bps) + .collect() +} + +/// Phase 1 (D-232): draw the body's closed K-template vocabulary. +/// +/// `chain` must be `SeedChain::for_body(world_seed, body_id)` — **not** a +/// per-settlement chain — so every settlement on the body draws the identical +/// vocabulary (the closed-vocabulary invariant this whole mechanism exists to +/// protect). This function derives its own `SeedDomain::TraitVocabulary` +/// sub-stream internally. +/// +/// Returns the selected tags in draw order (pins first, then the weighted +/// draw, then any coverage-repair substitutions). Empty when `K == 0` +/// (`ComplexityTier::Empty`) or the catalog has no hard-gate-eligible template. +/// +/// **Heritage templates never enter the ordinary lottery** (PR #173 review T1): +/// D-232's corridor two-part pool reserves the `heritage` sub-pool for the +/// remoteness dial — the T-1003 heritage-callback swerve — so the ordinary +/// weighted draw and coverage repair filter it out. A heritage template still +/// reaches a body via an authored hero **pin** (an explicit wiki decision) or +/// via the swerve/necessity paths, which read the full eligible set. +pub fn draw_body_vocabulary( + catalog: &[TraitTemplate], + bias: &[TraitBias], + inputs: &VocabularyDrawInputs, + chain: SeedChain, +) -> Vec { + let k = inputs.k; + if k == 0 || catalog.is_empty() { + return Vec::new(); + } + + let eligible = hard_gate_eligible(catalog, inputs); + if eligible.is_empty() { + return Vec::new(); + } + + let bias_by_tag: BTreeMap<&str, &TraitBias> = + bias.iter().map(|b| (b.template_tag.as_str(), b)).collect(); + + // ── Pins (mandatory, count toward K) ──────────────────────────────────── + // Pins still pass the hard gates above — a hero pin represents an iconic + // building for that body, but its *function* must still make economic + // sense (channel separation, D-232/D-233). Pins may be heritage templates: + // an authored pin is an explicit wiki decision, not a lottery outcome. + let mut selection: Vec<(String, u64, bool)> = Vec::new(); // (tag, weight, pinned) + for t in &eligible { + if matches!( + bias_by_tag.get(t.tag.as_str()), + Some(b) if b.bias_kind == BiasKind::Pin + ) { + selection.push((t.tag.clone(), 0, true)); + } + } + // "Pins count toward K" (D-232) presumes pins ≤ K. Authoring more pins + // than the body's K is a wiki-bias content error (the V-TT-05 importer + // guardrail bounds it); if it slips through anyway, keep every pin + // (authored intent outranks the tier budget) but say so loudly — the + // K-locked invariant is violated by data, not by this draw. + if selection.len() > k { + tracing::warn!( + pins = selection.len(), + k, + "atlas_body_trait_bias pins exceed this body's K — vocabulary exceeds tier budget" + ); + } + + // ── Weighted draw without replacement for the remaining slots ─────────── + // Heritage templates are excluded from the ordinary lottery — D-232 + // reserves the heritage sub-pool for the remoteness dial (T-1003 swerve). + let mut pool: Vec<(&TraitTemplate, u64)> = eligible + .iter() + .filter(|t| t.corridor_pool != "heritage") + .filter(|t| !selection.iter().any(|(tag, _, _)| tag == &t.tag)) + .map(|t| { + ( + *t, + effective_weight_bps(t, &bias_by_tag, inputs.geographic_sector), + ) + }) + .collect(); + let mut rng = chain.derive(SeedDomain::TraitVocabulary, 0).atlas_rng(); + let mut remaining = k.saturating_sub(selection.len()); + while remaining > 0 && !pool.is_empty() { + let total: u64 = pool.iter().map(|(_, w)| *w).sum(); + let mut roll = (rng.next_u32() as u64) % total.max(1); + let mut idx = 0; + for (i, (_, w)) in pool.iter().enumerate() { + if roll < *w { + idx = i; + break; + } + roll -= w; + } + let (picked, w) = pool.remove(idx); + selection.push((picked.tag.clone(), w, false)); + remaining -= 1; + } + + // ── Coverage repair (D-232: "must cover the body's actual district-type + // mix, not draw K templates that all starve the civic district") ──────── + for dt in inputs.coverage_district_types { + if selection + .iter() + .any(|(tag, _, _)| covers_district_type(catalog, tag, dt)) + { + continue; + } + // Find the best not-yet-selected eligible candidate covering `dt`. + // Heritage templates stay excluded here too (same T1 rule as the + // lottery). `max_by_key` returns the LAST maximal element on ties — + // deterministic given the catalog's stable tag-sorted order (D-010). + let candidate = eligible + .iter() + .filter(|t| t.corridor_pool != "heritage") + .filter(|t| !selection.iter().any(|(tag, _, _)| tag == &t.tag)) + .filter(|t| t.zone_affinity.get(dt).copied().unwrap_or(0) > 0) + .map(|t| { + ( + *t, + effective_weight_bps(t, &bias_by_tag, inputs.geographic_sector), + ) + }) + .max_by_key(|(_, w)| *w); + let Some((winner, w)) = candidate else { + // No eligible template anywhere covers this district type — a + // catalog content gap (the CI guardrails, V-TT-01/V-TT-02, are + // meant to prevent this), not something the draw can fix. + tracing::debug!(?dt, "no eligible trait template covers this DistrictType"); + continue; + }; + // Swap out the lowest-weight non-pinned member to keep K fixed (D-232: + // "K is not a range"). When every current member is pinned there is no + // slot to repair into — leave the type uncovered and let phase 2's + // necessity_swerve (the REAL sparsity escape hatch, out-of-vocabulary + // by design) serve it, rather than growing trait_selection past K + // (PR #173 review T2). + let swap_idx = selection + .iter() + .enumerate() + .filter(|(_, (_, _, pinned))| !pinned) + .min_by_key(|(_, (_, w, _))| *w) + .map(|(i, _)| i); + match swap_idx { + Some(i) => selection[i] = (winner.tag.clone(), w, false), + None => { + tracing::debug!( + ?dt, + "all K slots pinned — leaving district type to the phase-2 necessity swerve" + ); + } + } + } + + selection.into_iter().map(|(tag, _, _)| tag).collect() +} + +// --------------------------------------------------------------------------- +// Phase 2 — district-dominant pick +// --------------------------------------------------------------------------- + +/// Phase 2 (D-232): resolve the dominant template for every `DistrictType`, +/// keyed by the settlement's D-243 2 048 m District cell. +/// +/// `body_chain` must be `SeedChain::for_body(world_seed, body_id)` — two +/// settlements whose quarters share `district_pos` independently derive the +/// identical dominant template for a given `DistrictType` (same seed, same +/// key), which is exactly the "coherent 2 048 m area reads as one style" +/// invariant — no cross-settlement coordination is needed. +/// +/// Pre-resolved at L3→L4 dispatch time (T-994), **not** inside `FillChunk` +/// (T-987 keeps fill pure/cache-free) and not even inside the `GenerateSkeleton` +/// Rayon task — the inputs (`trait_selection` + the catalog's `zone_affinity`) +/// are already known once `trait_selection` is drawn, so resolving here keeps +/// the Rayon task's `assign_block_tags` a cheap infallible `BTreeMap` lookup. +/// +/// Always returns all 9 `DistrictType` entries. A type with no covering +/// candidate in `trait_selection` triggers the **sparsity escape hatch** +/// (T-1003, D-232: "the SAME mechanism triggered by necessity rather than +/// dice") — the pick reaches the full hard-gate-eligible catalog +/// (`trait_swerve::necessity_swerve`) and records the result as an +/// out-of-vocabulary `ArchitectureFlavorRef::Swerve`. Only when *nothing* +/// eligible covers the type either (a catalog content gap the V-TT-01/V-TT-02 +/// guardrails exist to prevent) does it fall back to `InVocabulary(0)` — the +/// pre-T-994 degenerate behaviour. +pub fn pick_district_dominant_by_type( + catalog: &[TraitTemplate], + eligible: &[&TraitTemplate], + trait_selection: &[String], + body_chain: SeedChain, + district_pos: (i32, i32), +) -> BTreeMap { + let mut out = BTreeMap::new(); + let pos_id = pos_to_id(district_pos); + + for dt in &ALL_DISTRICT_TYPES { + let candidates: Vec<(u8, u32)> = trait_selection + .iter() + .enumerate() + .filter_map(|(i, tag)| { + catalog + .iter() + .find(|t| &t.tag == tag) + .and_then(|t| t.zone_affinity.get(dt)) + .filter(|w| **w > 0) + .map(|w| (i as u8, *w)) + }) + .collect(); + + if candidates.is_empty() { + let picked = match necessity_swerve(eligible, |t| { + t.zone_affinity.get(dt).copied().unwrap_or(0) > 0 + }) { + Some(tag) => ArchitectureFlavorRef::Swerve(tag), + None => { + tracing::debug!( + ?dt, + "no eligible template covers this DistrictType — catalog content gap" + ); + ArchitectureFlavorRef::InVocabulary(0) + } + }; + out.insert(dt.clone(), picked); + continue; + } + + let picked = { + let mut rng = body_chain + .derive(SeedDomain::TraitDistrict, pos_id) + .derive(SeedDomain::TraitDistrict, district_type_ordinal(dt)) + .atlas_rng(); + let total: u64 = candidates.iter().map(|(_, w)| *w as u64).sum(); + let mut roll = (rng.next_u32() as u64) % total.max(1); + let mut picked = candidates[0].0; + for (idx, w) in &candidates { + if roll < *w as u64 { + picked = *idx; + break; + } + roll -= *w as u64; + } + picked + }; + out.insert(dt.clone(), ArchitectureFlavorRef::InVocabulary(picked)); + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn tmpl( + tag: &str, + base_weight: u32, + bulk_gate: &[BulkClass], + min_prosperity_bps: u32, + zone_affinity: &[(DistrictType, u32)], + ) -> TraitTemplate { + TraitTemplate { + tag: tag.to_string(), + corridor_pool: "cross_corridor".to_string(), + geographic_sector: None, + bulk_class_gate: bulk_gate.to_vec(), + production_ubiquity_gate: Vec::new(), + min_prosperity_bps, + base_weight, + weight_mods: BTreeMap::new(), + zone_affinity: zone_affinity.iter().cloned().collect(), + } + } + + fn base_inputs<'a>( + k: usize, + dominant_bulk_class: &'a BulkClass, + dominant_production_ubiquity: &'a ProductionUbiquity, + coverage: &'a [DistrictType], + ) -> VocabularyDrawInputs<'a> { + VocabularyDrawInputs { + k, + dominant_bulk_class, + dominant_production_ubiquity, + max_prosperity_bps: 9_000, + geographic_sector: None, + coverage_district_types: coverage, + } + } + + #[test] + fn complexity_k_matches_d232_table() { + assert_eq!(complexity_k(&ComplexityTier::Full), 5); + assert_eq!(complexity_k(&ComplexityTier::Moderate), 3); + assert_eq!(complexity_k(&ComplexityTier::Minimal), 1); + assert_eq!(complexity_k(&ComplexityTier::Empty), 0); + } + + #[test] + fn empty_complexity_draws_nothing() { + let catalog = vec![tmpl( + "a", + 10_000, + &[], + 0, + &[(DistrictType::Residential, 10_000)], + )]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Empty), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(1, "Body")); + assert!(sel.is_empty()); + } + + #[test] + fn k_locked_to_complexity_tier() { + let catalog: Vec = (0..10) + .map(|i| { + tmpl( + &format!("t{i}"), + 10_000, + &[], + 0, + &[(DistrictType::MixedUse, 10_000)], + ) + }) + .collect(); + let full = base_inputs( + complexity_k(&ComplexityTier::Full), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let sel = draw_body_vocabulary(&catalog, &[], &full, SeedChain::for_body(1, "Body")); + assert_eq!(sel.len(), 5); + + let minimal = base_inputs( + complexity_k(&ComplexityTier::Minimal), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let sel = draw_body_vocabulary(&catalog, &[], &minimal, SeedChain::for_body(1, "Body")); + assert_eq!(sel.len(), 1); + } + + #[test] + fn hard_gate_excludes_wrong_bulk_class() { + let catalog = vec![ + tmpl( + "solid_only", + 10_000, + &[BulkClass::BulkSolid], + 0, + &[(DistrictType::Industrial, 10_000)], + ), + tmpl( + "any_bulk", + 10_000, + &[], + 0, + &[(DistrictType::Residential, 10_000)], + ), + ]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(7, "Body")); + assert_eq!(sel, vec!["any_bulk".to_string()]); + } + + #[test] + fn min_prosperity_gate_excludes_below_threshold() { + let catalog = vec![tmpl( + "expensive", + 10_000, + &[], + 5_000, + &[(DistrictType::Residential, 10_000)], + )]; + let mut inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + inputs.max_prosperity_bps = 2_000; + let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(7, "Body")); + assert!(sel.is_empty()); + } + + #[test] + fn pin_forces_inclusion_and_counts_toward_k() { + let catalog = vec![ + tmpl( + "hero_pin", + 1, + &[], + 0, + &[(DistrictType::Administrative, 10_000)], + ), + tmpl( + "filler", + 10_000, + &[], + 0, + &[(DistrictType::MixedUse, 10_000)], + ), + ]; + let bias = vec![TraitBias { + template_tag: "hero_pin".to_string(), + bias_kind: BiasKind::Pin, + weight_multiplier_bps: None, + }]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), // K=1 + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let sel = draw_body_vocabulary(&catalog, &bias, &inputs, SeedChain::for_body(3, "Body")); + assert_eq!(sel, vec!["hero_pin".to_string()]); + } + + #[test] + fn coverage_repair_swaps_in_a_template_for_an_uncovered_district_type() { + // Two templates only cover MixedUse; K=1 draw would starve Administrative + // if it were present in the body's coverage — the repair pass must pull + // in a template that covers it, even though it's not the highest weight. + let catalog = vec![ + tmpl( + "mixed_a", + 20_000, + &[], + 0, + &[(DistrictType::MixedUse, 10_000)], + ), + tmpl( + "mixed_b", + 15_000, + &[], + 0, + &[(DistrictType::MixedUse, 10_000)], + ), + tmpl( + "civic", + 5_000, + &[], + 0, + &[(DistrictType::Administrative, 10_000)], + ), + ]; + let coverage = vec![DistrictType::MixedUse, DistrictType::Administrative]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), // K=1 + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &coverage, + ); + let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(11, "Body")); + assert_eq!(sel.len(), 1, "K stays fixed at 1 even after repair"); + assert_eq!( + sel[0], "civic", + "the sole slot must cover Administrative since MixedUse alone starves it" + ); + } + + fn tmpl_in_pool( + tag: &str, + corridor_pool: &str, + base_weight: u32, + zone_affinity: &[(DistrictType, u32)], + ) -> TraitTemplate { + TraitTemplate { + corridor_pool: corridor_pool.to_string(), + ..tmpl(tag, base_weight, &[], 0, zone_affinity) + } + } + + // ── T1 (PR #173): heritage sub-pool stays out of the ordinary lottery ──── + + #[test] + fn heritage_templates_never_enter_the_ordinary_lottery() { + // One heritage template with overwhelming weight + one modest baseline. + // Across many bodies the heritage tag must never be drawn — it is + // reserved for the remoteness dial (T-1003) and hero pins. + let catalog = vec![ + tmpl_in_pool( + "old_hacienda", + "heritage", + 1_000_000, + &[(DistrictType::MixedUse, 10_000)], + ), + tmpl_in_pool( + "plain_baseline", + "baseline", + 1_000, + &[(DistrictType::MixedUse, 10_000)], + ), + ]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), // K=1: one slot, worst case + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + for body in 0..50 { + let sel = draw_body_vocabulary( + &catalog, + &[], + &inputs, + SeedChain::for_body(9, &format!("Body{body}")), + ); + assert_eq!( + sel, + vec!["plain_baseline".to_string()], + "heritage must never win the ordinary lottery (body {body})" + ); + } + } + + #[test] + fn heritage_template_reaches_selection_via_pin() { + let catalog = vec![ + tmpl_in_pool( + "old_hacienda", + "heritage", + 1, + &[(DistrictType::MixedUse, 10_000)], + ), + tmpl_in_pool( + "plain_baseline", + "baseline", + 10_000, + &[(DistrictType::MixedUse, 10_000)], + ), + ]; + let bias = vec![TraitBias { + template_tag: "old_hacienda".to_string(), + bias_kind: BiasKind::Pin, + weight_multiplier_bps: None, + }]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let sel = draw_body_vocabulary(&catalog, &bias, &inputs, SeedChain::for_body(3, "Body")); + assert_eq!( + sel, + vec!["old_hacienda".to_string()], + "an authored hero pin overrides the heritage lottery exclusion" + ); + } + + #[test] + fn coverage_repair_skips_heritage_candidates() { + // Only a heritage template covers Administrative — the repair pass must + // NOT pull it in; the type stays uncovered for the phase-2 necessity + // swerve to serve out-of-vocabulary. + let catalog = vec![ + tmpl_in_pool( + "plain_baseline", + "baseline", + 10_000, + &[(DistrictType::MixedUse, 10_000)], + ), + tmpl_in_pool( + "heritage_civic", + "heritage", + 10_000, + &[(DistrictType::Administrative, 10_000)], + ), + ]; + let coverage = vec![DistrictType::MixedUse, DistrictType::Administrative]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &coverage, + ); + let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(5, "Body")); + assert_eq!( + sel, + vec!["plain_baseline".to_string()], + "coverage repair must not draft heritage templates" + ); + } + + // ── T2 + H4 (PR #173): the pins/K seam ─────────────────────────────────── + + #[test] + fn coverage_repair_never_grows_selection_past_k_when_all_slots_pinned() { + // K=1, the single slot is a pin covering only MixedUse; Administrative + // is in the coverage set and coverable by a baseline template. The old + // behaviour pushed a second entry past K; now the type is left to the + // phase-2 necessity swerve and the vocabulary stays exactly the pin. + let catalog = vec![ + tmpl_in_pool( + "hero_pin", + "baseline", + 10_000, + &[(DistrictType::MixedUse, 10_000)], + ), + tmpl_in_pool( + "civic", + "baseline", + 10_000, + &[(DistrictType::Administrative, 10_000)], + ), + ]; + let bias = vec![TraitBias { + template_tag: "hero_pin".to_string(), + bias_kind: BiasKind::Pin, + weight_multiplier_bps: None, + }]; + let coverage = vec![DistrictType::MixedUse, DistrictType::Administrative]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), // K=1 + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &coverage, + ); + let sel = draw_body_vocabulary(&catalog, &bias, &inputs, SeedChain::for_body(7, "Body")); + assert_eq!( + sel, + vec!["hero_pin".to_string()], + "K is not a range — repair must not grow the vocabulary past K" + ); + } + + #[test] + fn pins_exceeding_k_are_all_kept() { + // Two authored pins on a K=1 body: a content error the V-TT-05 importer + // guardrail bounds, but if it slips through, authored intent outranks + // the tier budget — both pins survive (with a runtime warning). + let catalog = vec![ + tmpl_in_pool("pin_a", "baseline", 10, &[(DistrictType::MixedUse, 10_000)]), + tmpl_in_pool( + "pin_b", + "baseline", + 10, + &[(DistrictType::Residential, 10_000)], + ), + ]; + let bias = vec![ + TraitBias { + template_tag: "pin_a".to_string(), + bias_kind: BiasKind::Pin, + weight_multiplier_bps: None, + }, + TraitBias { + template_tag: "pin_b".to_string(), + bias_kind: BiasKind::Pin, + weight_multiplier_bps: None, + }, + ]; + let inputs = base_inputs( + complexity_k(&ComplexityTier::Minimal), // K=1 + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let sel = draw_body_vocabulary(&catalog, &bias, &inputs, SeedChain::for_body(7, "Body")); + assert_eq!(sel.len(), 2, "both authored pins are kept"); + assert!(sel.contains(&"pin_a".to_string()) && sel.contains(&"pin_b".to_string())); + } + + #[test] + fn same_body_same_world_seed_draws_identical_vocabulary() { + let catalog: Vec = (0..8) + .map(|i| { + tmpl( + &format!("t{i}"), + 10_000 + i * 500, + &[], + 0, + &[(DistrictType::MixedUse, 10_000)], + ) + }) + .collect(); + let inputs = base_inputs( + complexity_k(&ComplexityTier::Full), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let a = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c")); + let b = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c")); + assert_eq!(a, b, "identical inputs must draw the identical vocabulary"); + } + + #[test] + fn different_body_id_draws_different_vocabulary_stream() { + let catalog: Vec = (0..12) + .map(|i| { + tmpl( + &format!("t{i}"), + 10_000, + &[], + 0, + &[(DistrictType::MixedUse, 10_000)], + ) + }) + .collect(); + let inputs = base_inputs( + complexity_k(&ComplexityTier::Full), + &BulkClass::NonPhysical, + &ProductionUbiquity::Common, + &[], + ); + let a = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c")); + let b = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1d")); + assert_ne!(a, b, "distinct bodies must not share the exact same draw"); + } + + // ── Phase 2 ─────────────────────────────────────────────────────────────── + + #[test] + fn district_dominant_covers_all_nine_types() { + let catalog = vec![tmpl( + "generic", + 10_000, + &[], + 0, + &ALL_DISTRICT_TYPES.map(|dt| (dt, 10_000)), + )]; + let selection = vec!["generic".to_string()]; + let map = pick_district_dominant_by_type( + &catalog, + &[], + &selection, + SeedChain::for_body(1, "Body"), + (3, 5), + ); + assert_eq!(map.len(), 9); + for dt in &ALL_DISTRICT_TYPES { + assert_eq!(map.get(dt), Some(&ArchitectureFlavorRef::InVocabulary(0))); + } + } + + #[test] + fn same_district_pos_same_body_gives_identical_dominant_pick() { + let catalog = vec![ + tmpl("a", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), + tmpl("b", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), + ]; + let selection = vec!["a".to_string(), "b".to_string()]; + let chain = SeedChain::for_body(9, "Body"); + let m1 = pick_district_dominant_by_type(&catalog, &[], &selection, chain, (2, 2)); + let m2 = pick_district_dominant_by_type(&catalog, &[], &selection, chain, (2, 2)); + assert_eq!( + m1.get(&DistrictType::Commercial), + m2.get(&DistrictType::Commercial), + "identical (body, district_pos) must derive the identical dominant pick" + ); + } + + #[test] + fn different_district_pos_can_pick_differently() { + // Not a strict guarantee for any two positions, but across a spread of + // positions the picks must not all collapse to one constant index — + // otherwise the seed derivation isn't actually keyed by position. + let catalog = vec![ + tmpl("a", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), + tmpl("b", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]), + ]; + let selection = vec!["a".to_string(), "b".to_string()]; + let chain = SeedChain::for_body(9, "Body"); + let picks: std::collections::BTreeSet = (0..20) + .map(|i| { + let map = pick_district_dominant_by_type( + &catalog, + &[], + &selection, + chain, + (i, i * 3 + 1), + ); + match map.get(&DistrictType::Commercial) { + Some(ArchitectureFlavorRef::InVocabulary(idx)) => *idx, + other => panic!("expected an in-vocabulary pick, got {other:?}"), + } + }) + .collect(); + assert!( + picks.len() > 1, + "expected variation in the dominant pick across distinct district positions" + ); + } + + #[test] + fn district_dominant_falls_back_to_zero_when_nothing_eligible_covers_type() { + let catalog = vec![tmpl( + "only_residential", + 10_000, + &[], + 0, + &[(DistrictType::Residential, 10_000)], + )]; + let selection = vec!["only_residential".to_string()]; + let map = pick_district_dominant_by_type( + &catalog, + &[], + &selection, + SeedChain::for_body(1, "Body"), + (0, 0), + ); + // Administrative has no candidate anywhere (empty eligible catalog) -> + // degenerate InVocabulary(0) fallback, matching pre-T-994 behaviour. + assert_eq!( + map.get(&DistrictType::Administrative), + Some(&ArchitectureFlavorRef::InVocabulary(0)) + ); + } + + #[test] + fn district_dominant_necessity_swerve_reaches_eligible_catalog() { + // The vocabulary only covers Residential; Administrative IS covered by an + // eligible out-of-vocabulary template — the sparsity escape hatch (T-1003, + // D-232 "same mechanism triggered by necessity") must surface it as a + // Swerve rather than defaulting to index 0. + let catalog = vec![ + tmpl( + "only_residential", + 10_000, + &[], + 0, + &[(DistrictType::Residential, 10_000)], + ), + tmpl( + "civic_hall", + 8_000, + &[], + 0, + &[(DistrictType::Administrative, 10_000)], + ), + ]; + let eligible: Vec<&TraitTemplate> = catalog.iter().collect(); + let selection = vec!["only_residential".to_string()]; + let map = pick_district_dominant_by_type( + &catalog, + &eligible, + &selection, + SeedChain::for_body(1, "Body"), + (0, 0), + ); + assert_eq!( + map.get(&DistrictType::Administrative), + Some(&ArchitectureFlavorRef::Swerve("civic_hall".to_string())) + ); + assert_eq!( + map.get(&DistrictType::Residential), + Some(&ArchitectureFlavorRef::InVocabulary(0)), + "covered types stay in-vocabulary" + ); + } +} diff --git a/server/src/atlas/trait_swerve.rs b/server/src/atlas/trait_swerve.rs new file mode 100644 index 000000000..c6240d9bf --- /dev/null +++ b/server/src/atlas/trait_swerve.rs @@ -0,0 +1,428 @@ +//! D-232 deviation/swerve system (T-1003) — the rare per-building wildcard that +//! draws a COHERENT whole template from *outside* the body's closed K-vocabulary. +//! +//! **Three sources, two opposed active drivers (D-232):** +//! - *foreign import* — another corridor's grammar (that corridor's baseline + +//! the shared cross-corridor pool), driven **up** by cosmopolitanism / +//! centrality / transit / Epicenter tier; +//! - *heritage callback* — the body's own corridor **heritage sub-pool**, driven +//! **up** by remoteness / isolation / conservatism; +//! - the passive *past-vogue holdover* is **not** drawn here — it rides the +//! D-217 wear/era condition layer (`era_cause`), no new mechanism (D-232: +//! "the temporal sibling of the spatial swerve"). +//! +//! The swerve is **cultural only**: candidate pools are built from the +//! hard-gate-*eligible* catalog (D-233 economic gates still hold — "function +//! still passes the normal economic hard gates; never an axis-scramble"). +//! +//! The **sparsity escape hatch is the same mechanism** triggered by necessity +//! rather than dice ([`necessity_swerve`]): when the closed vocabulary genuinely +//! cannot serve a district type, the phase-2 dominant pick reaches the full +//! eligible catalog (see `trait_draw::pick_district_dominant_by_type`). +//! +//! Like `trait_draw`, this module is pure — the driver rates and candidate +//! pools are resolved once per settlement at L3→L4 dispatch time +//! (`atlas::plugin`) and threaded through `CityGenerationContext`; the +//! per-building roll happens in `skeleton_gen::assign_block_tags` off the +//! footprint's own `SeedChain` (never in `FillChunk` — T-987 keeps fill pure). +//! +//! All numbers are integer basis points (D-010). Every constant below is a +//! **T-1003 refinement placeholder** (base 100 bps ≈ 1 %/building, hard cap +//! 300 bps per driver) — needs Nigel/Burnelli calibration once real +//! multi-corridor bodies are authored, same status as +//! `trait_draw::SECTOR_MISMATCH_BPS`. + +use crate::atlas::trait_catalog_reader::TraitTemplate; +use crate::seed::AtlasRng; +use crate::simulation::generator::WorldTier; + +/// Baseline per-building wildcard chance (bps of 10 000) before driver scaling. +const SWERVE_BASE_BPS: u32 = 100; +/// Hard cap per driver after scaling (refinement: "hard cap 300 bps"). +const SWERVE_DRIVER_CAP_BPS: u32 = 300; + +// ── Foreign-import driver multipliers (bps, 10 000 = 1.0×) ────────────────── +/// Epicenter tier — the cosmopolitan hub end of the dial. +const FOREIGN_EPICENTER_MULT_BPS: u32 = 20_000; +/// Passage tier — the refinement's "transit" input. +const FOREIGN_PASSAGE_MULT_BPS: u32 = 15_000; +/// `dominant_faction == "mixed"` — the refinement's "cosmopolitanism" input. +const FOREIGN_MIXED_FACTION_MULT_BPS: u32 = 15_000; +/// Per road/rail-graph link (centrality), additive on the multiplier. +const FOREIGN_PER_ROAD_DEGREE_BPS: u32 = 1_000; +/// Degree contribution cap — beyond 5 links a hub is a hub. +const FOREIGN_ROAD_DEGREE_CAP: u32 = 5; + +// ── Heritage-callback driver multipliers ───────────────────────────────────── +/// Road/rail degree ≤ 1 — the refinement's "isolation" input. +const HERITAGE_ISOLATED_MULT_BPS: u32 = 20_000; +/// Waypoint/Backwater tier — remoteness proxy. (The refinement floated a +/// `star_systems.dist_ly` percentile; that column is not in the D-199 read-set +/// today, and tier + graph degree are the in-world signals the percentile was +/// approximating. Slot a distance band in here if a reader field ever lands.) +const HERITAGE_REMOTE_TIER_MULT_BPS: u32 = 15_000; +/// `founding_age_years ≥ 300` — the refinement's "conservatism" input. +const HERITAGE_OLD_FOUNDING_MULT_BPS: u32 = 15_000; +/// `founding_age_years ≥ 150` (and < 300). +const HERITAGE_MID_FOUNDING_MULT_BPS: u32 = 12_500; + +/// Per-settlement driver inputs, all resolvable at L3→L4 dispatch time from +/// data that exists today (T-1003 refinement: "mappings to REAL fields"). +/// +/// `world_tier` currently rides the `city_context_reader` Waypoint stub in +/// production (same caveat as the T-994 `max_k` aggregation) — the Epicenter/ +/// Passage multipliers activate for real once a `world_tier` derivation lands. +pub struct SwerveDrivers<'a> { + pub world_tier: &'a WorldTier, + /// `dominant_faction == Some("mixed")` (cosmopolitanism). + pub faction_mixed: bool, + /// This settlement's node degree in the T-1038 road/rail graph + /// (centrality high — isolation low). + pub road_degree: u32, + /// D-199 field 5 (conservatism: older settlements reach back harder). + pub founding_age_years: u32, +} + +/// Per-building swerve chances (bps of 10 000), one per active driver. A +/// driver whose candidate pool is empty contributes no roll mass — enforced +/// inside [`roll_building_swerve`], not here. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SwerveRates { + pub foreign_bps: u32, + pub heritage_bps: u32, +} + +/// Scale the base rate by the two opposed driver stacks (D-232). A settlement +/// can plausibly score on both (an old, well-connected regional town keeps a +/// nonzero heritage pull) — the drivers oppose in *what they favour*, not as a +/// zero-sum split. +pub fn compute_swerve_rates(drivers: &SwerveDrivers) -> SwerveRates { + let mut foreign_mult: u64 = 10_000; + match drivers.world_tier { + WorldTier::Epicenter => foreign_mult = FOREIGN_EPICENTER_MULT_BPS as u64, + WorldTier::Passage => foreign_mult = FOREIGN_PASSAGE_MULT_BPS as u64, + _ => {} + } + if drivers.faction_mixed { + foreign_mult = foreign_mult * FOREIGN_MIXED_FACTION_MULT_BPS as u64 / 10_000; + } + foreign_mult += + (drivers.road_degree.min(FOREIGN_ROAD_DEGREE_CAP) * FOREIGN_PER_ROAD_DEGREE_BPS) as u64; + + let mut heritage_mult: u64 = 10_000; + if drivers.road_degree <= 1 { + heritage_mult = HERITAGE_ISOLATED_MULT_BPS as u64; + } + if matches!( + drivers.world_tier, + WorldTier::Waypoint | WorldTier::Backwater + ) { + heritage_mult = heritage_mult * HERITAGE_REMOTE_TIER_MULT_BPS as u64 / 10_000; + } + if drivers.founding_age_years >= 300 { + heritage_mult = heritage_mult * HERITAGE_OLD_FOUNDING_MULT_BPS as u64 / 10_000; + } else if drivers.founding_age_years >= 150 { + heritage_mult = heritage_mult * HERITAGE_MID_FOUNDING_MULT_BPS as u64 / 10_000; + } + + SwerveRates { + foreign_bps: ((SWERVE_BASE_BPS as u64 * foreign_mult / 10_000) as u32) + .min(SWERVE_DRIVER_CAP_BPS), + heritage_bps: ((SWERVE_BASE_BPS as u64 * heritage_mult / 10_000) as u32) + .min(SWERVE_DRIVER_CAP_BPS), + } +} + +/// The two out-of-vocabulary candidate pools, weighted by `base_weight` +/// (hero-body bias intentionally not applied — bias shapes the body's *own* +/// vocabulary, D-232; a swerve is by definition from elsewhere/elsewhen). +/// Tags, not indices: the swerve result is recorded as +/// `ArchitectureFlavorRef::Swerve(tag)`, re-derivable like everything else. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SwervePools { + /// Foreign import: eligible templates outside the vocabulary from *another* + /// corridor's grammar or the shared `cross_corridor` pool. + pub foreign: Vec<(String, u32)>, + /// Heritage callback: eligible `heritage`-pool templates of the body's own + /// corridor (or sector-unpinned heritage entries). + pub heritage: Vec<(String, u32)>, +} + +/// Partition the hard-gate-eligible catalog (minus the body's own vocabulary) +/// into the two swerve pools (D-232 corridor = two-part pool). +/// +/// `eligible` must already have passed the D-233 hard gates +/// (`trait_draw::hard_gate_eligible`) — the cultural-only rule. +pub fn build_swerve_pools( + eligible: &[&TraitTemplate], + trait_selection: &[String], + body_sector: Option<&str>, +) -> SwervePools { + let mut pools = SwervePools::default(); + for t in eligible { + if trait_selection.iter().any(|tag| tag == &t.tag) { + continue; // in-vocabulary — the closed draw already covers it + } + let entry = (t.tag.clone(), t.base_weight.max(1)); + match t.corridor_pool.as_str() { + // Another corridor's baseline grammar, or the shared cross-corridor + // pool that D-232 says "feeds the foreign-import swerves". + "cross_corridor" => pools.foreign.push(entry), + "baseline" => match (t.geographic_sector.as_deref(), body_sector) { + (Some(sector), Some(own)) if sector != own => pools.foreign.push(entry), + (Some(_), None) => pools.foreign.push(entry), + _ => {} // own-corridor (or unpinned) baseline — not foreign + }, + // The body's own corridor heritage sub-pool ("the remoteness dial + // draws specifically from it"). Other corridors' heritage is *not* + // a foreign-import source — D-232 scopes foreign import to grammar, + // heritage callback to one's own past. + "heritage" => match (t.geographic_sector.as_deref(), body_sector) { + (Some(sector), Some(own)) if sector == own => pools.heritage.push(entry), + (None, _) => pools.heritage.push(entry), + _ => {} + }, + other => { + tracing::debug!(tag = %t.tag, corridor_pool = %other, "unknown corridor_pool — excluded from swerve pools"); + } + } + } + pools +} + +/// Weighted pick from one pool. `None` on an empty pool. +fn pick_weighted(pool: &[(String, u32)], rng: &mut AtlasRng) -> Option { + if pool.is_empty() { + return None; + } + let total: u64 = pool.iter().map(|(_, w)| *w as u64).sum(); + let mut roll = (rng.next_u32() as u64) % total.max(1); + for (tag, w) in pool { + if roll < *w as u64 { + return Some(tag.clone()); + } + roll -= *w as u64; + } + pool.last().map(|(tag, _)| tag.clone()) +} + +/// The per-building wildcard roll (D-232 deviation system). One `u32` roll in +/// `[0, 10 000)`: below `foreign_bps` → foreign-import pick; below +/// `foreign_bps + heritage_bps` → heritage-callback pick; otherwise `None` +/// (the overwhelmingly common case — the building takes the district-dominant +/// template as usual). An empty pool's driver contributes no roll mass — a hit +/// would have nothing to draw. +/// +/// Takes the pools as slices (the `CityGenerationContext` fields) so per-block +/// callers never construct anything. `rng` must be a footprint-scoped stream +/// (`SeedDomain::TraitSwerve` off the footprint's own chain) so the roll is +/// deterministic per building (D-010) and uncorrelated with the zone/era/ +/// extent draws. +pub fn roll_building_swerve( + rates: SwerveRates, + foreign_pool: &[(String, u32)], + heritage_pool: &[(String, u32)], + rng: &mut AtlasRng, +) -> Option { + let foreign_bps = if foreign_pool.is_empty() { + 0 + } else { + rates.foreign_bps + }; + let heritage_bps = if heritage_pool.is_empty() { + 0 + } else { + rates.heritage_bps + }; + let total = foreign_bps + heritage_bps; + if total == 0 { + return None; + } + let roll = rng.next_u32() % 10_000; + if roll < foreign_bps { + pick_weighted(foreign_pool, rng) + } else if roll < total { + pick_weighted(heritage_pool, rng) + } else { + None + } +} + +/// The sparsity escape hatch (D-232: "the SAME mechanism triggered by necessity +/// rather than dice"): when zero vocabulary templates serve a district type at +/// phase-2 dominant-pick time, reach the full hard-gate-eligible catalog for +/// the best-weighted template that covers it. Deterministic (max-weight, +/// last-on-tie over the catalog's stable tag-sorted order — D-010), no dice: +/// necessity is not random. +pub fn necessity_swerve( + eligible: &[&TraitTemplate], + covers: impl Fn(&TraitTemplate) -> bool, +) -> Option { + eligible + .iter() + .filter(|t| covers(t)) + .max_by_key(|t| t.base_weight) + .map(|t| t.tag.clone()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::seed::{SeedChain, SeedDomain}; + use std::collections::BTreeMap; + + fn tmpl(tag: &str, pool: &str, sector: Option<&str>, base_weight: u32) -> TraitTemplate { + TraitTemplate { + tag: tag.to_string(), + corridor_pool: pool.to_string(), + geographic_sector: sector.map(str::to_string), + bulk_class_gate: Vec::new(), + production_ubiquity_gate: Vec::new(), + min_prosperity_bps: 0, + base_weight, + weight_mods: BTreeMap::new(), + zone_affinity: BTreeMap::new(), + } + } + + fn quiet_drivers() -> SwerveDrivers<'static> { + SwerveDrivers { + world_tier: &WorldTier::Regional, + faction_mixed: false, + road_degree: 2, + founding_age_years: 50, + } + } + + #[test] + fn baseline_rates_are_the_base_bps() { + let r = compute_swerve_rates(&quiet_drivers()); + assert_eq!( + r.foreign_bps, + SWERVE_BASE_BPS + 2 * FOREIGN_PER_ROAD_DEGREE_BPS / 100 + ); + assert_eq!(r.heritage_bps, SWERVE_BASE_BPS); + } + + #[test] + fn epicenter_hub_boosts_foreign_and_caps() { + let drivers = SwerveDrivers { + world_tier: &WorldTier::Epicenter, + faction_mixed: true, + road_degree: 9, + founding_age_years: 50, + }; + let r = compute_swerve_rates(&drivers); + assert_eq!( + r.foreign_bps, SWERVE_DRIVER_CAP_BPS, + "×2.0 ×1.5 + degree hits the cap" + ); + assert_eq!(r.heritage_bps, SWERVE_BASE_BPS); + } + + #[test] + fn isolated_old_backwater_boosts_heritage_and_caps() { + let drivers = SwerveDrivers { + world_tier: &WorldTier::Backwater, + faction_mixed: false, + road_degree: 1, + founding_age_years: 400, + }; + let r = compute_swerve_rates(&drivers); + assert_eq!( + r.heritage_bps, SWERVE_DRIVER_CAP_BPS, + "×2.0 ×1.5 ×1.5 hits the cap" + ); + } + + #[test] + fn pools_partition_by_corridor_and_exclude_vocabulary() { + let own = tmpl("own_baseline", "baseline", Some("east_reach"), 10_000); + let foreign = tmpl("west_baseline", "baseline", Some("west_reach"), 10_000); + let shared = tmpl("shared", "cross_corridor", None, 10_000); + let own_heritage = tmpl("east_temple", "heritage", Some("east_reach"), 10_000); + let foreign_heritage = tmpl("west_hacienda", "heritage", Some("west_reach"), 10_000); + let in_vocab = tmpl("in_vocab", "cross_corridor", None, 10_000); + let eligible: Vec<&TraitTemplate> = vec![ + &own, + &foreign, + &shared, + &own_heritage, + &foreign_heritage, + &in_vocab, + ]; + + let pools = build_swerve_pools(&eligible, &["in_vocab".to_string()], Some("east_reach")); + let foreign_tags: Vec<&str> = pools.foreign.iter().map(|(t, _)| t.as_str()).collect(); + let heritage_tags: Vec<&str> = pools.heritage.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(foreign_tags, ["west_baseline", "shared"]); + assert_eq!( + heritage_tags, + ["east_temple"], + "other corridors' heritage is not drawn" + ); + } + + #[test] + fn empty_pools_never_swerve_regardless_of_rates() { + let rates = SwerveRates { + foreign_bps: 10_000, + heritage_bps: 10_000, + }; + let mut rng = SeedChain::root(1) + .derive(SeedDomain::TraitSwerve, 0) + .atlas_rng(); + for _ in 0..100 { + assert_eq!(roll_building_swerve(rates, &[], &[], &mut rng), None); + } + } + + #[test] + fn swerve_is_rare_and_deterministic() { + let shared = tmpl("shared", "cross_corridor", None, 10_000); + let temple = tmpl("temple", "heritage", None, 10_000); + let eligible: Vec<&TraitTemplate> = vec![&shared, &temple]; + let pools = build_swerve_pools(&eligible, &[], None); + let rates = SwerveRates { + foreign_bps: 100, + heritage_bps: 100, + }; // 2% total + + let count_hits = || { + let mut hits = 0; + for i in 0..10_000u64 { + let mut rng = SeedChain::root(7) + .derive(SeedDomain::TraitSwerve, i) + .atlas_rng(); + if roll_building_swerve(rates, &pools.foreign, &pools.heritage, &mut rng).is_some() + { + hits += 1; + } + } + hits + }; + let hits = count_hits(); + assert_eq!(hits, count_hits(), "same seeds → same swerves (D-010)"); + // 2% nominal over 10k rolls — generous band, this is a rarity check + // not a distribution test. + assert!( + (100..=400).contains(&hits), + "expected ~200 swerves in 10k rolls, got {hits}" + ); + } + + #[test] + fn necessity_swerve_picks_max_weight_covering_template() { + let a = tmpl("light", "baseline", None, 5_000); + let b = tmpl("heavy", "baseline", None, 9_000); + let c = tmpl("heavier_but_not_covering", "baseline", None, 12_000); + let eligible: Vec<&TraitTemplate> = vec![&a, &b, &c]; + let picked = necessity_swerve(&eligible, |t| t.tag != "heavier_but_not_covering"); + assert_eq!(picked.as_deref(), Some("heavy")); + assert_eq!(necessity_swerve(&eligible, |_| false), None); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 853bd182a..0e36326b7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -234,6 +234,28 @@ fn main() { ), } + // D-232 trait-template catalog reader (T-994): reads `trait_templates` + + // `atlas_body_trait_bias` on a body-analysis completion so the L3→L4 dispatch + // aggregation (atlas::plugin::drain_generation_completions) can run the + // three-phase draw. Absent → trait_selection stays empty for every body + // (the pre-T-994 degenerate behaviour), not a hard failure. + match settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReader::open( + &systems_db_path, + ) { + Ok(reader) => { + tracing::info!("Trait catalog reader opened: {:?}", systems_db_path); + app.insert_resource( + settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReaderResource( + reader, + ), + ); + } + Err(e) => tracing::warn!( + "Trait catalog reader unavailable ({}). Architecture-flavor draw will stay empty.", + e + ), + } + // Initialize SQLite settings store (#627). // Path: alongside save files in the server's working directory. let settings_path = std::path::PathBuf::from("settings.db"); diff --git a/server/src/seed.rs b/server/src/seed.rs index 252956880..657aea0db 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -120,6 +120,25 @@ pub enum SeedDomain { /// keyed by a single constant id). Distinct domain so the mosaic-selection lattice /// can never correlate with the relief, cover, or per-voxel terrain streams. VoxelMosaic = 12, + /// Architecture-flavor body-vocabulary K-draw (D-232 phase 1, T-994). Keyed by + /// a constant id (one draw per body — `SeedChain::for_body` already isolates + /// bodies). Distinct domain so the vocabulary draw can never correlate with + /// any other body-scoped stream. + TraitVocabulary = 13, + /// Architecture-flavor district-dominant template pick (D-232 phase 2, T-994). + /// Keyed by a **two-level derive chain** under this one domain — first by the + /// `DistrictPos` id (`pos_to_id`), then by the `DistrictType` ordinal — so + /// every settlement whose quarter falls in the same D-243 2 048 m district + /// independently derives the *identical* dominant template for a given + /// district type: same body seed + same two-level key, no cross-quarter + /// coordination required. (Two chained derives, not a packed single id — + /// see `trait_draw::pick_district_dominant_by_type`.) + TraitDistrict = 14, + /// Per-building deviation/swerve roll (D-232 deviation system, T-1003). + /// Derived off the footprint's own chain, keyed by footprint index. Distinct + /// domain so the rare-wildcard roll can never correlate with the zone/era/ + /// extent draws sharing that chain. + TraitSwerve = 15, } /// A position in the deterministic seed tree (D-224). @@ -281,6 +300,9 @@ mod tests { assert_eq!(SeedDomain::Cover as u64, 10); assert_eq!(SeedDomain::VoxelRelief as u64, 11); assert_eq!(SeedDomain::VoxelMosaic as u64, 12); + assert_eq!(SeedDomain::TraitVocabulary as u64, 13); + assert_eq!(SeedDomain::TraitDistrict as u64, 14); + assert_eq!(SeedDomain::TraitSwerve as u64, 15); } #[test] diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index f23ce179f..71c3df7a3 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -198,7 +198,13 @@ pub enum SettingType { } /// Classification of a district (high-level function). -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] +/// +/// `Ord`/`PartialOrd` (T-994): lets callers dedup a body-wide coverage set into a +/// `BTreeSet`/`BTreeMap` key (D-010 determinism) — the D-232 trait-template draw's +/// `zone_affinity` lookup and the body-level district-type-mix coverage aggregate. +/// Declaration order is not a stability-pinned wire format (unlike `MorphologyZone` +/// or `SeedDomain`), so this is a safe additive derive. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum DistrictType { LogisticsHub, Residential, @@ -817,15 +823,31 @@ impl TileRect { } } -/// Architecture flavor index into the body's trait-template draw (D-229, D-232). +/// Architecture flavor reference into the body's trait-template draw (D-229, D-232). /// /// Records which template the generator selected at skeleton time for Phase-6 to -/// read cold. The selection mechanism is D-232's weighted `allow`/`block` filter; -/// the index is frozen-amber once written. +/// read cold. The selection mechanism is D-232's three-phase draw (T-994): a body +/// vocabulary K-draw, then a per-district dominant-template pick by `zone_affinity`, +/// then within-template seed picks (not represented here — that's the D-235 visual +/// bundle resolution). The reference is frozen-amber once written. +/// +/// Shaped as an enum (rather than a bare index) so the closed-vocabulary case and +/// the future out-of-vocabulary swerve (T-1003, D-232's deviation system) share one +/// wire type without a breaking change when the swerve lands — `T-994` introduces +/// the shape only; swerve *logic* (foreign-import / heritage-callback draws) is not +/// implemented here. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct ArchitectureFlavorRef { - /// Index into `CityGenerationContext::trait_selection` (0-based). - pub flavor_index: u8, +pub enum ArchitectureFlavorRef { + /// Index into `CityGenerationContext::trait_selection` (0-based) — the body's + /// closed K-template vocabulary (D-232 phase 1). + InVocabulary(u8), + /// A template tag drawn from *outside* the body's closed vocabulary (T-1003, + /// D-232 deviation system): the rare per-building foreign-import / + /// heritage-callback wildcard (`trait_swerve::roll_building_swerve`), or the + /// sparsity escape hatch at district-dominant time + /// (`trait_swerve::necessity_swerve`). The passive past-vogue holdover is NOT + /// a swerve — it rides the D-217 wear/era condition layer (`era_cause`). + Swerve(String), } /// A single building footprint tag — the frozen step-3 output placed on every @@ -1116,11 +1138,57 @@ pub struct CityGenerationContext { /// Body vocabulary draw result — K trait-template tags selected at skeleton /// time (D-232). K is locked to `complexity_tier`: Full=5, Moderate=3, /// Minimal=1, Empty=0. NOT named `flavor_profile` (that was the round-2 name). + /// Populated by the T-994 phase-1 K-draw; empty when the trait catalog reader + /// is unavailable or `complexity_tier == Empty` (K=0). pub trait_selection: Vec, /// Dominant `BulkClass` for this settlement's primary commodity (D-233). pub dominant_bulk_class: BulkClass, /// Spatial concentration of dominant production (D-233). pub dominant_production_ubiquity: ProductionUbiquity, + + // ── T-994 additions (D-232 three-phase draw) ─────────────────────────── + /// This system's corridor (`star_systems.geographic_sector` — + /// core/north_reach/south_reach/west_reach/east_reach/deep_frontier; `None` + /// if unset). A SOFT weight on the phase-1 body-vocabulary draw only — composes + /// with a template's own `geographic_sector` pool-narrowing column AND its + /// `weight_mods.geographic_sector` map (PR #148 review note: a two-part join, + /// never a hard gate — D-232 "corridors are tendencies, not borders"). + pub geographic_sector: Option, + /// Body-level district-type coverage: every `DistrictType` present anywhere + /// among this body's settlements, deduped (T-994). Threaded from the L3→L4 + /// dispatch aggregation so the phase-1 K-draw is coverage-aware up front + /// (≥1 eligible template per district type actually present on the body). + pub body_district_type_mix: Vec, + /// The D-243 2 048 m District cell (`atlas::scale::DistrictPos`) this + /// settlement's quarter falls in (T-994). Two settlements sharing a + /// `settlement_district_pos` independently derive the identical phase-2 + /// dominant template for a given `DistrictType` — same body seed + same key, + /// so a coherent 2 048 m area reads as one style with no cross-quarter + /// coordination required. + pub settlement_district_pos: (i32, i32), + /// Phase-2 dominant-template pick (D-232), pre-resolved at L3→L4 dispatch time + /// (T-994) — **not** at `FillChunk` (T-987 keeps fill pure/cache-free) and not + /// even inside the `GenerateSkeleton` Rayon task itself, since the inputs + /// (`trait_selection` + the catalog's `zone_affinity`) are already known at + /// dispatch. One entry per `DistrictType` (all 9, so `assign_block_tags` is a + /// cheap infallible lookup). Usually `InVocabulary` (an index into + /// `trait_selection`); `Swerve` when the sparsity escape hatch fired (T-1003). + /// `BTreeMap` for D-010 determinism. + pub district_dominant_by_type: BTreeMap, + + // ── T-1003 additions (D-232 deviation/swerve system) ──────────────────── + /// Per-building wildcard chances (bps of 10 000): `(foreign_bps, + /// heritage_bps)`, resolved once per settlement at dispatch time from the + /// driver inputs (`trait_swerve::compute_swerve_rates`). Both zero when no + /// catalog reader is wired. + pub swerve_rates_bps: (u32, u32), + /// Foreign-import swerve candidates — hard-gate-eligible templates outside + /// the body vocabulary, from another corridor's grammar or the shared + /// `cross_corridor` pool: `(tag, weight_bps)` (`trait_swerve` module). + pub swerve_foreign_pool: Vec<(String, u32)>, + /// Heritage-callback swerve candidates — the body's own corridor heritage + /// sub-pool: `(tag, weight_bps)`. + pub swerve_heritage_pool: Vec<(String, u32)>, } // --------------------------------------------------------------------------- @@ -1146,6 +1214,12 @@ pub struct BlockSkeleton { /// Grid position (0–3, 0–3). pub position: (u8, u8), pub zoning: ZoningType, + /// The D-194 district-mix `DistrictType` this block was assigned (T-994). + /// `zoning` is the *function* derived from it (`zoning_for_district`); this + /// field keeps the source classification around because the D-232 phase-2 + /// dominant-template pick keys `zone_affinity` by `DistrictType`, not + /// `ZoningType` — dropping it here would make the pick unrecoverable. + pub district_type: DistrictType, /// Which multi-block reservation this block belongs to (if any). pub reservation: Option, pub chunk_layout: ChunkLayout, 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..1cc63f629 100644 --- a/tooling/economy-db/economy_import/traits.py +++ b/tooling/economy-db/economy_import/traits.py @@ -5,15 +5,126 @@ 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"} +# V-TT-05: pins count toward K (D-232) and the largest K is 5 +# (ComplexityTier::Full) — a body can never use more pins than that. +_MAX_PINS_PER_BODY: int = 5 # 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") +# 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 +145,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 +163,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"), @@ -117,8 +257,9 @@ def populate_atlas_body_trait_bias(conn: sqlite3.Connection, dry_run: bool) -> i 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. + multiplier), plus V-TT-05: at most _MAX_PINS_PER_BODY pins per body (pins + count toward K, D-232). 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: @@ -154,6 +295,19 @@ def populate_atlas_body_trait_bias(conn: sqlite3.Connection, dry_run: bool) -> i 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"))) + # V-TT-05 (PR #173 review H4): pins count toward K (D-232) and the largest + # possible K is 5 (ComplexityTier::Full) — more pins than that can never + # fit any body's vocabulary budget and would force the draw past K. + pin_counts: dict[str, int] = {} + for bid, _tag, kind, _mult, _note in rows: + if kind == "pin": + pin_counts[bid] = pin_counts.get(bid, 0) + 1 + for bid, count in sorted(pin_counts.items()): + if count > _MAX_PINS_PER_BODY: + errors.append( + f"V-TT-05: body '{bid}' has {count} pins — more than the maximum " + f"K of {_MAX_PINS_PER_BODY} (ComplexityTier::Full); pins count toward K (D-232)" + ) if errors: print(f" TRAIT BIAS ERRORS ({len(errors)}):") for e in errors: diff --git a/tooling/economy-db/test_traits.py b/tooling/economy-db/test_traits.py new file mode 100644 index 000000000..03eb86b65 --- /dev/null +++ b/tooling/economy-db/test_traits.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +Unit tests for economy_import.traits validation (T-995, PR #173 review H2). + +Covers the failure branches of the ObjectTag registry loader/validator +(V-TT-03 existence/axis, V-TT-04 fallback-graph) and the V-TT-05 pin bound — +the `make test-tooling` dry-run only exercises the happy path against the +committed, already-valid registry. + +Stdlib only (unittest) — run directly or via `make test-tooling`: + python3 tooling/economy-db/test_traits.py +""" + +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from economy_import import traits # noqa: E402 +from economy_import.errors import ImportAborted # noqa: E402 + + +def write_registry(tmp: Path, body: str) -> Path: + path = tmp / "object_tag_vocabulary.toml" + path.write_text(body, encoding="utf-8") + return path + + +VALID_REGISTRY = """ +[tags.wall.generic_wall] +description = "generic wall placeholder" +generic = true + +[tags.wall.brick_wall] +description = "fired brick" +fallback = "generic_wall" + +[tags.roof.generic_roof] +description = "generic roof placeholder" +generic = true + +[tags.roof.flat_roof] +description = "flat roof" +fallback = "generic_roof" + +[tags.facade.generic_facade] +description = "generic facade placeholder" +generic = true + +[tags.street.generic_street] +description = "generic street placeholder" +generic = true +""" + + +class RegistryValidationTests(unittest.TestCase): + """_load_object_tag_vocabulary: V-TT-03 shape + V-TT-04 fallback graph.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self._orig = traits.OBJECT_TAG_VOCABULARY_TOML + + def tearDown(self): + traits.OBJECT_TAG_VOCABULARY_TOML = self._orig + self._tmp.cleanup() + + def load(self, registry_toml: str | None): + if registry_toml is None: + traits.OBJECT_TAG_VOCABULARY_TOML = self.tmp / "missing.toml" + else: + traits.OBJECT_TAG_VOCABULARY_TOML = write_registry(self.tmp, registry_toml) + errors: list[str] = [] + registry = traits._load_object_tag_vocabulary(errors) + return registry, errors + + def test_valid_registry_loads_without_errors(self): + registry, errors = self.load(VALID_REGISTRY) + self.assertEqual(errors, []) + self.assertEqual(registry["brick_wall"]["axis"], "wall") + self.assertEqual(registry["brick_wall"]["fallback"], "generic_wall") + self.assertTrue(registry["generic_wall"]["generic"]) + + def test_missing_registry_is_a_vtt03_error(self): + registry, errors = self.load(None) + self.assertEqual(registry, {}) + self.assertTrue(any("V-TT-03" in e and "not found" in e for e in errors)) + + def test_unknown_axis_is_flagged(self): + _, errors = self.load( + VALID_REGISTRY + + """ +[tags.chimney.smoke_stack] +description = "not a real axis" +fallback = "generic_wall" +""" + ) + self.assertTrue(any("V-TT-03" in e and "axis 'chimney'" in e for e in errors)) + + def test_duplicate_tag_across_axes_is_flagged(self): + _, errors = self.load( + VALID_REGISTRY + + """ +[tags.roof.brick_wall] +description = "duplicate of a wall tag" +fallback = "generic_roof" +""" + ) + self.assertTrue(any("V-TT-03" in e and "declared in both" in e for e in errors)) + + def test_missing_description_is_flagged(self): + _, errors = self.load( + VALID_REGISTRY + + """ +[tags.wall.bare_wall] +fallback = "generic_wall" +""" + ) + self.assertTrue(any("V-TT-03" in e and "missing 'description'" in e for e in errors)) + + def test_generic_with_fallback_is_flagged(self): + _, errors = self.load( + """ +[tags.wall.generic_wall] +description = "generic wall" +generic = true +fallback = "generic_wall" +""" + ) + self.assertTrue(any("V-TT-04" in e and "fallback-terminal" in e for e in errors)) + + def test_non_generic_without_fallback_is_flagged(self): + _, errors = self.load( + VALID_REGISTRY + + """ +[tags.wall.orphan_wall] +description = "no parent" +""" + ) + self.assertTrue(any("V-TT-04" in e and "must declare" in e for e in errors)) + + def test_fallback_to_unknown_tag_is_flagged(self): + _, errors = self.load( + VALID_REGISTRY + + """ +[tags.wall.floating_wall] +description = "points nowhere" +fallback = "no_such_tag" +""" + ) + self.assertTrue(any("V-TT-04" in e and "unknown tag 'no_such_tag'" in e for e in errors)) + + def test_fallback_cycle_is_flagged(self): + _, errors = self.load( + VALID_REGISTRY + + """ +[tags.wall.wall_a] +description = "cycles to b" +fallback = "wall_b" + +[tags.wall.wall_b] +description = "cycles to a" +fallback = "wall_a" +""" + ) + self.assertTrue(any("V-TT-04" in e and "cycles" in e for e in errors)) + + def test_wrong_generic_set_is_flagged(self): + # Drop generic_street entirely — the 4-placeholder contract breaks. + registry_toml = VALID_REGISTRY.replace( + """ +[tags.street.generic_street] +description = "generic street placeholder" +generic = true +""", + "", + ) + _, errors = self.load(registry_toml) + self.assertTrue(any("V-TT-04" in e and "generic placeholders" in e for e in errors)) + + +CATALOG_WITH_BAD_TAG = """ +[templates.test_template] +label = "Test Template" +corridor_pool = "baseline" +base_weight = 10000 +allow_tags = ["no_such_tag"] +zone_affinity = { MixedUse = 10000 } +""" + +CATALOG_WITH_AXIS_MISMATCH = """ +[templates.test_template] +label = "Test Template" +corridor_pool = "baseline" +base_weight = 10000 +zone_affinity = { MixedUse = 10000 } + +[templates.test_template.visual_bundle] +wall = ["flat_roof"] +""" + + +class CatalogCrossCheckTests(unittest.TestCase): + """populate_trait_templates: V-TT-03 catalog→registry cross-checks.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self._orig_registry = traits.OBJECT_TAG_VOCABULARY_TOML + self._orig_catalog = traits.ARCHITECTURE_TRAIT_CATALOG_TOML + traits.OBJECT_TAG_VOCABULARY_TOML = write_registry(self.tmp, VALID_REGISTRY) + self.conn = sqlite3.connect(":memory:") + self.conn.execute( + """CREATE TABLE 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)""" + ) + + def tearDown(self): + traits.OBJECT_TAG_VOCABULARY_TOML = self._orig_registry + traits.ARCHITECTURE_TRAIT_CATALOG_TOML = self._orig_catalog + self.conn.close() + self._tmp.cleanup() + + def populate_errors(self, catalog_toml: str) -> str: + """Run populate_trait_templates; return its printed error report. + + A tiny test catalog also trips the V-TT-01 pool-size guardrail, so the + abort alone proves nothing — assertions must target the specific + V-TT-03 line in the captured output. + """ + import contextlib + import io + + catalog = self.tmp / "architecture_trait_catalog.toml" + catalog.write_text(catalog_toml, encoding="utf-8") + traits.ARCHITECTURE_TRAIT_CATALOG_TOML = catalog + out = io.StringIO() + with contextlib.redirect_stdout(out): + with self.assertRaises(ImportAborted): + traits.populate_trait_templates(self.conn, dry_run=True) + return out.getvalue() + + def test_unknown_allow_tag_reports_vtt03(self): + out = self.populate_errors(CATALOG_WITH_BAD_TAG) + self.assertIn("V-TT-03", out) + self.assertIn("no_such_tag", out) + + def test_axis_mismatch_in_visual_bundle_reports_vtt03(self): + # flat_roof is a valid tag, but registered under 'roof', used as 'wall'. + out = self.populate_errors(CATALOG_WITH_AXIS_MISMATCH) + self.assertIn("V-TT-03", out) + self.assertIn("axis 'roof'", out) + + +class PinBoundTests(unittest.TestCase): + """populate_atlas_body_trait_bias: V-TT-05 pins-per-body bound (H4).""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self._orig_bias = traits.ARCHITECTURE_TRAIT_BIAS_TOML + self.conn = sqlite3.connect(":memory:") + self.conn.execute("CREATE TABLE bodies (body_id TEXT PRIMARY KEY)") + self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY)") + self.conn.execute( + """CREATE TABLE atlas_body_trait_bias ( + id INTEGER PRIMARY KEY AUTOINCREMENT, body_id TEXT NOT NULL, + template_tag TEXT NOT NULL, bias_kind TEXT NOT NULL, + weight_multiplier_bps INTEGER, note TEXT)""" + ) + self.conn.execute("INSERT INTO bodies VALUES ('HeroBody')") + for i in range(6): + self.conn.execute("INSERT INTO trait_templates VALUES (?)", (f"tmpl_{i}",)) + + def tearDown(self): + traits.ARCHITECTURE_TRAIT_BIAS_TOML = self._orig_bias + self.conn.close() + self._tmp.cleanup() + + def bias_toml(self, pin_count: int) -> str: + blocks = [] + for i in range(pin_count): + blocks.append( + f'[[bias]]\nbody_id = "HeroBody"\ntemplate_tag = "tmpl_{i}"\nbias_kind = "pin"\n' + ) + return "\n".join(blocks) + + def populate(self, body: str): + path = self.tmp / "architecture_trait_bias.toml" + path.write_text(body, encoding="utf-8") + traits.ARCHITECTURE_TRAIT_BIAS_TOML = path + return traits.populate_atlas_body_trait_bias(self.conn, dry_run=True) + + def test_five_pins_on_one_body_pass(self): + self.assertEqual(self.populate(self.bias_toml(5)), 5) + + def test_six_pins_on_one_body_abort_with_vtt05(self): + with self.assertRaises(ImportAborted): + self.populate(self.bias_toml(6)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) 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