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 -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)