From 314894d0804eb017c5310067f59694ed86789d0b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 16 Jul 2026 10:29:17 +0200 Subject: [PATCH] test(simulation): lock standalone-HQ-as-ordinary-settlement-row invariant (T-1074) Co-Authored-By: Claude Fable 5 --- server/src/atlas/attractor_matching.rs | 70 +++++++++++++++++++++++++ server/src/atlas/city_context_reader.rs | 44 ++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs index a31f252fe..1b00c7464 100644 --- a/server/src/atlas/attractor_matching.rs +++ b/server/src/atlas/attractor_matching.rs @@ -828,6 +828,76 @@ mod tests { assert!(p2.synthetic); } + /// D-242: "hubs are the significant cities, standalone HQs are typically + /// minor nodes." `match_cities` has no concept of "corp-originated" + /// settlement — a Standalone-HQ CityRecord competes on population/class + /// exactly like an ordinary pooled city, so a modest-population HQ must + /// NOT out-rank a genuinely major city for the best attractor, and a + /// major HQ (large population, or NameLocked) DOES win Tier A on the same + /// terms any other settlement would. Both directions verified here. + #[test] + fn standalone_hq_settlement_competes_on_equal_terms_with_pooled_cities() { + let major_city = make_city(1, SettlementClass::PopulationBudget, 5_000_000); + // A "minor node" Standalone HQ (D-242's own framing) — modest + // population, ordinary PopulationBudget class, nothing marking it as + // corp-originated (mirrors populate_standalone_hq_settlements' output + // shape: an atlas_city_names row with no corp_id, same as any city). + let minor_hq = make_city(2, SettlementClass::PopulationBudget, 80_000); + let cities = vec![major_city, minor_hq]; + let attractors = vec![ + make_attractor(5, 5, AttractorType::RiverMouth, 95), // best + make_attractor(10, 10, AttractorType::ValleyFloor, 30), // worst + ]; + let matrix = uniform_matrix(); + let placements = match_cities( + &cities, + &attractors, + &matrix, + None, + 512, + 256, + &TerritorialStatus::FrontierUnclaimed, + SeedChain::root(42), + ); + let major = placements.iter().find(|p| p.city_id == 1).unwrap(); + let minor = placements.iter().find(|p| p.city_id == 2).unwrap(); + assert_eq!( + major.position, + (5, 5), + "the major city (5M pop) must win the best attractor over an 80k-pop HQ" + ); + assert_eq!( + minor.position, + (10, 10), + "the minor HQ gets whatever's left — no special corp-origin priority" + ); + + // Flip it: a NameLocked HQ (the T-1075 hero-pin path — e.g. "The Gate + // Corporation" in the real data) DOES win Tier A, on the same + // NameLocked-priority terms as any hero-pinned city (tier_a_gets_priority, + // above) — population size never mattered for NameLocked, corp-origin + // doesn't add or subtract anything either. + let hero_hq = make_city(3, SettlementClass::NameLocked, 200_000); + let ordinary_city = make_city(4, SettlementClass::PopulationBudget, 3_000_000); + let cities2 = vec![hero_hq, ordinary_city]; + let placements2 = match_cities( + &cities2, + &attractors, + &matrix, + None, + 512, + 256, + &TerritorialStatus::FrontierUnclaimed, + SeedChain::root(42), + ); + let hero = placements2.iter().find(|p| p.city_id == 3).unwrap(); + assert_eq!( + hero.position, + (5, 5), + "NameLocked HQ wins Tier A over a larger ordinary city — same rule as any NameLocked settlement" + ); + } + #[test] fn all_cities_placed() { let cities: Vec = (1..=5) diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index 9ed3bee95..17b77082b 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -1154,6 +1154,50 @@ mod tests { ); } + // ─── D-242 corp-HQ settlement model (T-1074/T-1075) ────────────────────── + // + // A Standalone-HQ settlement (economy_import/corporations.py: + // populate_standalone_hq_settlements) is inserted into atlas_city_names as + // an ORDINARY row — same schema, same fields, no corp linkage marker (the + // corp<->settlement relationship lives on corporations.headquarters_body, + // not on atlas_city_names). This module's test fixture schema + // (make_settlements_db, above) never had a corp_id column to begin with — + // this test makes that design invariant explicit: read_body_settlements + // (the D-211 placement pipeline's input) cannot distinguish a + // corp-Standalone-HQ row from a wiki-pooled city, by construction. If a + // future change ever needs to special-case corp-originated settlements, + // it will have to be threaded through explicitly — this test fails the + // moment that stops being true silently. + + #[test] + fn read_body_settlements_treats_standalone_hq_row_identically_to_pooled_city() { + // "Gate Corporation" here stands in for a Phase-B-inserted + // Standalone-HQ row (name = corp proper_name, economic_role from + // corp_hq_placement.toml's standalone_economic_role, population from + // the T-1075 Zipf bake, settlement_class from the same bake/override + // path as any other city) — indistinguishable in shape from the + // ordinary pooled cities "Tributarium"/"Ruhr" it sits alongside. + let db = make_settlements_db(&[ + ("Tributarium", "manufacturing", 2_727_273_224, Some("PopulationBudget")), + ("Ruhr", "manufacturing", 1_363_636_611, Some("PopulationBudget")), + ("Gate Corporation", "manufacturing", 909_090_165, Some("NameLocked")), + ]); + let reader = CityContextReader::open(&db).expect("open"); + let cities = reader.read_body_settlements("PlanetX").expect("read"); + assert_eq!(cities.len(), 3, "all three rows read back, HQ or not"); + + let hq = cities + .iter() + .find(|c| c.name == "Gate Corporation") + .expect("Standalone-HQ row must be present"); + // Every CityRecord field the D-211 pipeline reads is populated exactly + // like an ordinary city's — nothing marks this row as corp-originated. + assert_eq!(hq.economic_role, "manufacturing"); + assert_eq!(hq.population, 909_090_165); + assert_eq!(hq.settlement_class, SettlementClass::NameLocked); + assert!(!hq.is_capital, "Standalone HQ is not a capital by default"); + } + // ─── read_body_city_names (T-949) ──────────────────────────────────────── #[test]