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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user