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:
2026-07-08 12:18:14 +02:00
co-authored by Claude Fable 5
parent 034791602b
commit ebe8742469
8 changed files with 895 additions and 62 deletions
+4 -1
View File
@@ -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 \
Binary file not shown.
+241 -40
View File
@@ -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(
+70 -8
View File
@@ -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();
+241 -7
View File
@@ -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
View File
@@ -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
+19 -2
View File
@@ -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:
+313
View File
@@ -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)