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:
2026-05-23 08:08:57 +02:00
co-authored by Claude Opus 4.7
parent cabdd7c097
commit abcb41deaa
+74 -9
View File
@@ -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"
);
}
}