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
+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(