diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 551fb1a94..d7bd87b00 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -1921,3 +1921,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang Q-110 resolved (2026-06-14) -> D-243. Scope item (1) ChunkPos->RegionPos is now defined: the D-243 absolute ladder is voxel(1m)->chunk(64m)->block(128m)->quarter(512m)->district(2km)->region(~205km), with a single elastic seam region<->planet (round(2*pi*R/204.8km) regions per body from body_radius_km). ChunkPos->district->region is fixed integer math; only region->heightmap-sample is body-specific. Prerequisite: T-1077 re-scales the code to this ladder (the current ~1km RegionProfile moves onto the 2km district; the region becomes the new 205km top hard block) — T-1046 now blocked by T-1077. Climate/weather lockdown + edge fuzz on the region is T-1078. Once T-1077 lands, T-1046 is the production wiring (proxy layers + Atlas surfacing) over a ladder whose scale is finally coherent.', NULL, '2026-06-14 19:22:14', '2026-06-14 19:22:14', '2026-06-14 19:22:14', NULL, '386fdc3725a511127d2254a36dd90c62', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCF56EAJ55PHQ977SV866CFM', 'status', 'backlog', 'in_progress', NULL, '2026-06-14 19:40:42', '2026-06-14 19:40:42', '2026-06-14 19:40:42', NULL, 'e13782322e02f56c9e7ddb3b28c76d3a', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCF56EAJ55PHQ977SV866CFM', 'status', 'in_progress', 'in_progress', NULL, '2026-06-14 19:51:10', '2026-06-14 19:51:10', '2026-06-14 19:51:10', NULL, '8e625a7b0faf0d348bc718e0f8b7d7f3', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 522fd817f..1f07f02ef 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2241,3 +2241,19 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Q-110 resolved (2026-06-14) -> D-243. Scope item (1) ChunkPos->RegionPos is now defined: the D-243 absolute ladder is voxel(1m)->chunk(64m)->block(128m)->quarter(512m)->district(2km)->region(~205km), with a single elastic seam region<->planet (round(2*pi*R/204.8km) regions per body from body_radius_km). ChunkPos->district->region is fixed integer math; only region->heightmap-sample is body-specific. Prerequisite: T-1077 re-scales the code to this ladder (the current ~1km RegionProfile moves onto the 2km district; the region becomes the new 205km top hard block) — T-1046 now blocked by T-1077. Climate/weather lockdown + edge fuzz on the region is T-1078. Once T-1077 lands, T-1046 is the production wiring (proxy layers + Atlas surfacing) over a ladder whose scale is finally coherent.', 'backlog', 'high', NULL, 'server', 'D-239', '2026-06-12 10:40:58', '2026-06-14 19:22:14', NULL, 'c3379b82cb706bdf11174688e5d203e6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCF56EAJ55PHQ977SV866CFM', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Re-scale the cascade to the D-243 ladder + elastic-seam resampling (region 205km / district 2km)', 'Per D-243 (resolves Q-110). Make the code match the canonical ladder. + +1. Re-scale the terrain/climate carrier: the current ~1km RegionProfile (region_profile.rs, derive_all_regions 8-cells/region) moves to the DISTRICT (2km), aligning with D-239 §2''s climate district. Rename RegionProfile -> DistrictProfile (or equivalent); fix the contradictory scale assumptions Q-110 flagged (region_profile.rs:3-4 ~1km doc, cascade.rs:235-237 ''8 cells/region on 128x64'', chunk_context.rs:137-141 ''region=16 chunks''). +2. Add the REGION tier = 100x100 districts = 204.8km (~205km) = the top hard block. +3. Elastic seam region<->planet: round(2*pi*R/204.8km) regions per body from body_radius_km (D-204). ChunkPos -> district -> region is fixed integer math; region->heightmap-sample is the only body-specific function. +4. Detail-scatter synthesis (D-243 §2): interpolate the coarse heightmap (~40-78km/px) + compose a simple multi-octave seed+position-keyed detail field (shaped by morphology/slope, continuous) atop the D-239 §4 domain warp — invented, not stored (D-227), plausible-at-a-glance. + +Blocks T-1046 (the tier can''t wire to production until its scale matches the ladder).', 'in_progress', 'high', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-14 19:40:42', NULL, '752fdfed97c7f7fe81747a8504dee793', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCF56EAJ55PHQ977SV866CFM', 'task', '06FB0TNSRZXCHGS16BFHSSGSV4', 'Re-scale the cascade to the D-243 ladder + elastic-seam resampling (region 205km / district 2km)', 'Per D-243 (resolves Q-110). Make the code match the canonical ladder. + +1. Re-scale the terrain/climate carrier: the current ~1km RegionProfile (region_profile.rs, derive_all_regions 8-cells/region) moves to the DISTRICT (2km), aligning with D-239 §2''s climate district. Rename RegionProfile -> DistrictProfile (or equivalent); fix the contradictory scale assumptions Q-110 flagged (region_profile.rs:3-4 ~1km doc, cascade.rs:235-237 ''8 cells/region on 128x64'', chunk_context.rs:137-141 ''region=16 chunks''). +2. Add the REGION tier = 100x100 districts = 204.8km (~205km) = the top hard block. +3. Elastic seam region<->planet: round(2*pi*R/204.8km) regions per body from body_radius_km (D-204). ChunkPos -> district -> region is fixed integer math; region->heightmap-sample is the only body-specific function. +4. Detail-scatter synthesis (D-243 §2): interpolate the coarse heightmap (~40-78km/px) + compose a simple multi-octave seed+position-keyed detail field (shaped by morphology/slope, continuous) atop the D-239 §4 domain warp — invented, not stored (D-227), plausible-at-a-glance. + +Blocks T-1046 (the tier can''t wire to production until its scale matches the ladder).', 'in_progress', 'high', NULL, 'server', 'D-243', '2026-06-14 19:21:18', '2026-06-14 19:51:10', NULL, 'ecae1405c540fb403d3f1126ce5b4082', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/server/src/atlas/body_params_reader.rs b/server/src/atlas/body_params_reader.rs index 99bcd59da..828dd818b 100644 --- a/server/src/atlas/body_params_reader.rs +++ b/server/src/atlas/body_params_reader.rs @@ -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, // b.hydrosphere Option, // b.atmosphere Option, // b.planet_class + Option, // 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, ) -> 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); diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 45b3321eb..7fc16f8c4 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -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; diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 5aee7de33..388442490 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -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, } // --------------------------------------------------------------------------- diff --git a/server/src/atlas/scale.rs b/server/src/atlas/scale.rs new file mode 100644 index 000000000..0b50888c0 --- /dev/null +++ b/server/src/atlas/scale.rs @@ -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); + } +}