feat(simulation): D-232 three-phase trait draw + deviation/swerve system (T-994, T-1003)

T-994 — three-phase draw replacing the flat splitmix64 flavor pick:
- Phase 1 body K-draw (trait_draw.rs): hard gates -> soft weights (two-part
  geographic_sector join, PR #148) -> SeedChain::for_body-seeded weighted
  draw of K (5/3/1/0 by ComplexityTier), pins count toward K, coverage-aware
  over a new body-level district-type-mix aggregate threaded into
  CityGenerationContext at L3->L4 dispatch.
- Phase 2 district-dominant pick keyed by the D-243 2048m District cell —
  settlements sharing a cell independently derive the identical template.
  Resolved at dispatch, never in FillChunk (T-987 stays pure).
- trait_catalog_reader.rs: systems.db reader for trait_templates +
  atlas_body_trait_bias (D-225 resource pattern), registered in main.rs
  with graceful degradation.
- BlockSkeleton carries district_type; assign_block_tags is a pure lookup.

T-1003 — deviation/swerve system (trait_swerve.rs):
- ArchitectureFlavorRef is now InVocabulary(u8) | Swerve(tag).
- Rare per-building wildcard: foreign-import pool (other corridors +
  cross_corridor) driven by Epicenter/Passage tier, mixed faction,
  road-graph degree; heritage-callback pool (own-corridor heritage) driven
  by isolation, remote tier, founding_age_years. Integer bps, base 100,
  cap 300 per driver — placeholder constants pending calibration.
- Sparsity escape hatch = same mechanism, necessity-triggered at the
  phase-2 pick (deterministic max-weight over the hard-gate-eligible pool).
- New SeedDomains: TraitVocabulary(13) / TraitDistrict(14) / TraitSwerve(15).

Governance: D-232 amended (district pinned to the D-243 2048m tier — the
D-222/D-243 redefinition was never captured; swerve driver mappings +
placeholder rates recorded), D-229 flavor_ref field updated to the enum
shape, D-235 amended (T-995 vocabulary ratification note; rides here due
to single-file entanglement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 09:16:47 +02:00
co-authored by Claude Fable 5
parent 766ceb436c
commit 90655382eb
13 changed files with 2392 additions and 33 deletions
+78 -4
View File
@@ -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<String>,
}
// ---------------------------------------------------------------------------
@@ -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<String>, // acn.economic_role
i64, // acn.population
Option<String>, // acn.settlement_class
Option<i64>, // b.founding_age_years
Option<String>, // sf.dominant_faction
Option<String>, // 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]