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