fix(simulation): decorrelate per-footprint tag fields + test gaps (#957)

PR #153 review (Hoshe H1): zone_type/era/floors/flavor all shared one fp_chain
and each took splitmix64(seed) of the same value, correlating the fields (era
tracking floor height across a city). Derive a distinct sub-chain per field so
they draw independent entropy. Block_tags aren't serialized, so no fixture
change; behavior stays deterministic.

Also (review): n=1 corridor test (empty, no panic), Frontier-density waterfront
test, and a comment on the reserved `_morphology` param (Tyre).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:55:07 +02:00
co-authored by Claude Opus 4.8
parent 0fe3b15a2e
commit f880635b24
+49 -4
View File
@@ -798,6 +798,9 @@ fn block_on_quarter_edge(row: u8, col: u8, edge: Edge) -> bool {
fn subdivide_block_footprints(
density_pct: u8,
bulk: &BulkClass,
// Reserved for morphology-specific lot shaping (narrow canyon lots,
// pier-oriented delta lots) once that detail is designed; the waterfront
// rule already consumes the water-facing geometry via `waterfront`.
_morphology: &MorphologyZone,
waterfront: Option<Edge>,
seed: SeedChain,
@@ -877,17 +880,31 @@ fn assign_block_tags(
.into_iter()
.enumerate()
.map(|(i, footprint)| {
// Distinct per-field sub-chains so zone / era / floors / flavor draw
// independent entropy — sharing one `fp_chain` made every field a
// modulo of the same `splitmix64(seed)` value and correlated them
// (e.g. construction era tracking floor height across the city).
let fp_chain = block_chain.derive(SeedDomain::Block, i as u64 + 1);
let zone_type_id = zone_type_for(&block.zoning, economic_role, setting, fp_chain);
let zone_type_id = zone_type_for(
&block.zoning,
economic_role,
setting,
fp_chain.derive(SeedDomain::Block, 0),
);
let entry_class =
building_entry_class(&block.zoning, &skeleton.layout_mode, prosperity);
let (era, era_cause) = construction_era(founding_age_years, prosperity, fp_chain);
let (era, era_cause) = construction_era(
founding_age_years,
prosperity,
fp_chain.derive(SeedDomain::Block, 1),
);
let initial = initial_condition(prosperity, &era_cause);
let extent = floor_extent(block.density_pct, fp_chain);
let extent = floor_extent(block.density_pct, fp_chain.derive(SeedDomain::Block, 2));
// D-232: deterministic (seed + zone_type) → flavor index into the
// body's K-tag trait selection.
let flavor_seed = fp_chain.derive(SeedDomain::Block, 3).seed();
let flavor_index =
(splitmix64(fp_chain.seed() ^ zone_type_hash(&zone_type_id)) % flavor_n) as u8;
(splitmix64(flavor_seed ^ zone_type_hash(&zone_type_id)) % flavor_n) as u8;
BuildingPropertyTag {
zone_type_id,
footprint,
@@ -1724,6 +1741,13 @@ mod tests {
assert_eq!(derive_corridors(&mesh, &MorphologyZone::Delta).len(), 3); // hub-spoke
}
#[test]
fn corridors_single_node_returns_empty() {
let aps = derive_access_points(&[], &[]);
assert_eq!(aps.len(), 1);
assert!(derive_corridors(&aps, &MorphologyZone::AlluvialPlain).is_empty());
}
#[test]
fn hub_spoke_shares_a_common_node() {
let aps = derive_access_points(&[0, 2, 4, 6], &[]);
@@ -1769,6 +1793,27 @@ mod tests {
);
}
#[test]
fn waterfront_applies_at_frontier_density() {
// A coastal frontier settlement (few large lots) still presents to the quay.
let inland = subdivide_block_footprints(
10,
&BulkClass::BulkSolid,
&MorphologyZone::CoastalLowland,
None,
SeedChain::root(11),
);
let quay = subdivide_block_footprints(
10,
&BulkClass::BulkSolid,
&MorphologyZone::CoastalLowland,
Some(Edge::North),
SeedChain::root(11),
);
let min_y = |v: &[TileRect]| v.iter().map(|r| r.origin.1).min().unwrap_or(u8::MAX);
assert!(min_y(&quay) < min_y(&inland));
}
#[test]
fn skeleton_has_streets_and_local_lattice() {
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);