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/server/data/systems.db b/server/data/systems.db index 808054ae0..aaf68bf8c 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/atlas/plugin.rs b/server/src/atlas/plugin.rs index 65e13ec9a..f307ffcfa 100644 --- a/server/src/atlas/plugin.rs +++ b/server/src/atlas/plugin.rs @@ -136,15 +136,13 @@ fn drain_generation_completions( // 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). + // 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(); - 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 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, @@ -152,41 +150,14 @@ fn drain_generation_completions( error = %e, "L3→L4 dispatch: read_set failed — skipping placement" ); - continue; } - }; - 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 aggregation loop 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)); - - resolved.push((placement, read_set)); + } } - let body_district_type_mix: Vec = - body_district_type_mix.into_iter().collect(); + 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. @@ -307,6 +278,65 @@ 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` @@ -800,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( diff --git a/server/src/atlas/trait_catalog_reader.rs b/server/src/atlas/trait_catalog_reader.rs index 9b44affe0..d9cb97f0a 100644 --- a/server/src/atlas/trait_catalog_reader.rs +++ b/server/src/atlas/trait_catalog_reader.rs @@ -5,7 +5,7 @@ //! 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 (D-225). +//! 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 @@ -19,7 +19,7 @@ //! left unparsed here. use std::path::Path; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use rusqlite::{Connection, OpenFlags}; use thiserror::Error; @@ -48,9 +48,11 @@ pub enum TraitCatalogReadError { #[derive(Debug, Clone, PartialEq)] pub struct TraitTemplate { pub tag: String, - /// `baseline` | `heritage` | `cross_corridor` — informational (T-994 does not - /// implement the heritage-callback / cross-corridor swerve dials; that's - /// T-1003's deviation system). + /// `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. @@ -106,6 +108,12 @@ pub struct TraitBias { /// 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 { @@ -115,12 +123,25 @@ impl TraitCatalogReader { .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). + /// 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() @@ -283,7 +304,16 @@ pub(crate) fn parse_district_type(s: &str) -> Option { fn parse_bulk_class_gate(json: Option<&str>, tag: &str) -> Vec { let Some(json) = json else { return Vec::new() }; - let raw: Vec = serde_json::from_str(json).unwrap_or_default(); + // 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); @@ -297,7 +327,14 @@ fn parse_bulk_class_gate(json: Option<&str>, tag: &str) -> Vec { fn parse_production_ubiquity_gate(json: Option<&str>, tag: &str) -> Vec { let Some(json) = json else { return Vec::new() }; - let raw: Vec = serde_json::from_str(json).unwrap_or_default(); + // 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); @@ -495,6 +532,31 @@ mod tests { ); } + #[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(); diff --git a/server/src/atlas/trait_draw.rs b/server/src/atlas/trait_draw.rs index 8f4ca9cb6..5583e465e 100644 --- a/server/src/atlas/trait_draw.rs +++ b/server/src/atlas/trait_draw.rs @@ -201,6 +201,13 @@ pub fn hard_gate_eligible<'a>( /// 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], @@ -223,7 +230,8 @@ pub fn draw_body_vocabulary( // ── 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). + // 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!( @@ -233,10 +241,25 @@ pub fn draw_body_vocabulary( 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| { ( @@ -273,10 +296,12 @@ pub fn draw_body_vocabulary( continue; } // Find the best not-yet-selected eligible candidate covering `dt`. - // `max_by_key` returns the LAST maximal element on ties — deterministic - // given the catalog's stable tag-sorted order (D-010). + // 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| { @@ -294,9 +319,11 @@ pub fn draw_body_vocabulary( continue; }; // Swap out the lowest-weight non-pinned member to keep K fixed (D-232: - // "K is not a range"); if every current member is pinned, push anyway - // — the sparsity escape hatch (D-232: "reaches the full catalog" — - // here, the wider hard-gate-eligible pool). + // "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() @@ -305,7 +332,12 @@ pub fn draw_body_vocabulary( .map(|(i, _)| i); match swap_idx { Some(i) => selection[i] = (winner.tag.clone(), w, false), - None => selection.push((winner.tag.clone(), w, false)), + None => { + tracing::debug!( + ?dt, + "all K slots pinned — leaving district type to the phase-2 necessity swerve" + ); + } } } @@ -630,6 +662,208 @@ mod tests { ); } + 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) diff --git a/server/src/seed.rs b/server/src/seed.rs index 67b86adc1..657aea0db 100644 --- a/server/src/seed.rs +++ b/server/src/seed.rs @@ -126,10 +126,13 @@ pub enum SeedDomain { /// any other body-scoped stream. TraitVocabulary = 13, /// Architecture-flavor district-dominant template pick (D-232 phase 2, T-994). - /// Keyed by a packed `(DistrictPos, DistrictType)` id 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 key, no cross-quarter coordination required. + /// 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 diff --git a/tooling/economy-db/economy_import/traits.py b/tooling/economy-db/economy_import/traits.py index 39c2ede76..1cc63f629 100644 --- a/tooling/economy-db/economy_import/traits.py +++ b/tooling/economy-db/economy_import/traits.py @@ -13,6 +13,9 @@ from .paths import ( _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") @@ -254,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: @@ -291,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)