feat(simulation): canonical spatial scale ladder + body_radius_km (T-1077)

First phase of the D-243 re-scale. Adds server/src/atlas/scale.rs as the
single source of truth for the metric containment ladder (voxel 1m -> chunk
64m -> block 128m -> quarter 512m -> district 2048m -> region 204.8km), the
fixed integer addressing (ChunkPos -> DistrictPos -> RegionPos), and the
elastic seam regions_per_equator(R). Compile-time const asserts enforce the
nesting so the rungs cannot silently drift again — the structural fix for the
Q-110 failure mode (three files, three different region sizes).

Wires body_radius_km (D-204) through BodyParams + the reader: the single
body-specific input to the elastic seam.

No behavior change — foundation only. The rename/re-scale of the carrier
(RegionProfile -> DistrictProfile) and on-demand district derivation follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 22:04:50 +02:00
co-authored by Claude Opus 4.8
parent e39ba89064
commit 38054d4b12
6 changed files with 204 additions and 17 deletions
+34 -17
View File
@@ -16,9 +16,10 @@
//!
//! | Field | Source |
//! |-------|--------|
//! | `hydrosphere` | `bodies.hydrosphere` |
//! | `atmosphere` | `bodies.atmosphere` |
//! | `planet_class` | `bodies.planet_class` |
//! | `hydrosphere` | `bodies.hydrosphere` |
//! | `atmosphere` | `bodies.atmosphere` |
//! | `planet_class` | `bodies.planet_class` |
//! | `body_radius_km` | `bodies.body_radius_km` (D-204 — the D-243 elastic-seam input) |
//!
//! `tectonic_activity` is **not** in the current schema; `BodyParams.tectonic_activity`
//! is left `None` so the derivation falls back to `planet_class` as documented
@@ -91,26 +92,28 @@ impl BodyParamsReader {
.lock()
.map_err(|e| BodyParamsReadError::Db(format!("mutex poisoned: {e}")))?;
// All three columns are nullable; row absence is what signals UnknownBody.
// All columns are nullable; row absence is what signals UnknownBody.
// `tectonic_activity` is absent from the current schema — leave that
// BodyParams field None (derives from planet_class at derivation time).
let result: rusqlite::Result<(
Option<String>, // b.hydrosphere
Option<String>, // b.atmosphere
Option<String>, // b.planet_class
Option<f64>, // b.body_radius_km (D-204 — the D-243 elastic seam input)
)> = conn.query_row(
"SELECT
b.hydrosphere,
b.atmosphere,
b.planet_class
b.planet_class,
b.body_radius_km
FROM bodies AS b
WHERE b.body_id = ?1",
[body_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
);
match result {
Ok((hydrosphere, atmosphere, planet_class)) => Ok(BodyParams {
Ok((hydrosphere, atmosphere, planet_class, body_radius_km)) => Ok(BodyParams {
hydrosphere,
atmosphere,
planet_class,
@@ -120,6 +123,7 @@ impl BodyParamsReader {
// not at the body level. Leave at struct defaults (0.0).
region_latitude_deg: 0.0,
elevation_km: 0.0,
body_radius_km,
}),
Err(rusqlite::Error::QueryReturnedNoRows) => {
Err(BodyParamsReadError::UnknownBody(body_id.to_string()))
@@ -161,6 +165,7 @@ mod tests {
hydrosphere: Option<&str>,
atmosphere: Option<&str>,
planet_class: Option<&str>,
body_radius_km: Option<f64>,
) -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_bpr_{}_{n}.db", std::process::id()));
@@ -171,15 +176,16 @@ mod tests {
body_id TEXT PRIMARY KEY,
hydrosphere TEXT,
atmosphere TEXT,
planet_class TEXT
planet_class TEXT,
body_radius_km REAL
);",
)
.expect("create tables");
conn.execute(
"INSERT INTO bodies (body_id, hydrosphere, atmosphere, planet_class)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![body_id, hydrosphere, atmosphere, planet_class],
"INSERT INTO bodies (body_id, hydrosphere, atmosphere, planet_class, body_radius_km)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![body_id, hydrosphere, atmosphere, planet_class, body_radius_km],
)
.expect("insert body");
@@ -189,13 +195,20 @@ mod tests {
#[test]
fn reads_all_columns_present() {
let db = make_test_db("GJ1c", Some("ocean"), Some("breathable"), Some("temperate"));
let db = make_test_db(
"GJ1c",
Some("ocean"),
Some("breathable"),
Some("temperate"),
Some(6371.0),
);
let reader = BodyParamsReader::open(&db).expect("open");
let params = reader.read_body_params("GJ1c").expect("read");
assert_eq!(params.hydrosphere.as_deref(), Some("ocean"));
assert_eq!(params.atmosphere.as_deref(), Some("breathable"));
assert_eq!(params.planet_class.as_deref(), Some("temperate"));
assert_eq!(params.body_radius_km, Some(6371.0));
// Per-region fields always start at 0.0 from the reader.
assert_eq!(params.region_latitude_deg, 0.0);
assert_eq!(params.elevation_km, 0.0);
@@ -205,18 +218,19 @@ mod tests {
#[test]
fn handles_all_null_columns() {
let db = make_test_db("GJ2b", None, None, None);
let db = make_test_db("GJ2b", None, None, None, None);
let reader = BodyParamsReader::open(&db).expect("open");
let params = reader.read_body_params("GJ2b").expect("read");
assert!(params.hydrosphere.is_none());
assert!(params.atmosphere.is_none());
assert!(params.planet_class.is_none());
assert!(params.body_radius_km.is_none());
}
#[test]
fn unknown_body_returns_error() {
let db = make_test_db("GJ3c", None, None, None);
let db = make_test_db("GJ3c", None, None, None, None);
let reader = BodyParamsReader::open(&db).expect("open");
let err = reader.read_body_params("ghost").expect_err("should fail");
assert!(matches!(err, BodyParamsReadError::UnknownBody(_)));
@@ -224,7 +238,7 @@ mod tests {
#[test]
fn read_is_deterministic() {
let db = make_test_db("GJ4d", Some("ice"), Some("thin"), Some("frozen"));
let db = make_test_db("GJ4d", Some("ice"), Some("thin"), Some("frozen"), Some(3389.5));
let reader = BodyParamsReader::open(&db).expect("open");
let p1 = reader.read_body_params("GJ4d").expect("first read");
let p2 = reader.read_body_params("GJ4d").expect("second read");
@@ -232,6 +246,8 @@ mod tests {
assert_eq!(p1.hydrosphere, p2.hydrosphere);
assert_eq!(p1.atmosphere, p2.atmosphere);
assert_eq!(p1.planet_class, p2.planet_class);
assert_eq!(p1.body_radius_km, p2.body_radius_km);
assert_eq!(p1.body_radius_km, Some(3389.5));
}
#[test]
@@ -250,11 +266,12 @@ mod tests {
body_id TEXT PRIMARY KEY,
hydrosphere TEXT,
atmosphere TEXT,
planet_class TEXT
planet_class TEXT,
body_radius_km REAL
-- orbital_period_days, axial_tilt_deg, spectral_class, star_type
-- deliberately absent to prove SELECT doesn't reference them
);
INSERT INTO bodies VALUES ('X', 'ocean', 'breathable', 'temperate');",
INSERT INTO bodies VALUES ('X', 'ocean', 'breathable', 'temperate', 6371.0);",
)
.expect("setup");
drop(conn);
+1
View File
@@ -21,6 +21,7 @@ pub mod layer_proxy;
pub mod plugin;
pub mod region_profile;
pub mod road_graph;
pub mod scale;
pub mod skeleton_gen;
pub mod source_resolver;
pub mod subbiome;
+4
View File
@@ -152,6 +152,10 @@ pub struct BodyParams {
pub region_latitude_deg: f64,
/// Mean elevation of this region relative to sea level, in km. Used for lapse rate.
pub elevation_km: f64,
/// `bodies.body_radius_km` (D-204) — the body's radius in km. The single
/// body-specific input to the D-243 elastic seam (`scale::regions_per_equator`).
/// `None` if the body has no recorded radius.
pub body_radius_km: Option<f64>,
}
// ---------------------------------------------------------------------------
+147
View File
@@ -0,0 +1,147 @@
//! Canonical spatial scale ladder (D-243) — **the single source of truth** for
//! the metric containment hierarchy. Every cascade consumer references these
//! constants and conversions; no module re-derives its own scale.
//!
//! This is the *structural* fix for the Q-110 failure mode — three files had
//! three different "region" sizes (1 024 m, 624 km, "16 chunks") because the
//! metres-per-scale lived as scattered magic numbers. With one canonical source
//! a file physically cannot invent a scale that disagrees, and the `const`
//! asserts below fail the build if the rungs ever stop nesting cleanly.
//!
//! ```text
//! voxel 1 m → chunk 64 m → block 128 m → quarter 512 m → district 2 048 m
//! → region ~205 km → (elastic seam) → planet
//! ```
//!
//! Below the region everything is fixed integer math; only [`regions_per_equator`]
//! (the planetary count) varies per body. See [D-243] for the full model.
//!
//! [D-243]: ../../../governance/decisions/architecture.md
// ---------------------------------------------------------------------------
// Edge lengths in metres (absolute, fixed — D-243)
// ---------------------------------------------------------------------------
/// Voxel edge — the tile (D-228 / D-220).
pub const VOXEL_M: i32 = 1;
/// Chunk edge — the stream / derive unit (D-222, D-239).
pub const CHUNK_M: i32 = 64;
/// Block edge — generator planning unit (D-222).
pub const BLOCK_M: i32 = 128;
/// Quarter edge — settlement footprint cell (D-222).
pub const QUARTER_M: i32 = 512;
/// District edge — urban division **and** the local terrain/climate carrier cell
/// (D-222, D-239 §2, D-243). The fine carrier `ChunkContext`/voxels consume.
pub const DISTRICT_M: i32 = 2_048;
/// Region edge — the top hard block: planetary grid + climate/weather/season
/// lockdown (D-243). 100 districts on a side.
pub const REGION_M: i32 = 204_800;
// ---------------------------------------------------------------------------
// Linear nesting ratios (children per parent edge)
// ---------------------------------------------------------------------------
/// Voxels per chunk edge (64 m / 1 m). A chunk is 64×64 voxels.
pub const VOXELS_PER_CHUNK: i32 = CHUNK_M / VOXEL_M;
/// Chunks per district edge (2 048 m / 64 m = 32 = `1 << 5`).
pub const CHUNKS_PER_DISTRICT: i32 = DISTRICT_M / CHUNK_M;
/// `log2(CHUNKS_PER_DISTRICT)` — chunk→district is an arithmetic shift (floors
/// toward −∞ for negative coords, the correct tiling behaviour).
pub const CHUNK_DISTRICT_SHIFT: u32 = 5;
/// Districts per region edge (204 800 m / 2 048 m = 100). Not a power of two,
/// so region mapping uses division, not a shift.
pub const DISTRICTS_PER_REGION: i32 = REGION_M / DISTRICT_M;
// Compile-time ladder invariants — the build fails if a rung stops nesting.
const _: () = assert!(CHUNKS_PER_DISTRICT == 1 << CHUNK_DISTRICT_SHIFT);
const _: () = assert!(CHUNKS_PER_DISTRICT * CHUNK_M == DISTRICT_M);
const _: () = assert!(DISTRICTS_PER_REGION * DISTRICT_M == REGION_M);
const _: () = assert!(DISTRICTS_PER_REGION == 100);
// ---------------------------------------------------------------------------
// Grid positions + fixed integer addressing
// ---------------------------------------------------------------------------
/// Position on the 64 m chunk grid (chunk-units, not metres). `BTreeMap` key (D-010).
pub type ChunkPos = (i32, i32);
/// Position on the 2 km district grid (district-units) — the terrain/climate carrier cell.
pub type DistrictPos = (i32, i32);
/// Position on the ~205 km region grid (region-units) — the top hard block.
pub type RegionPos = (i32, i32);
/// The district covering a chunk. Arithmetic shift floors toward −∞ so the tiling
/// is consistent across the origin (a chunk at −1 belongs to district −1, not 0).
#[inline]
pub fn chunk_to_district(c: ChunkPos) -> DistrictPos {
(c.0 >> CHUNK_DISTRICT_SHIFT, c.1 >> CHUNK_DISTRICT_SHIFT)
}
/// The region covering a district. Euclidean floor division matches the shift's
/// flooring, so chunk→district→region tiling is seamless across the origin.
#[inline]
pub fn district_to_region(d: DistrictPos) -> RegionPos {
(
d.0.div_euclid(DISTRICTS_PER_REGION),
d.1.div_euclid(DISTRICTS_PER_REGION),
)
}
/// The region covering a chunk (chunk → district → region).
#[inline]
pub fn chunk_to_region(c: ChunkPos) -> RegionPos {
district_to_region(chunk_to_district(c))
}
// ---------------------------------------------------------------------------
// The elastic seam — region ↔ planet (the only per-body-floating quantity)
// ---------------------------------------------------------------------------
/// Number of ~205 km regions around the body's equator: `round(2πR / REGION_M)`,
/// minimum 1 (D-243). `R` is `bodies.body_radius_km` (D-204) — the single
/// body-specific input in the whole scale chain. Pole-to-pole is half this.
pub fn regions_per_equator(body_radius_km: f64) -> u32 {
let circumference_m = 2.0 * std::f64::consts::PI * body_radius_km * 1000.0;
((circumference_m / REGION_M as f64).round() as i64).max(1) as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ladder_nests_cleanly() {
assert_eq!(VOXELS_PER_CHUNK, 64);
assert_eq!(CHUNKS_PER_DISTRICT, 32);
assert_eq!(DISTRICTS_PER_REGION, 100);
assert_eq!(CHUNK_M * CHUNKS_PER_DISTRICT, DISTRICT_M);
assert_eq!(DISTRICT_M * DISTRICTS_PER_REGION, REGION_M);
}
#[test]
fn addressing_floors_across_origin() {
// chunk → district (32 chunks/district)
assert_eq!(chunk_to_district((0, 0)), (0, 0));
assert_eq!(chunk_to_district((31, 31)), (0, 0));
assert_eq!(chunk_to_district((32, 32)), (1, 1));
assert_eq!(chunk_to_district((-1, -1)), (-1, -1)); // floors, not truncates
assert_eq!(chunk_to_district((-32, -32)), (-1, -1));
assert_eq!(chunk_to_district((-33, -33)), (-2, -2));
// district → region (100 districts/region)
assert_eq!(district_to_region((99, 0)), (0, 0));
assert_eq!(district_to_region((100, 0)), (1, 0));
assert_eq!(district_to_region((-1, 0)), (-1, 0));
// chunk → region (3200 chunks/region)
assert_eq!(chunk_to_region((3199, 0)), (0, 0));
assert_eq!(chunk_to_region((3200, 0)), (1, 0));
}
#[test]
fn regions_per_equator_earth_and_moon() {
// Earth (R≈6371 km): 2πR ≈ 40 030 km / 204.8 km ≈ 195.46 → 195.
assert_eq!(regions_per_equator(6371.0), 195);
// A small moon (R≈200 km): ≈ 1257 km / 204.8 ≈ 6.1 → 6.
assert_eq!(regions_per_equator(200.0), 6);
// Degenerate radius still yields at least one region.
assert_eq!(regions_per_equator(0.0), 1);
}
}