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:
@@ -538,21 +538,23 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState
|
|||||||
/// `CityContextReader::read_body_settlements` (keep the two in lockstep):
|
/// `CityContextReader::read_body_settlements` (keep the two in lockstep):
|
||||||
/// baked `settlement_class` is honoured (D-242/T-1075 bakes it on every row;
|
/// baked `settlement_class` is honoured (D-242/T-1075 bakes it on every row;
|
||||||
/// unknown/NULL falls back to `PopulationBudget`), and the T-1076
|
/// unknown/NULL falls back to `PopulationBudget`), and the T-1076
|
||||||
/// `is_standalone_hq` flag comes from the identical `corporations` LEFT JOIN.
|
/// `is_standalone_hq` flag comes from the shared
|
||||||
|
/// [`STANDALONE_HQ_JOIN_SQL`](crate::atlas::city_context_reader) join
|
||||||
|
/// fragment — single source of truth for the join shape (PR #178 T2).
|
||||||
fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
|
fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
|
||||||
|
use crate::atlas::city_context_reader::STANDALONE_HQ_JOIN_SQL;
|
||||||
|
|
||||||
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
|
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT acn.id, acn.name, COALESCE(acn.economic_role,'service_mixed'),
|
||||||
|
COALESCE(acn.population,0), COALESCE(acn.kind,'city'),
|
||||||
|
acn.settlement_class, c.corp_id IS NOT NULL
|
||||||
|
FROM atlas_city_names AS acn
|
||||||
|
{STANDALONE_HQ_JOIN_SQL}
|
||||||
|
WHERE acn.body_id = ?1 ORDER BY acn.id"
|
||||||
|
);
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(&sql)
|
||||||
"SELECT acn.id, acn.name, COALESCE(acn.economic_role,'service_mixed'),
|
|
||||||
COALESCE(acn.population,0), COALESCE(acn.kind,'city'),
|
|
||||||
acn.settlement_class, c.corp_id IS NOT NULL
|
|
||||||
FROM atlas_city_names AS acn
|
|
||||||
LEFT JOIN corporations AS c
|
|
||||||
ON c.hq_placement = 'Standalone'
|
|
||||||
AND c.headquarters_body = acn.body_id
|
|
||||||
AND c.proper_name = acn.name
|
|
||||||
WHERE acn.body_id = ?1 ORDER BY acn.id",
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("prepare city query: {e}"))?;
|
.map_err(|e| format!("prepare city query: {e}"))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([body_id], |r| {
|
.query_map([body_id], |r| {
|
||||||
@@ -759,4 +761,84 @@ mod tests {
|
|||||||
let b = analyze(7, "GJ1c", &districts);
|
let b = analyze(7, "GJ1c", &districts);
|
||||||
assert_eq!(a, b);
|
assert_eq!(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PR #178 H4 — the believability settlement reader honours baked
|
||||||
|
/// settlement_class (D-242/T-1075) and derives is_standalone_hq from the
|
||||||
|
/// shared corporations join, in lockstep with
|
||||||
|
/// `CityContextReader::read_body_settlements` (T2). Mirrors that module's
|
||||||
|
/// synthetic-DB fixture pattern.
|
||||||
|
#[test]
|
||||||
|
fn read_cities_honours_baked_class_and_standalone_hq_flag() {
|
||||||
|
use crate::simulation::generator::SettlementClass;
|
||||||
|
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("sr_believability_cities_{}.db", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let conn = rusqlite::Connection::open(&path).expect("create db");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE atlas_city_names (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
body_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'city',
|
||||||
|
economic_role TEXT,
|
||||||
|
population INTEGER NOT NULL,
|
||||||
|
settlement_class TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE corporations (
|
||||||
|
corp_id TEXT PRIMARY KEY,
|
||||||
|
proper_name TEXT NOT NULL,
|
||||||
|
hq_placement TEXT,
|
||||||
|
headquarters_body TEXT
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.expect("create tables");
|
||||||
|
for (name, pop, sclass) in [
|
||||||
|
("Hero City", 2_000_000i64, Some("NameLocked")),
|
||||||
|
("Plain Town", 300_000, Some("PopulationBudget")),
|
||||||
|
("Weird Row", 50_000, Some("TotallyBogus")), // unknown → default
|
||||||
|
("Gate Corporation", 900_000, Some("PopulationBudget")),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO atlas_city_names
|
||||||
|
(body_id, name, economic_role, population, settlement_class)
|
||||||
|
VALUES ('PlanetX', ?1, 'manufacturing', ?2, ?3)",
|
||||||
|
rusqlite::params![name, pop, sclass],
|
||||||
|
)
|
||||||
|
.expect("insert city");
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO corporations (corp_id, proper_name, hq_placement, headquarters_body)
|
||||||
|
VALUES ('gate-corporation', 'Gate Corporation', 'Standalone', 'PlanetX')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("insert corp");
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
let cities = read_cities(&path, "PlanetX").expect("read");
|
||||||
|
assert_eq!(cities.len(), 4);
|
||||||
|
let by_name = |n: &str| cities.iter().find(|c| c.name == n).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
by_name("Hero City").settlement_class,
|
||||||
|
SettlementClass::NameLocked,
|
||||||
|
"baked NameLocked must be honoured, not flattened to the default"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
by_name("Plain Town").settlement_class,
|
||||||
|
SettlementClass::PopulationBudget
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
by_name("Weird Row").settlement_class,
|
||||||
|
SettlementClass::PopulationBudget,
|
||||||
|
"unknown class text falls back to the default"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
by_name("Gate Corporation").is_standalone_hq,
|
||||||
|
"registered Standalone corp row is flagged via the shared join"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!by_name("Plain Town").is_standalone_hq,
|
||||||
|
"unregistered rows stay unflagged"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -689,4 +689,109 @@ mod tests {
|
|||||||
edge_count
|
edge_count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PR #178 H3 — the RailHeadFacing wiring end-to-end (T-1076 §4): after the
|
||||||
|
/// RoadGraph layer runs, the cascade must have mutated
|
||||||
|
/// `layer3.placements[..].founding_orientation` to `RailHeadFacing` for
|
||||||
|
/// exactly the settlements that are high-connectivity junctions
|
||||||
|
/// (degree ≥ 3), and for no others. A regression that drops the
|
||||||
|
/// `assign_railhead_orientations` call (or runs it before the graph
|
||||||
|
/// exists) fails here mechanically.
|
||||||
|
#[test]
|
||||||
|
fn cascade_assigns_railhead_orientation_at_junctions() {
|
||||||
|
use crate::atlas::attractor_matching::CityRecord;
|
||||||
|
use crate::atlas::road_graph::{RoadNodeKind, JUNCTION_DEGREE};
|
||||||
|
use crate::simulation::generator::{FoundingOrientation, SettlementClass};
|
||||||
|
|
||||||
|
// A plus-shaped landmass (arms meeting at the centre, ocean elsewhere):
|
||||||
|
// settlements string along the arms, so the trunk MST must branch where
|
||||||
|
// the arms meet — diagnosed to yield exactly 2 degree-3 settlement
|
||||||
|
// junctions with 8 cities. Deterministic: the junction requirement
|
||||||
|
// below is a stable fixture property, not flakiness. (The default
|
||||||
|
// slope fixture never branches — its coastal attractors form a chain,
|
||||||
|
// and snapped minors raise Junction-node degrees, not settlement
|
||||||
|
// degrees.)
|
||||||
|
let (width, height) = (64u32, 32u32);
|
||||||
|
let mut data = vec![0.05f32; (width * height) as usize]; // ocean
|
||||||
|
for r in 0..height {
|
||||||
|
for c in 0..width {
|
||||||
|
let in_v_arm = (24..40).contains(&c); // vertical arm
|
||||||
|
let in_h_arm = (12..20).contains(&r); // horizontal arm
|
||||||
|
if in_v_arm || in_h_arm {
|
||||||
|
data[(r * width + c) as usize] = 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cross_hm = crate::atlas::heightmap::BodyHeightmap {
|
||||||
|
body_id: "test_body".into(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
data,
|
||||||
|
sea_level: 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cities: Vec<CityRecord> = (1..=8u64)
|
||||||
|
.map(|id| CityRecord {
|
||||||
|
city_id: id,
|
||||||
|
name: format!("City{id}"),
|
||||||
|
settlement_class: SettlementClass::PopulationBudget,
|
||||||
|
population: 1_000_000 - (id as i64) * 1_000, // hubs = lowest 6 ids
|
||||||
|
economic_role: "manufacturing".into(),
|
||||||
|
is_capital: false,
|
||||||
|
is_standalone_hq: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let snap = run_cascade_from_heightmap(
|
||||||
|
body_seed(),
|
||||||
|
cross_hm,
|
||||||
|
&cities,
|
||||||
|
Some("independent"),
|
||||||
|
None,
|
||||||
|
CascadeLayer::RoadGraph,
|
||||||
|
);
|
||||||
|
let graph = snap.road_graph.as_ref().expect("RoadGraph layer ran");
|
||||||
|
let junction_city_ids: Vec<u64> = graph
|
||||||
|
.high_connectivity_junctions()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| graph.nodes[i].city_id)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!junction_city_ids.is_empty(),
|
||||||
|
"fixture must produce at least one degree ≥ {JUNCTION_DEGREE} settlement \
|
||||||
|
junction — if this fires the fixture changed, not the wiring"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The wiring assertion, both directions: junction settlements carry
|
||||||
|
// RailHeadFacing; every other settlement does not.
|
||||||
|
let l3 = snap.layer3.as_ref().expect("Layer 3 ran");
|
||||||
|
for p in &l3.placements {
|
||||||
|
let is_junction = junction_city_ids.contains(&p.city_id);
|
||||||
|
let is_rail = matches!(
|
||||||
|
p.founding_orientation,
|
||||||
|
FoundingOrientation::RailHeadFacing { .. }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
is_junction, is_rail,
|
||||||
|
"city {} junction={} but rail_facing={} — cascade wiring broken",
|
||||||
|
p.city_id, is_junction, is_rail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// And the mutated placements are what BodyWorldState carries forward.
|
||||||
|
let junction_count = junction_city_ids.len();
|
||||||
|
let state = snap.into_body_world_state();
|
||||||
|
let rail_count = state
|
||||||
|
.placements
|
||||||
|
.iter()
|
||||||
|
.filter(|p| {
|
||||||
|
matches!(
|
||||||
|
p.founding_orientation,
|
||||||
|
FoundingOrientation::RailHeadFacing { .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(rail_count, junction_count);
|
||||||
|
// Silence unused-import warning when the filter above changes.
|
||||||
|
let _ = RoadNodeKind::Settlement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,25 @@ use crate::simulation::generator::{
|
|||||||
ProductionUbiquity, SettingType, SettlementClass, WorldTier,
|
ProductionUbiquity, SettingType, SettlementClass, WorldTier,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared SQL fragments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The D-242 standalone-corp-HQ LEFT JOIN (T-1076 §1; single source of truth,
|
||||||
|
/// PR #178 T2). Marks an `atlas_city_names` row (aliased `acn`) as a Standalone
|
||||||
|
/// corp-HQ company town when a `corporations` row (aliased `c`) matches on
|
||||||
|
/// `hq_placement = 'Standalone'`, `headquarters_body = body_id`, and
|
||||||
|
/// `proper_name = name` — the exact shape
|
||||||
|
/// `populate_standalone_hq_settlements` (economy_import/corporations.py) emits
|
||||||
|
/// settlement rows with. Composed into a query whose FROM clause aliases
|
||||||
|
/// `atlas_city_names AS acn`; the flag is read as `c.corp_id IS NOT NULL`.
|
||||||
|
/// Used by [`CityContextReader::read_body_settlements`] and the believability
|
||||||
|
/// harness's `read_cities` — extend both if the join shape ever changes.
|
||||||
|
pub(crate) const STANDALONE_HQ_JOIN_SQL: &str = "LEFT JOIN corporations AS c
|
||||||
|
ON c.hq_placement = 'Standalone'
|
||||||
|
AND c.headquarters_body = acn.body_id
|
||||||
|
AND c.proper_name = acn.name";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error type
|
// Error type
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -266,14 +285,12 @@ impl CityContextReader {
|
|||||||
/// any malformed field), this method is best-effort: it never fails on row
|
/// any malformed field), this method is best-effort: it never fails on row
|
||||||
/// content, only on a DB/connection error.
|
/// content, only on a DB/connection error.
|
||||||
///
|
///
|
||||||
/// `is_standalone_hq` (T-1076 §1): a LEFT JOIN against `corporations`
|
/// `is_standalone_hq` (T-1076 §1): the [`STANDALONE_HQ_JOIN_SQL`] LEFT JOIN
|
||||||
/// marks the rows that are D-242 Standalone corp-HQ company towns
|
/// marks the rows that are D-242 Standalone corp-HQ company towns. The
|
||||||
/// (`hq_placement = 'Standalone'`, `headquarters_body = body_id`,
|
/// road-graph hub rule demotes these to minor nodes regardless of
|
||||||
/// `proper_name = name` — the exact shape `populate_standalone_hq_settlements`
|
/// population. The believability harness's own settlement read
|
||||||
/// emits them with). The road-graph hub rule demotes these to minor nodes
|
/// (`believability::read_cities`) composes the same shared constant —
|
||||||
/// regardless of population. The believability harness's own settlement
|
/// single source of truth for the join (PR #178 T2).
|
||||||
/// read (`believability::read_cities`) performs the identical join — keep
|
|
||||||
/// the two in lockstep.
|
|
||||||
pub fn read_body_settlements(
|
pub fn read_body_settlements(
|
||||||
&self,
|
&self,
|
||||||
body_id: &str,
|
body_id: &str,
|
||||||
@@ -282,19 +299,17 @@ impl CityContextReader {
|
|||||||
.conn
|
.conn
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT acn.id, acn.name, acn.economic_role, acn.population,
|
||||||
|
acn.settlement_class, COALESCE(acn.kind, 'city'),
|
||||||
|
c.corp_id IS NOT NULL
|
||||||
|
FROM atlas_city_names AS acn
|
||||||
|
{STANDALONE_HQ_JOIN_SQL}
|
||||||
|
WHERE acn.body_id = ?1
|
||||||
|
ORDER BY acn.id"
|
||||||
|
);
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(&sql)
|
||||||
"SELECT acn.id, acn.name, acn.economic_role, acn.population,
|
|
||||||
acn.settlement_class, COALESCE(acn.kind, 'city'),
|
|
||||||
c.corp_id IS NOT NULL
|
|
||||||
FROM atlas_city_names AS acn
|
|
||||||
LEFT JOIN corporations AS c
|
|
||||||
ON c.hq_placement = 'Standalone'
|
|
||||||
AND c.headquarters_body = acn.body_id
|
|
||||||
AND c.proper_name = acn.name
|
|
||||||
WHERE acn.body_id = ?1
|
|
||||||
ORDER BY acn.id",
|
|
||||||
)
|
|
||||||
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([body_id], |row| {
|
.query_map([body_id], |row| {
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ pub const JUNCTION_DEGREE: u16 = 3;
|
|||||||
/// diagonal ([`RouteGrid::body_scale`]) — the same measure every other
|
/// diagonal ([`RouteGrid::body_scale`]) — the same measure every other
|
||||||
/// workshop tunable in this file keys off (`SECONDARY_*_FRAC`,
|
/// workshop tunable in this file keys off (`SECONDARY_*_FRAC`,
|
||||||
/// `LONG_HAUL_FRAC`); physical radius is not available to this layer.
|
/// `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;
|
const HUB_SPACING_DIAG_PX: f64 = 64.0;
|
||||||
|
|
||||||
/// Floor for the hub cap: small grids keep at least this many trunk hubs so
|
/// 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))
|
(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`]
|
/// Split `edges[ei]` at `jpos` on segment `si`: a new [`RoadNodeKind::Junction`]
|
||||||
/// node replaces the single edge with two halves meeting at the junction.
|
/// node replaces the single edge with two halves meeting at the junction.
|
||||||
/// Both halves inherit the parent's maintenance/named-route identity;
|
/// Both halves inherit the parent's maintenance/named-route identity;
|
||||||
/// `length_cells` splits proportionally by polyline vertex count (a routed-
|
/// `length_cells` splits **geometric-distance proportionally** — each half
|
||||||
/// length proxy — the halves' true routed lengths are not re-measured).
|
/// gets the parent's routed length scaled by its polyline's share of the
|
||||||
/// Returns the junction's node index. Caller guarantees `jpos` is not an
|
/// total geometry (PR #178 H1: the earlier vertex-count proxy went
|
||||||
/// endpoint of the edge's path (guarded at the call site).
|
/// 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(
|
fn split_edge_at(
|
||||||
nodes: &mut Vec<RoadNode>,
|
nodes: &mut Vec<RoadNode>,
|
||||||
edges: &mut Vec<RoadEdge>,
|
edges: &mut Vec<RoadEdge>,
|
||||||
@@ -626,8 +651,13 @@ fn split_edge_at(
|
|||||||
path2.extend_from_slice(&old.path[si + 1..]);
|
path2.extend_from_slice(&old.path[si + 1..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_segs = (old.path.len() - 1).max(1) as u32;
|
let (len1, len2) = (polyline_len(&path1), polyline_len(&path2));
|
||||||
let l1 = old.length_cells * (path1.len().saturating_sub(1) as u32) / total_segs;
|
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;
|
let l2 = old.length_cells - l1;
|
||||||
|
|
||||||
edges[ei] = norm_edge(RoadEdge {
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user