fix(simulation): PR #178 review round — H1-H4, T1, T2

H1: split_edge_at now splits length_cells geometric-distance proportionally (fixed-order f64 polyline sums, halves sum exactly to parent) — subsumes the 2-point-parent all-or-nothing bug. H2: chained-snap test (minor onto minor spur) + determinism assertion actually exercising attach_minors + direct 2-point split unit test. H3: cascade-level RailHeadFacing end-to-end assertion on a plus-shaped landmass fixture (2 deterministic degree-3 settlement junctions). H4+T2: STANDALONE_HQ_JOIN_SQL shared constant, both readers compose it; believability lockstep unit test. T1: HUB_SPACING_DIAG_PX doc cross-refs D-243 elastic seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 17:03:30 +02:00
co-authored by Claude Fable 5
parent 5d37d205e1
commit 1fee6fd9da
4 changed files with 404 additions and 38 deletions
+170 -6
View File
@@ -110,6 +110,10 @@ pub const JUNCTION_DEGREE: u16 = 3;
/// diagonal ([`RouteGrid::body_scale`]) — the same measure every other
/// workshop tunable in this file keys off (`SECONDARY_*_FRAC`,
/// `LONG_HAUL_FRAC`); physical radius is not available to this layer.
/// Fixed-grid premise: revisit when [D-243]'s elastic planetary seam gives
/// bodies varying grid sizes (`round(2πR / 204.8 km)` regions) — the diagonal
/// then genuinely scales with `body_radius_km` and this constant starts doing
/// real per-body work (PR #178 T1).
const HUB_SPACING_DIAG_PX: f64 = 64.0;
/// Floor for the hub cap: small grids keep at least this many trunk hubs so
@@ -590,13 +594,34 @@ fn project_onto_segment(p: (u16, u16), a: (u16, u16), b: (u16, u16)) -> (f64, (u
(d2, (jr.round() as u16, jc.round() as u16))
}
/// Geometric length of a polyline in grid px — Euclidean segment lengths
/// summed in path order (fixed order + correctly-rounded f64 ops, so the sum
/// is bit-reproducible per the module's determinism doctrine).
fn polyline_len(path: &[(u16, u16)]) -> f64 {
let mut total = 0.0;
for w in path.windows(2) {
total += euclid(
(w[0].0 as f64, w[0].1 as f64),
(w[1].0 as f64, w[1].1 as f64),
);
}
total
}
/// Split `edges[ei]` at `jpos` on segment `si`: a new [`RoadNodeKind::Junction`]
/// node replaces the single edge with two halves meeting at the junction.
/// Both halves inherit the parent's maintenance/named-route identity;
/// `length_cells` splits proportionally by polyline vertex count (a routed-
/// length proxy — the halves' true routed lengths are not re-measured).
/// Returns the junction's node index. Caller guarantees `jpos` is not an
/// endpoint of the edge's path (guarded at the call site).
/// `length_cells` splits **geometric-distance proportionally** — each half
/// gets the parent's routed length scaled by its polyline's share of the
/// total geometry (PR #178 H1: the earlier vertex-count proxy went
/// all-or-nothing on 2-point parents — `l1 = full, l2 = 0` wherever the
/// junction fell — and 2-point parents are routine: every snap spur is one,
/// so chained snaps always hit it). The halves always sum exactly to the
/// parent's `length_cells` (l2 is the remainder), and an interior junction
/// on a parent with `length_cells > 0` gives both halves a non-zero share
/// whenever its geometry does. Returns the junction's node index. Caller
/// guarantees `jpos` is not an endpoint of the edge's path (guarded at the
/// call site).
fn split_edge_at(
nodes: &mut Vec<RoadNode>,
edges: &mut Vec<RoadEdge>,
@@ -626,8 +651,13 @@ fn split_edge_at(
path2.extend_from_slice(&old.path[si + 1..]);
}
let total_segs = (old.path.len() - 1).max(1) as u32;
let l1 = old.length_cells * (path1.len().saturating_sub(1) as u32) / total_segs;
let (len1, len2) = (polyline_len(&path1), polyline_len(&path2));
let total = len1 + len2;
let l1 = if total > 0.0 {
((old.length_cells as f64 * len1 / total).round() as u32).min(old.length_cells)
} else {
0 // fully degenerate geometry (all points coincide) — nothing to apportion
};
let l2 = old.length_cells - l1;
edges[ei] = norm_edge(RoadEdge {
@@ -1784,4 +1814,138 @@ mod tests {
));
}
}
// ─── PR #178 H1/H2 — edge-split length arithmetic ────────────────────────
/// Direct unit test of the H1 bug shape: a 2-POINT parent path with real
/// `length_cells`. The old vertex-count proxy computed
/// `l1 = length × (path1.len()-1) / total_segs` = all-or-nothing whenever
/// `total_segs == 1`; the geometric split apportions by where the junction
/// actually falls.
#[test]
fn split_edge_at_two_point_parent_splits_geometrically() {
let mk_node = |pos: (u16, u16)| RoadNode {
city_id: Some(99),
position: pos,
kind: RoadNodeKind::Settlement,
degree: 0,
parent_edge: None,
is_hub: true,
};
// Midpoint split: 10 cells → 5 + 5.
let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))];
let mut edges = vec![RoadEdge {
from: 0,
to: 1,
path: vec![(10, 10), (10, 30)],
length_cells: 10,
maintenance: MaintenanceAuthority::Communal,
named_route_id: None,
is_rail: false,
}];
let jn = split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 20));
assert_eq!(nodes[jn].kind, RoadNodeKind::Junction);
assert_eq!(edges.len(), 2);
assert_eq!(edges[0].length_cells, 5, "midpoint → equal halves");
assert_eq!(edges[1].length_cells, 5);
// Quarter-point split: 10 cells at t=0.25 → 2/3 + remainder split, but
// NEVER all-or-nothing: both halves non-zero, summing to the parent.
let mut nodes = vec![mk_node((10, 10)), mk_node((10, 30))];
let mut edges = vec![RoadEdge {
from: 0,
to: 1,
path: vec![(10, 10), (10, 30)],
length_cells: 10,
maintenance: MaintenanceAuthority::Communal,
named_route_id: None,
is_rail: false,
}];
split_edge_at(&mut nodes, &mut edges, 0, 0, (10, 15));
let (l1, l2) = (edges[0].length_cells, edges[1].length_cells);
assert_eq!(l1 + l2, 10, "halves always sum to the parent");
assert!(l1 > 0 && l2 > 0, "interior split is never all-or-nothing");
assert!(l1 < l2, "the shorter geometric half gets the smaller share");
}
/// H2(a) — chained snap: minor B snaps onto minor A's already-created spur
/// (the accreting behavior), splitting a 2-point spur path. Also the H2(b)
/// determinism pin on a scenario that actually exercises attach_minors
/// (the pre-existing determinism test's 5 placements all become hubs under
/// HUB_CAP_MIN = 6, so it never reaches the attach path).
#[test]
fn chained_snap_onto_earlier_spur_and_attach_determinism() {
let hm = flat_hm(64, 32);
let ta = ta_for(&hm);
let mut placements: Vec<CityPlacement> = vec![
placement(1, (16, 4), PoliticalArchetype::Pioneer),
placement(2, (16, 60), PoliticalArchetype::Pioneer),
placement(3, (4, 4), PoliticalArchetype::Pioneer),
placement(4, (4, 60), PoliticalArchetype::Pioneer),
placement(5, (28, 4), PoliticalArchetype::Pioneer),
placement(6, (28, 60), PoliticalArchetype::Pioneer),
];
for p in placements.iter_mut() {
p.population = 1_000_000; // the six trunk hubs
}
// Minor A: 8 px above the row-16 trunk edge → boundary snap; its spur
// runs down col 32 from the trunk to (8,32).
let mut minor_a = placement(7, (8, 32), PoliticalArchetype::Pioneer);
minor_a.population = 10_000;
// Minor B: 3 px from A's spur segment, 4 px from the trunk → B's
// nearest edge is the spur an earlier attachment created.
let mut minor_b = placement(8, (12, 35), PoliticalArchetype::Pioneer);
minor_b.population = 5_000;
placements.push(minor_a);
placements.push(minor_b);
let run = || {
build_road_graph(
&placements,
&ta,
&[],
64,
32,
&TerritorialStatus::FrontierUnclaimed,
&[],
)
};
let g = run();
let junctions: Vec<usize> = g
.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.kind == RoadNodeKind::Junction)
.map(|(i, _)| i)
.collect();
assert_eq!(
junctions.len(),
2,
"A splits the trunk, B splits A's spur — two junctions"
);
// B's junction sits ON A's spur (col 32, strictly between the trunk
// row and A's row) — proving the chain hit a 2-point spur parent.
let chained = junctions
.iter()
.map(|&ji| g.nodes[ji].position)
.find(|p| p.1 == 32 && p.0 > 8 && p.0 < 16)
.expect("a junction must sit interior to A's spur on col 32");
assert_eq!(chained.1, 32);
// Both minors are connected; every edge honours the invariants
// (including the split halves of the 2-point spur).
for mi in [6usize, 7usize] {
assert!(
g.edges.iter().any(|e| e.from == mi || e.to == mi),
"minor node {mi} must be attached"
);
}
for e in &g.edges {
assert!(e.from < e.to);
assert_eq!(g.nodes[e.from].position, *e.path.first().unwrap());
assert_eq!(g.nodes[e.to].position, *e.path.last().unwrap());
}
// H2(b): the whole accreting attach sequence is deterministic.
assert_eq!(run(), run(), "attach_minors must be deterministic");
}
}