fix(simulation): PR #173 review round — all 9 findings addressed
T1: heritage corridor_pool excluded from the ordinary phase-1 lottery and coverage repair (D-232 reserves the heritage sub-pool for the remoteness dial); reachable via hero pin, necessity swerve, and the T-1003 heritage pool only. 3 new tests. T2+H4: coverage repair no longer grows trait_selection past K when all slots are pinned (phase-2 necessity swerve serves the type instead); runtime warn when authored pins exceed K; V-TT-05 importer guardrail bounds pins per body at 5 (max ComplexityTier K). 2 new tests + 2 python tests. H1: body-level dispatch aggregation extracted to pure aggregate_body_dispatch_inputs + tested directly (union mix, MAX prosperity/K); threading test asserts identical vocabulary/pools across co-body settlements with per-settlement swerve rates. 2 new tests. H2: tooling/economy-db/test_traits.py — 14 stdlib unittest cases over V-TT-03/04/05 failure branches, wired into make test-tooling. H3: hard-gate JSON parsers now tracing::warn on malformed blobs (silent gate-widening) matching the sibling map parsers. H5: catalog read memoized (OnceLock) — SQL+parse once per server run, bias stays per-body. 1 new test. T3: TraitDistrict seed-domain doc aligned with the two-level derive chain. T4: D-225 misattribution dropped from the reader module doc. systems.db regenerated + stamped (traits.py is a stamped source). Gates: full cargo test 1638 green (goldens intact), clippy -D warnings, ruff, make test-tooling (now incl. the traits units). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+241
-40
@@ -136,15 +136,13 @@ fn drain_generation_completions(
|
||||
// build the body-wide coverage aggregate here and to build
|
||||
// that placement's own skeleton work item below, so this
|
||||
// dispatch pass makes exactly one `read_set` DB round trip per
|
||||
// settlement (same as before this ticket).
|
||||
// settlement (same as before this ticket). The aggregation
|
||||
// math itself is the pure `aggregate_body_dispatch_inputs`
|
||||
// (unit-tested directly, PR #173 review H1).
|
||||
let mut resolved: Vec<(&CityPlacement, CityEconomicReadSet)> = Vec::new();
|
||||
let mut body_district_type_mix: std::collections::BTreeSet<DistrictType> =
|
||||
Default::default();
|
||||
let mut max_prosperity_bps: u32 = 0;
|
||||
let mut max_k: usize = 0;
|
||||
for placement in &state.placements {
|
||||
let read_set = match reader.read_set(placement.city_id, world_seed) {
|
||||
Ok(rs) => rs,
|
||||
match reader.read_set(placement.city_id, world_seed) {
|
||||
Ok(rs) => resolved.push((placement, rs)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
city_id = placement.city_id,
|
||||
@@ -152,41 +150,14 @@ fn drain_generation_completions(
|
||||
error = %e,
|
||||
"L3→L4 dispatch: read_set failed — skipping placement"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
max_prosperity_bps =
|
||||
max_prosperity_bps.max(read_set.prosperity_baseline_bps);
|
||||
// Re-derives the identical DistrictType mix
|
||||
// `generate_quarter_skeleton` computes later for this same
|
||||
// placement (same chain, same inputs) — cheap (a 16-draw
|
||||
// seeded LCG) and gives the aggregation loop the *actual*
|
||||
// district-type mix rather than a proxy.
|
||||
let mix_chain = SeedChain::for_body(world_seed, &body_id)
|
||||
.derive(SeedDomain::Layer4Quarter, placement.city_id);
|
||||
let mix = compute_district_mix(
|
||||
read_set.population,
|
||||
&read_set.economic_role,
|
||||
&placement.political_archetype,
|
||||
16,
|
||||
mix_chain,
|
||||
);
|
||||
body_district_type_mix.extend(mix.districts);
|
||||
// world_tier has no real derivation yet (city_context_reader's
|
||||
// #TBD stub, always Waypoint) — tracked here via population
|
||||
// tier alone so a future world_tier derivation slots into
|
||||
// this MAX-K aggregate without revisiting this loop.
|
||||
let tier = derive_complexity(
|
||||
&WorldTier::Waypoint,
|
||||
population_tier(read_set.population),
|
||||
read_set.population,
|
||||
);
|
||||
max_k = max_k.max(complexity_k(&tier));
|
||||
|
||||
resolved.push((placement, read_set));
|
||||
}
|
||||
}
|
||||
let body_district_type_mix: Vec<DistrictType> =
|
||||
body_district_type_mix.into_iter().collect();
|
||||
let BodyDispatchAggregates {
|
||||
body_district_type_mix,
|
||||
max_prosperity_bps,
|
||||
max_k,
|
||||
} = aggregate_body_dispatch_inputs(&resolved, world_seed, &body_id);
|
||||
|
||||
// Phase-1 K-draw (D-232): computed once, shared by every
|
||||
// settlement on this body — the closed-vocabulary invariant.
|
||||
@@ -307,6 +278,65 @@ fn drain_generation_completions(
|
||||
}
|
||||
}
|
||||
|
||||
/// Body-level aggregates feeding the phase-1 K-draw (T-994), computed over the
|
||||
/// successfully-resolved placements of one body.
|
||||
struct BodyDispatchAggregates {
|
||||
/// Every `DistrictType` any settlement on the body will produce, deduped
|
||||
/// and deterministically ordered (BTreeSet iteration, D-010).
|
||||
body_district_type_mix: Vec<DistrictType>,
|
||||
/// MAX prosperity across settlements — the vocabulary gate is
|
||||
/// coverage-aware (see `VocabularyDrawInputs::max_prosperity_bps`).
|
||||
max_prosperity_bps: u32,
|
||||
/// MAX `complexity_k` across settlements (see the `trait_draw`
|
||||
/// module-level note on body-vs-settlement K).
|
||||
max_k: usize,
|
||||
}
|
||||
|
||||
/// The pure aggregation math behind the L3→L4 dispatch (T-994) — split out of
|
||||
/// `drain_generation_completions` so it unit-tests without a DB, queue, or
|
||||
/// Bevy world (PR #173 review H1).
|
||||
fn aggregate_body_dispatch_inputs(
|
||||
resolved: &[(&CityPlacement, CityEconomicReadSet)],
|
||||
world_seed: u64,
|
||||
body_id: &str,
|
||||
) -> BodyDispatchAggregates {
|
||||
let mut body_district_type_mix: std::collections::BTreeSet<DistrictType> = Default::default();
|
||||
let mut max_prosperity_bps: u32 = 0;
|
||||
let mut max_k: usize = 0;
|
||||
for (placement, read_set) in resolved {
|
||||
max_prosperity_bps = max_prosperity_bps.max(read_set.prosperity_baseline_bps);
|
||||
// Re-derives the identical DistrictType mix `generate_quarter_skeleton`
|
||||
// computes later for this same placement (same chain, same inputs) —
|
||||
// cheap (a 16-draw seeded LCG) and gives the aggregate the *actual*
|
||||
// district-type mix rather than a proxy.
|
||||
let mix_chain = SeedChain::for_body(world_seed, body_id)
|
||||
.derive(SeedDomain::Layer4Quarter, placement.city_id);
|
||||
let mix = compute_district_mix(
|
||||
read_set.population,
|
||||
&read_set.economic_role,
|
||||
&placement.political_archetype,
|
||||
16,
|
||||
mix_chain,
|
||||
);
|
||||
body_district_type_mix.extend(mix.districts);
|
||||
// world_tier has no real derivation yet (city_context_reader's #TBD
|
||||
// stub, always Waypoint) — tracked here via population tier alone so a
|
||||
// future world_tier derivation slots into this MAX-K aggregate without
|
||||
// revisiting this loop.
|
||||
let tier = derive_complexity(
|
||||
&WorldTier::Waypoint,
|
||||
population_tier(read_set.population),
|
||||
read_set.population,
|
||||
);
|
||||
max_k = max_k.max(complexity_k(&tier));
|
||||
}
|
||||
BodyDispatchAggregates {
|
||||
body_district_type_mix: body_district_type_mix.into_iter().collect(),
|
||||
max_prosperity_bps,
|
||||
max_k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Body-level D-232 trait-vocabulary draw outputs, threaded into
|
||||
/// [`build_skeleton_work_item`] (T-994). Bundled into one struct purely to keep
|
||||
/// that function's argument count under the clippy `too_many_arguments`
|
||||
@@ -800,6 +830,177 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── T-994 body-level aggregation + threading (PR #173 review H1) ─────────
|
||||
|
||||
fn read_set_with(
|
||||
prosperity_bps: u32,
|
||||
population: i64,
|
||||
founding_age: u32,
|
||||
) -> CityEconomicReadSet {
|
||||
CityEconomicReadSet {
|
||||
prosperity_baseline_bps: prosperity_bps,
|
||||
population,
|
||||
founding_age_years: founding_age,
|
||||
..sample_read_set()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_body_dispatch_inputs_unions_mixes_and_takes_maxes() {
|
||||
let p1 = sample_placement(1, FoundingOrientation::Cardinal);
|
||||
let p2 = sample_placement(2, FoundingOrientation::Cardinal);
|
||||
// City 1: ghost-stub population (< 5K on Waypoint → ComplexityTier::Empty, K=0).
|
||||
// City 2: normal city (Waypoint → Minimal, K=1).
|
||||
let rs1 = read_set_with(6_000, 3_000, 200);
|
||||
let rs2 = read_set_with(8_500, 500_000, 200);
|
||||
let resolved = vec![(&p1, rs1.clone()), (&p2, rs2.clone())];
|
||||
|
||||
let agg = aggregate_body_dispatch_inputs(&resolved, 42, "BodyAgg");
|
||||
assert_eq!(
|
||||
agg.max_prosperity_bps, 8_500,
|
||||
"MAX prosperity across settlements"
|
||||
);
|
||||
assert_eq!(agg.max_k, 1, "MAX complexity K across settlements (0 vs 1)");
|
||||
|
||||
// The coverage mix must be exactly the union of each placement's own
|
||||
// deterministic district mix (same chains generate_quarter_skeleton uses).
|
||||
let mut expected: std::collections::BTreeSet<DistrictType> = Default::default();
|
||||
for (p, rs) in [(&p1, &rs1), (&p2, &rs2)] {
|
||||
let chain =
|
||||
SeedChain::for_body(42, "BodyAgg").derive(SeedDomain::Layer4Quarter, p.city_id);
|
||||
expected.extend(
|
||||
compute_district_mix(
|
||||
rs.population,
|
||||
&rs.economic_role,
|
||||
&p.political_archetype,
|
||||
16,
|
||||
chain,
|
||||
)
|
||||
.districts,
|
||||
);
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
assert_eq!(
|
||||
agg.body_district_type_mix,
|
||||
expected.into_iter().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatched_contexts_share_vocabulary_but_carry_per_settlement_swerve_rates() {
|
||||
use crate::atlas::trait_catalog_reader::TraitTemplate;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn tmpl(tag: &str) -> TraitTemplate {
|
||||
TraitTemplate {
|
||||
tag: tag.to_string(),
|
||||
corridor_pool: "baseline".to_string(),
|
||||
geographic_sector: None,
|
||||
bulk_class_gate: Vec::new(),
|
||||
production_ubiquity_gate: Vec::new(),
|
||||
min_prosperity_bps: 0,
|
||||
base_weight: 10_000,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: [(DistrictType::MixedUse, 10_000)].into_iter().collect(),
|
||||
}
|
||||
}
|
||||
let catalog = vec![tmpl("temp_a"), tmpl("temp_b")];
|
||||
let eligible: Vec<&TraitTemplate> = catalog.iter().collect();
|
||||
let trait_selection = vec!["temp_a".to_string(), "temp_b".to_string()];
|
||||
let mix = vec![DistrictType::MixedUse];
|
||||
let pools = SwervePools {
|
||||
foreign: vec![("foreign_x".to_string(), 10_000)],
|
||||
heritage: vec![("herit_y".to_string(), 10_000)],
|
||||
};
|
||||
let vocab = BodyVocabularyContext {
|
||||
trait_selection: &trait_selection,
|
||||
body_district_type_mix: &mix,
|
||||
catalog: &catalog,
|
||||
eligible: &eligible,
|
||||
swerve_pools: &pools,
|
||||
};
|
||||
|
||||
// City 1 sits in the road graph with degree 2; city 2 has no node (degree 0).
|
||||
let road_graph = RoadGraph {
|
||||
nodes: vec![
|
||||
RoadNode {
|
||||
city_id: Some(1),
|
||||
position: (10, 20),
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 2,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(90),
|
||||
position: (10, 4),
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
RoadNode {
|
||||
city_id: Some(91),
|
||||
position: (26, 20),
|
||||
kind: RoadNodeKind::Settlement,
|
||||
degree: 1,
|
||||
parent_edge: None,
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
RoadEdge {
|
||||
from: 0,
|
||||
to: 1,
|
||||
path: vec![(10, 20), (10, 4)],
|
||||
length_cells: 16,
|
||||
maintenance: MaintenanceAuthority::Administrative,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
},
|
||||
RoadEdge {
|
||||
from: 0,
|
||||
to: 2,
|
||||
path: vec![(10, 20), (26, 20)],
|
||||
length_cells: 16,
|
||||
maintenance: MaintenanceAuthority::Administrative,
|
||||
named_route_id: None,
|
||||
is_rail: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let build = |city_id: u64, founding_age: u32| {
|
||||
let GenWorkItem::GenerateSkeleton { context, .. } = build_skeleton_work_item(
|
||||
"BodyThread",
|
||||
42,
|
||||
&sample_placement(city_id, FoundingOrientation::Cardinal),
|
||||
read_set_with(6_000, 500_000, founding_age),
|
||||
&BTreeMap::new(),
|
||||
&road_graph,
|
||||
&vocab,
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
context
|
||||
};
|
||||
let ctx1 = build(1, 50); // connected (degree 2), young settlement
|
||||
let ctx2 = build(2, 400); // off-graph (degree 0), old settlement
|
||||
|
||||
// The body-level draw outputs are identical on both settlements — the
|
||||
// closed-vocabulary invariant threaded through dispatch.
|
||||
assert_eq!(ctx1.trait_selection, ctx2.trait_selection);
|
||||
assert_eq!(ctx1.trait_selection, trait_selection);
|
||||
assert_eq!(ctx1.body_district_type_mix, ctx2.body_district_type_mix);
|
||||
assert_eq!(ctx1.swerve_foreign_pool, ctx2.swerve_foreign_pool);
|
||||
assert_eq!(ctx1.swerve_heritage_pool, ctx2.swerve_heritage_pool);
|
||||
assert_eq!(ctx1.swerve_foreign_pool, pools.foreign);
|
||||
|
||||
// The swerve RATES are per-settlement (T-1003 drivers): city 1 gets the
|
||||
// road-degree centrality bump on foreign (100 → 120) and no isolation
|
||||
// multiplier on heritage (Waypoint remote ×1.5 only → 150); city 2 is
|
||||
// isolated (×2.0) + remote (×1.5) + old (×1.5) → capped at 300.
|
||||
assert_eq!(ctx1.swerve_rates_bps, (120, 150));
|
||||
assert_eq!(ctx2.swerve_rates_bps, (100, 300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_skeleton_work_item_threads_orientation_and_canonical_quarter_id() {
|
||||
let placement = sample_placement(
|
||||
|
||||
Reference in New Issue
Block a user