fix(simulation): PR #173 review round — all 9 findings addressed
T1: heritage corridor_pool excluded from the ordinary phase-1 lottery and coverage repair (D-232 reserves the heritage sub-pool for the remoteness dial); reachable via hero pin, necessity swerve, and the T-1003 heritage pool only. 3 new tests. T2+H4: coverage repair no longer grows trait_selection past K when all slots are pinned (phase-2 necessity swerve serves the type instead); runtime warn when authored pins exceed K; V-TT-05 importer guardrail bounds pins per body at 5 (max ComplexityTier K). 2 new tests + 2 python tests. H1: body-level dispatch aggregation extracted to pure aggregate_body_dispatch_inputs + tested directly (union mix, MAX prosperity/K); threading test asserts identical vocabulary/pools across co-body settlements with per-settlement swerve rates. 2 new tests. H2: tooling/economy-db/test_traits.py — 14 stdlib unittest cases over V-TT-03/04/05 failure branches, wired into make test-tooling. H3: hard-gate JSON parsers now tracing::warn on malformed blobs (silent gate-widening) matching the sibling map parsers. H5: catalog read memoized (OnceLock) — SQL+parse once per server run, bias stays per-body. 1 new test. T3: TraitDistrict seed-domain doc aligned with the two-level derive chain. T4: D-225 misattribution dropped from the reader module doc. systems.db regenerated + stamped (traits.py is a stamped source). Gates: full cargo test 1638 green (goldens intact), clippy -D warnings, ruff, make test-tooling (now incl. the traits units). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+241
-40
@@ -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<DistrictType> =
|
||||
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<DistrictType> =
|
||||
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<DistrictType>,
|
||||
/// 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<DistrictType> = 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<DistrictType> = 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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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(
|
||||
|
||||
@@ -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<Mutex<Connection>>,
|
||||
/// 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<Vec<TraitTemplate>>,
|
||||
}
|
||||
|
||||
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<Vec<TraitTemplate>, 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<Vec<TraitTemplate>, TraitCatalogReadError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
@@ -283,7 +304,16 @@ pub(crate) fn parse_district_type(s: &str) -> Option<DistrictType> {
|
||||
|
||||
fn parse_bulk_class_gate(json: Option<&str>, tag: &str) -> Vec<BulkClass> {
|
||||
let Some(json) = json else { return Vec::new() };
|
||||
let raw: Vec<String> = 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<String> = 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<BulkClass> {
|
||||
|
||||
fn parse_production_ubiquity_gate(json: Option<&str>, tag: &str) -> Vec<ProductionUbiquity> {
|
||||
let Some(json) = json else { return Vec::new() };
|
||||
let raw: Vec<String> = serde_json::from_str(json).unwrap_or_default();
|
||||
// Same hard-gate-widening concern as parse_bulk_class_gate above (H3).
|
||||
let raw: Vec<String> = 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();
|
||||
|
||||
@@ -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<TraitTemplate> = (0..8)
|
||||
|
||||
+7
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user