fix(simulation): type-aware attractor cap so RiverMouth isn't crowded out (#953)
Review (Hoshe) caught that the MAX_ATTRACTORS cap sorted by global strength,
and RiverMouth's normalized strength (accum/max_accum) is tiny — so on a
realistic body 108 river mouths produced 0 surviving RiverMouth attractors,
violating D-209 ('RiverMouth: always high-value') and starving #955 placement.
Replace the global-strength cap with group-by-type + round-robin so every
present type keeps representation (strongest-first within each type).
Deterministic. New test river_mouths_survive_cap locks it in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -424,16 +424,45 @@ pub fn extract_attractors(
|
||||
claim(&mut out, &mut claimed, r, c, AttractorType::PlainCenter, s);
|
||||
}
|
||||
|
||||
// Cap by keeping the strongest across ALL types (so a coast-heavy body
|
||||
// doesn't starve ValleyFloor/PassEntrance/etc.), then sort the survivors
|
||||
// deterministically by (attractor_type, row, col).
|
||||
// Cap to MAX_ATTRACTORS while preserving type diversity. D-209 calls
|
||||
// RiverMouth "always high-value", but its *normalized* strength
|
||||
// (accum / max_accum) is tiny for all but the largest river, so a pure
|
||||
// global-strength cap lets abundant ValleyFloor/CoastalAccess crowd every
|
||||
// RiverMouth out. Instead: group by type, sort each group strongest-first,
|
||||
// then round-robin across types so every present type keeps representation.
|
||||
// Deterministic (BTreeMap type order, integer strength key, fixed rotation).
|
||||
if out.len() > MAX_ATTRACTORS {
|
||||
out.sort_by(|a, b| {
|
||||
let sa = (a.strength * 1e6) as i64;
|
||||
let sb = (b.strength * 1e6) as i64;
|
||||
sb.cmp(&sa).then(a.row.cmp(&b.row)).then(a.col.cmp(&b.col))
|
||||
});
|
||||
out.truncate(MAX_ATTRACTORS);
|
||||
let mut by_type: std::collections::BTreeMap<u8, Vec<RawAttractor>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for a in out.drain(..) {
|
||||
by_type.entry(a.attractor_type as u8).or_default().push(a);
|
||||
}
|
||||
for group in by_type.values_mut() {
|
||||
group.sort_by(|a, b| {
|
||||
let sa = (a.strength * 1e6) as i64;
|
||||
let sb = (b.strength * 1e6) as i64;
|
||||
sb.cmp(&sa).then(a.row.cmp(&b.row)).then(a.col.cmp(&b.col))
|
||||
});
|
||||
}
|
||||
let mut kept: Vec<RawAttractor> = Vec::with_capacity(MAX_ATTRACTORS);
|
||||
let mut depth = 0usize;
|
||||
'fill: loop {
|
||||
let mut progressed = false;
|
||||
for group in by_type.values() {
|
||||
if let Some(a) = group.get(depth) {
|
||||
kept.push(*a);
|
||||
progressed = true;
|
||||
if kept.len() >= MAX_ATTRACTORS {
|
||||
break 'fill;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
out = kept;
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
(a.attractor_type as u8, a.row, a.col).cmp(&(b.attractor_type as u8, b.row, b.col))
|
||||
@@ -612,4 +641,40 @@ mod tests {
|
||||
assert!(ta.elev_pct.iter().all(|&p| (0.0..=1.0).contains(&p)));
|
||||
assert_eq!(ta.slope_deg.len(), 32 * 16);
|
||||
}
|
||||
|
||||
/// Multi-octave sine terrain (continents + many small coastal streams) —
|
||||
/// produces > MAX_ATTRACTORS candidates with plenty of river mouths.
|
||||
fn sine_grid(w: u32, h: u32) -> Vec<f32> {
|
||||
use std::f32::consts::{PI, TAU};
|
||||
(0..(w * h))
|
||||
.map(|i| {
|
||||
let r = (i / w) as f32;
|
||||
let c = (i % w) as f32;
|
||||
let x = c / w as f32 * TAU;
|
||||
let y = r / h as f32 * PI;
|
||||
(0.5 + 0.25 * (x * 3.0).sin() * (y * 2.0).sin()
|
||||
+ 0.15 * (x * 7.0).cos() * (y * 5.0).sin()
|
||||
+ 0.08 * (x * 13.0).sin() * (y * 11.0).cos()
|
||||
+ 0.05 * (x * 23.0).cos() * (y * 19.0).sin())
|
||||
.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_mouths_survive_cap() {
|
||||
// D-209 + the type-aware cap: even though RiverMouth normalized strength
|
||||
// is tiny, a body full of mouths must still keep RiverMouth attractors
|
||||
// (a global-strength cap would drop all of them — the bug Hoshe caught).
|
||||
let h = hm(sine_grid(512, 256), 512, 256, 0.40);
|
||||
let dr = drainage::analyze(&h.data, 512, 256, 0.40);
|
||||
assert!(!dr.river_network.mouths.is_empty(), "fixture must have mouths");
|
||||
let ta = TerrainAnalysis::analyze(&h, &dr);
|
||||
let a = extract_attractors(&h, &dr, &ta);
|
||||
assert!(a.len() <= MAX_ATTRACTORS);
|
||||
assert!(
|
||||
a.iter().any(|x| x.attractor_type == AttractorType::RiverMouth),
|
||||
"RiverMouth attractors must survive the cap when mouths exist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user