Files
settled-reach/server/src/atlas/chunk_context.rs
T

571 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ChunkContext — 64 m carrier of the D-239 three-tier refinement chain (T-1028).
//!
//! `ChunkContext` is the middle tier: coarser than a voxel (1 m), finer than a
//! region (~1 km). It is derived purely from the covering `RegionProfile`(s) and
//! the world seed — no stored state, no side effects.
//!
//! ## Scale contract (D-239 §10)
//!
//! A chunk is 64 m × 64 m. The D8 drainage direction grid is ~152 m/cell —
//! **coarser than a chunk** — so there is no per-tile `flow_direction[64×64]`
//! here. Instead the chunk carries:
//! - **`basin_direction`** — a cardinal basin-flow direction (N/E/S/W, 4-way)
//! derived from the dominant D8 thalweg in the covering region.
//! - **`meander_phase`** and **`meander_wavelength_m`** — global meander-curve
//! params for the MeanderReach and AlluvialPlain families.
//!
//! Features with wavelength > 64 m seed from **Region-or-higher** (the covering
//! `RegionProfile`), not from the chunk-local seed. This is enforced by structure:
//! the chunk seed is only used for sub-chunk (<64 m) scatter.
//!
//! The same rule places the feature axes themselves (T-1040/T-1041): the channel
//! centreline and landform axis (`channel_anchor_m`) and the coast-face line
//! (`coast_anchor_m`) are **world-metre coordinates derived once per region**
//! from the region-scale seed. Voxel generators measure distance to these
//! anchors in continuous world coordinates — never from the world origin and
//! never folded into the 64 m chunk frame.
//!
//! ## D-010 compliance
//!
//! All derivation is integer arithmetic. The only f64 in this module is
//! `meander_wavelength_m` (positional physics, D-239 §4). Structural decisions
//! consume it only via deterministic i32 truncation (the `has_active_channel`
//! band reach), mirroring the established voxel.rs truncate-before-decision
//! convention — only correctly-rounded f64 +,-,*,/ feed the truncation.
//!
//! ## SeedDomain extension
//!
//! Uses `SeedDomain::ChunkContext = 8` (appended, never renumber).
use serde::{Deserialize, Serialize};
use crate::atlas::region_profile::RegionProfile;
use crate::seed::{SeedChain, SeedDomain};
// ---------------------------------------------------------------------------
// Basin direction (cardinal, 4-way)
// ---------------------------------------------------------------------------
/// Cardinal basin-flow direction — the dominant D8 thalweg direction in the
/// covering region. Coarser than a chunk (D-239 §10); derived from region slope
/// and morphology, NOT from a per-tile D8 grid.
///
/// Integer-discriminant, append-only (D-010).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum BasinDirection {
/// Flow toward the north (decreasing y in grid coords).
#[default]
North = 0,
/// Flow toward the east (increasing x in grid coords).
East = 1,
/// Flow toward the south (increasing y in grid coords).
South = 2,
/// Flow toward the west (decreasing x in grid coords).
West = 3,
}
// ---------------------------------------------------------------------------
// ChunkPos — position on the 64 m chunk grid
// ---------------------------------------------------------------------------
/// Position of a chunk on the 64 m grid, in chunk-units (not metres).
///
/// A body at ~6 000 regions, each region ~1 km², gets ~1 000 × 1 000 chunks.
/// `BTreeMap` key — implements `Ord` for D-010 determinism.
pub type ChunkPos = (i32, i32);
/// Chunk edge length in metres.
pub const CHUNK_M: i32 = 64;
/// Region edge length in chunks (region ≈ 1 km = 16 chunks). Must match the
/// `>> REGION_CHUNKS_SHIFT` region-index mapping used for the region-scale seed.
const REGION_CHUNKS_SHIFT: u32 = 4;
/// Region edge length in metres (1 024 m).
const REGION_M: i32 = CHUNK_M << REGION_CHUNKS_SHIFT;
/// Margin keeping a region's feature anchor away from the region edge, so the
/// channel's full swept band (max meander amplitude wavelength/4 ≈ 162 m +
/// channel edge + levee band + warp bound ≈ 187 m) stays inside the region.
/// Cross-region feature continuity is the stage-2 Voronoi model (T-1040).
const ANCHOR_MARGIN_M: i32 = 192;
/// Seed-addressable anchor span within a region (REGION_M − 2 × margin).
const ANCHOR_SPAN_M: i32 = REGION_M - 2 * ANCHOR_MARGIN_M;
/// Maximum levee band width in metres (`voxel::in_levee_band`: 4 + 3 jitter).
const LEVEE_BAND_MAX_M: i32 = 7;
/// Domain-warp displacement bound in metres (mirrors `domain_warp::WARP_BOUND`,
/// D-239 §4 ±8 m). Integer here — used only to widen the channel gate band.
const WARP_BOUND_M: i32 = 8;
// ---------------------------------------------------------------------------
// ChunkContext
// ---------------------------------------------------------------------------
/// 64 m carrier derived from `RegionProfile`(s) — the second tier of D-239 §1.
///
/// Pure deterministic function of `(seed, body_id, region, chunk_pos)`.
/// Never stored; derived on demand and cached (D-227).
///
/// ## Fields
///
/// - `basin_direction` — dominant drainage direction (cardinal) for this chunk.
/// - `meander_phase` — integer phase offset (0–255) for the meander curve.
/// Used by MeanderReach and AlluvialPlain voxel generators to place the channel.
/// - `meander_wavelength_m` — meander wavelength in metres. Derived from
/// region-level morphology (slope, moisture), seeded at region scale (> 64 m).
/// f64 for positional physics (D-239 §4); structural decisions consume it
/// only via deterministic i32 truncation (the `has_active_channel` band).
/// - `has_active_channel` — whether a water channel is present in this chunk:
/// the region has water presence AND the channel's swept band around
/// `channel_anchor_m` crosses this chunk (T-1040).
/// - `channel_width_m` — channel width in metres (integer; D-010). 0 if no
/// active channel.
/// - `channel_anchor_m` / `coast_anchor_m` — region-anchored feature axes in
/// world metres (T-1040/T-1041, D-239 §10).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkContext {
/// Dominant basin-flow direction for this chunk's drainage catchment.
pub basin_direction: BasinDirection,
/// Integer phase offset for the meander curve (0–255).
/// Derived at region scale (wavelength > 64 m), NOT from the chunk seed.
pub meander_phase: u8,
/// Meander wavelength in metres. Positional physics value (f64 — D-239 §4).
/// Derived at region scale; typically 80–500 m for AlluvialPlain.
pub meander_wavelength_m: f64,
/// Whether this chunk contains an active water channel.
/// True when the region has water presence (`ocean_fraction_q` ≥ 10) AND
/// the channel's swept band around `channel_anchor_m` crosses this chunk's
/// cross-axis range (T-1040 — channels exist where the region says, not
/// region-wide and not only at the world origin).
pub has_active_channel: bool,
/// Active channel width in metres (integer; D-010). 0 if no active channel.
/// Derived from region morphology and slope.
pub channel_width_m: i32,
/// Cross-axis world-metre coordinate of the region's feature centreline:
/// channel/meander axis, fjord trough, gorge floor, braid-fan axis.
/// Derived once per region from the region-scale seed (T-1040/T-1041,
/// D-239 §10) — constant across all chunks of a region, so the feature is
/// continuous across chunk boundaries. Cross axis = x for N/S basins,
/// y for E/W basins.
pub channel_anchor_m: i32,
/// Along-axis (basin-axis) world-metre coordinate of the CliffCoast face
/// line. Region-scale (T-1041): one continuous coast per region, not a
/// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins.
pub coast_anchor_m: i32,
}
// ---------------------------------------------------------------------------
// Derivation
// ---------------------------------------------------------------------------
/// Derive a `ChunkContext` for the chunk at `chunk_pos` on the 64 m grid.
///
/// Pure function of `(seed, body_id, region, chunk_pos)`. Takes the covering
/// region's `RegionProfile`; in the future a blend of adjacent profiles will
/// handle cross-region chunk seams, but for the walking skeleton one profile
/// is sufficient.
///
/// ## Seed usage
///
/// The **meander phase** and **basin direction** are seeded at region scale so
/// features with wavelength > 64 m are consistent across chunk boundaries.
/// The chunk-local seed (keyed on `chunk_pos`) is reserved for sub-chunk scatter
/// in the voxel pass — not consumed here.
pub fn derive_chunk_context(
world_seed: u64,
body_id: &str,
region: &RegionProfile,
chunk_pos: ChunkPos,
) -> ChunkContext {
// Region-scale seed — features with wavelength > 64 m derive from here.
// Keyed on the chunk position mapped to region-scale units (>> 4 gives
// the ~1 km region index if a region is ~16 chunks wide).
let region_scale_id = pos_to_id((chunk_pos.0 >> 4, chunk_pos.1 >> 4));
let region_seed =
SeedChain::for_body(world_seed, body_id).derive(SeedDomain::ChunkContext, region_scale_id);
// Basin direction — derived from region slope_q (which encodes the
// dominant terrain gradient). We use the region's `elev_q` gradient
// direction as a proxy for the D8 thalweg direction.
// All integer arithmetic (D-010).
let basin_direction = derive_basin_direction(region, region_seed.seed());
// Meander phase — region-scale integer offset so the channel is consistent
// across all chunks in the same region. 0–255.
let meander_phase = (region_seed.seed() >> 8) as u8;
// Meander wavelength — derived from slope and morphology, region-scale.
// Lower slope → longer wavelength (wider meanders); integer inputs, f64 result
// is positional physics (D-239 §4, not a gate comparison).
let meander_wavelength_m = derive_meander_wavelength(region);
// Region-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
// landform centrelines have wavelength > 64 m, so their position derives
// from the region-scale seed — never the chunk frame or the world origin.
// Cross axis ⊥ basin_direction (x for N/S, y for E/W); along axis ∥ basin.
let (cross_chunk, along_chunk) = match basin_direction {
BasinDirection::North | BasinDirection::South => (chunk_pos.0, chunk_pos.1),
BasinDirection::East | BasinDirection::West => (chunk_pos.1, chunk_pos.0),
};
let channel_anchor_m = derive_region_anchor(cross_chunk, (region_seed.seed() >> 16) & 0xFFFF);
let coast_anchor_m = derive_region_anchor(along_chunk, (region_seed.seed() >> 32) & 0xFFFF);
// Active channel — water presence (ocean_fraction_q >= 10 indicates a
// perennial waterway or water body covers at least 10% of the region) AND
// the channel's swept band around the region anchor crosses this chunk
// (T-1040). The river_threshold governs D8 drainage accumulation at a finer
// scale; at the chunk level ocean_fraction_q is the direct proxy for water
// presence (D-239 §10: one basin-direction, not a per-tile flow grid).
//
// The band is generous (it must cover every chunk that can contain channel,
// levee, or warped-channel voxels — a gate-off chunk renders dry), using
// the larger MeanderReach amplitude (wavelength/4) for both channel families.
let channel_width = derive_channel_width(region);
let has_active_channel = region.ocean_fraction_q >= 10 && {
let wavelength_i = (meander_wavelength_m as i32).max(10);
let amplitude_max = (wavelength_i / 4).max(3);
let edge_max = (channel_width / 2).max(2) + 3; // half-width + max edge jitter
// Floor: the BraidedDelta belt reaches anchor ±(32 thread-centre + 4
// thread-half) before warp regardless of wavelength — the band must
// cover it even at the short-wavelength extreme (costs ≤4 m of extra
// gate generosity for the other families).
let reach = (amplitude_max + edge_max + LEVEE_BAND_MAX_M).max(32 + 4) + WARP_BOUND_M;
let cross_lo = cross_chunk * CHUNK_M;
let cross_hi = cross_lo + CHUNK_M - 1;
cross_lo <= channel_anchor_m + reach && cross_hi >= channel_anchor_m - reach
};
// Channel width — integer metres; 0 when no active channel in this chunk.
let channel_width_m = if has_active_channel { channel_width } else { 0 };
ChunkContext {
basin_direction,
meander_phase,
meander_wavelength_m,
has_active_channel,
channel_width_m,
channel_anchor_m,
coast_anchor_m,
}
}
/// World-metre anchor coordinate for a region-scale feature axis on one axis.
///
/// The anchor sits in `[region_origin + ANCHOR_MARGIN_M, region_origin +
/// REGION_M − ANCHOR_MARGIN_M)` so the feature's full swept band stays inside
/// its region (no cross-region band spill; region-seam continuity is the
/// stage-2 Voronoi model, T-1040). Same value for every chunk of the region:
/// the region index is `axis_chunk >> REGION_CHUNKS_SHIFT` (arithmetic shift =
/// floor division, correct for negative chunks) and `seed_bits` comes from the
/// shared region-scale seed. Integer arithmetic (D-010).
fn derive_region_anchor(axis_chunk: i32, seed_bits: u64) -> i32 {
let region_idx = axis_chunk >> REGION_CHUNKS_SHIFT;
let origin_m = region_idx * REGION_M;
origin_m + ANCHOR_MARGIN_M + (seed_bits % ANCHOR_SPAN_M as u64) as i32
}
/// Fold `(x, y)` chunk coordinates into a single u64 id for seed derivation.
///
/// Mirrors `domain_warp::pos_to_id` — zigzag-encode + Cantor pairing.
/// Integer-only (D-010).
#[inline]
pub(crate) fn pos_to_id(pos: (i32, i32)) -> u64 {
let zz = |v: i32| -> u64 {
let v = v as i64;
((v << 1) ^ (v >> 63)) as u64
};
let x = zz(pos.0);
let y = zz(pos.1);
let s = x.wrapping_add(y);
s.wrapping_mul(s.wrapping_add(1))
.wrapping_div(2)
.wrapping_add(y)
}
/// Derive basin direction from the region profile and a region-scale seed.
///
/// Uses `elev_q` and `slope_q` as a proxy for the dominant D8 gradient
/// direction. In lieu of a full D8 computation at this scale, the basin
/// direction is derived from the region's terrain characteristics:
/// - Coastal regions (high `ocean_fraction_q`) flow toward ocean (West fallback)
/// - High-elevation regions flow away from ridges (seed-derived direction)
/// - Low-slope regions use the seed for unbiased direction
///
/// All integer arithmetic (D-010).
fn derive_basin_direction(region: &RegionProfile, region_seed: u64) -> BasinDirection {
// Coastal: flow toward the ocean (use seed to pick E/W/N/S with coastal bias).
if region.ocean_fraction_q >= 15 {
// The low 2 bits of seed give 4 directions; bias toward the most common
// coastal configurations (N or S for equatorial coasts, E/W for shelf).
return match (region_seed >> 2) & 0x3 {
0 => BasinDirection::South,
1 => BasinDirection::East,
2 => BasinDirection::North,
_ => BasinDirection::West,
};
}
// Interior: pure seed-derived direction (unbiased).
match region_seed & 0x3 {
0 => BasinDirection::North,
1 => BasinDirection::East,
2 => BasinDirection::South,
_ => BasinDirection::West,
}
}
/// Derive meander wavelength in metres from region morphology.
///
/// Low slope + high moisture → longer wavelength (wide meanders).
/// High slope → short wavelength (confined/straight channels).
/// Result is f64 positional physics (D-239 §4), not used in gate comparisons.
fn derive_meander_wavelength(region: &RegionProfile) -> f64 {
// Base wavelength range: 80–500 m for AlluvialPlain.
// slope_q 0 → 500 m; slope_q 100 → 80 m. Linear interpolation.
let slope_clamped = region.slope_q.clamp(0, 100) as f64;
let base = 500.0 - (slope_clamped / 100.0) * 420.0;
// Moisture boost: high moisture → slightly longer wavelength (more sinuous).
let moisture_factor = 1.0 + (region.moisture_q.clamp(0, 100) as f64 / 100.0) * 0.3;
base * moisture_factor
}
/// Derive active channel width in metres from region morphology.
///
/// Returns an integer metre value (D-010).
fn derive_channel_width(region: &RegionProfile) -> i32 {
// Water presence drives width; ocean_fraction_q is our proxy.
// Meander channels: 3–15 m (D-239 §9 game-feel constraint).
// We derive in that range from ocean_fraction_q.
let base = match region.ocean_fraction_q {
0..=9 => 3,
10..=19 => 5,
20..=34 => 8,
35..=49 => 10,
_ => 15,
};
// Slope modifier: high slope → narrower (gorge-like); low slope → wider.
let slope_penalty = (region.slope_q / 20).min(3);
(base - slope_penalty).max(3)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::region_profile::{
GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
};
use crate::simulation::generator::MorphologyZone;
fn alluvial_region() -> RegionProfile {
RegionProfile {
morphology_zone: MorphologyZone::AlluvialPlain,
tectonic_class: TectonicClass::Stable,
glaciation_grade: GlaciationGrade::None,
precipitation_class: PrecipitationClass::Temperate,
slope_q: 5,
elev_q: 20,
ocean_fraction_q: 15,
river_threshold: 200,
temperature_c: Some(18.0),
moisture_q: 55,
vegetation_class: VegetationClass::Forest,
}
}
/// The chunk of region (0, 0) whose cross-range contains the region's
/// channel anchor — guaranteed inside the T-1040 channel band.
fn anchor_chunk_pos(world_seed: u64, body_id: &str, region: &RegionProfile) -> ChunkPos {
let probe = derive_chunk_context(world_seed, body_id, region, (0, 0));
let idx = probe.channel_anchor_m.div_euclid(CHUNK_M);
match probe.basin_direction {
BasinDirection::North | BasinDirection::South => (idx, 0),
BasinDirection::East | BasinDirection::West => (0, idx),
}
}
#[test]
fn derive_chunk_context_is_deterministic() {
let region = alluvial_region();
let a = derive_chunk_context(42, "GJ1c", &region, (10, 20));
let b = derive_chunk_context(42, "GJ1c", &region, (10, 20));
assert_eq!(a.basin_direction, b.basin_direction);
assert_eq!(a.meander_phase, b.meander_phase);
assert_eq!(a.meander_wavelength_m, b.meander_wavelength_m);
assert_eq!(a.has_active_channel, b.has_active_channel);
assert_eq!(a.channel_width_m, b.channel_width_m);
}
#[test]
fn different_positions_yield_different_phases() {
let region = alluvial_region();
let a = derive_chunk_context(42, "GJ1c", &region, (0, 0));
let b = derive_chunk_context(42, "GJ1c", &region, (200, 100));
// Different region-scale ids → different phases (high probability).
assert_ne!(
a.meander_phase, b.meander_phase,
"chunks far apart should differ in meander phase"
);
}
#[test]
fn alluvial_region_has_active_channel() {
let region = alluvial_region();
// T-1040: the channel is region-anchored — the chunk under the anchor
// must claim it (ocean_fraction_q=15 → water present).
let pos = anchor_chunk_pos(42, "GJ1c", &region);
let ctx = derive_chunk_context(42, "GJ1c", &region, pos);
assert!(
ctx.has_active_channel,
"anchor-covering chunk of a watered region must have active channel"
);
}
#[test]
fn chunk_outside_channel_band_has_no_active_channel() {
// T-1040: channels are region-anchored, not region-wide. A chunk whose
// cross-range lies outside the channel's swept band (max reach < 192 m
// = 3 chunks) must not claim a channel — pre-fix every chunk of a
// watered region did, while voxels rendered dry floodplain.
let region = alluvial_region();
let (anchor_pos, probe) = {
let pos = anchor_chunk_pos(42, "GJ1c", &region);
(pos, derive_chunk_context(42, "GJ1c", &region, pos))
};
// 8 cross-chunks away (512 m) is past any band reach but still inside
// region (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
let anchor_idx = anchor_pos.0.max(anchor_pos.1);
let far_idx = if anchor_idx < 8 {
anchor_idx + 8
} else {
anchor_idx - 8
};
let far_pos = match probe.basin_direction {
BasinDirection::North | BasinDirection::South => (far_idx, 0),
BasinDirection::East | BasinDirection::West => (0, far_idx),
};
let far_ctx = derive_chunk_context(42, "GJ1c", &region, far_pos);
assert!(
!far_ctx.has_active_channel,
"chunk {far_pos:?} outside the channel band must not claim a channel"
);
assert_eq!(
far_ctx.channel_width_m, 0,
"no active channel → channel_width_m must be 0"
);
// Region-scale params stay constant across the region's chunks.
assert_eq!(far_ctx.channel_anchor_m, probe.channel_anchor_m);
assert_eq!(far_ctx.meander_phase, probe.meander_phase);
}
#[test]
fn anchors_constant_within_region_and_inside_it() {
// T-1040/T-1041: feature anchors are a region property — identical for
// every chunk of the region, and positioned inside the region's extent.
let region = alluvial_region();
let base = derive_chunk_context(42, "GJ1c", &region, (0, 0));
for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] {
let ctx = derive_chunk_context(42, "GJ1c", &region, pos);
assert_eq!(
ctx.channel_anchor_m, base.channel_anchor_m,
"channel anchor must be region-constant (chunk {pos:?})"
);
assert_eq!(
ctx.coast_anchor_m, base.coast_anchor_m,
"coast anchor must be region-constant (chunk {pos:?})"
);
}
// Region (0, 0) spans [0, 1024) m on both axes.
assert!((0..1024).contains(&base.channel_anchor_m));
assert!((0..1024).contains(&base.coast_anchor_m));
// A different region derives its anchors inside its own extent.
let far = derive_chunk_context(42, "GJ1c", &region, (1000, -750));
assert!((62 * 1024..63 * 1024).contains(&far.channel_anchor_m) || (-47 * 1024..-46 * 1024).contains(&far.channel_anchor_m),
"far region anchor {} must lie inside its region extent (cross axis depends on basin direction)",
far.channel_anchor_m);
}
#[test]
fn dry_region_has_no_active_channel() {
let region = RegionProfile {
morphology_zone: MorphologyZone::AlluvialPlain,
tectonic_class: TectonicClass::Stable,
glaciation_grade: GlaciationGrade::None,
precipitation_class: PrecipitationClass::Arid,
slope_q: 3,
elev_q: 10,
ocean_fraction_q: 0, // no water at all
river_threshold: 300,
temperature_c: Some(35.0),
moisture_q: 5,
vegetation_class: VegetationClass::Barren,
};
let ctx = derive_chunk_context(42, "dry_body", &region, (5, 5));
assert!(
!ctx.has_active_channel,
"arid region with ocean_fraction_q=0 must not have active channel"
);
}
#[test]
fn channel_width_in_game_feel_range() {
// D-239 §9: river crossings 3–15 m. Measured on a chunk that carries
// the channel (T-1040 gating zeroes the width elsewhere).
let region = alluvial_region();
let pos = anchor_chunk_pos(42, "GJ1c", &region);
let ctx = derive_chunk_context(42, "GJ1c", &region, pos);
assert!(
(3..=15).contains(&ctx.channel_width_m),
"channel_width_m {} out of game-feel range [3, 15]",
ctx.channel_width_m
);
}
#[test]
fn meander_wavelength_within_physics_range() {
let region = alluvial_region();
let ctx = derive_chunk_context(42, "GJ1c", &region, (5, 5));
// AlluvialPlain flat (slope_q=5): should be near max wavelength.
assert!(
ctx.meander_wavelength_m > 400.0 && ctx.meander_wavelength_m < 700.0,
"meander wavelength {} out of expected range for flat alluvial",
ctx.meander_wavelength_m
);
}
#[test]
fn basin_direction_discriminants_pinned() {
// Append-only invariant (D-010).
assert_eq!(BasinDirection::North as u8, 0);
assert_eq!(BasinDirection::East as u8, 1);
assert_eq!(BasinDirection::South as u8, 2);
assert_eq!(BasinDirection::West as u8, 3);
}
#[test]
fn pos_to_id_is_injective_for_small_coords() {
// Spot-check that nearby positions produce distinct ids.
let ids: Vec<u64> = [(0, 0), (1, 0), (0, 1), (1, 1), (-1, 0), (0, -1), (-1, -1)]
.iter()
.map(|&p| pos_to_id(p))
.collect();
let unique: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len(), "pos_to_id must be injective");
}
}