diff --git a/.claude/skills/pr-push/SKILL.md b/.claude/skills/pr-push/SKILL.md index 2d037daef..662a423ef 100644 --- a/.claude/skills/pr-push/SKILL.md +++ b/.claude/skills/pr-push/SKILL.md @@ -231,6 +231,7 @@ git diff --name-only origin/main...HEAD -- \ tooling/planet-gen/generate_atlas.py \ tooling/planet-gen/gemma_naming.py \ tooling/planet-gen/naming_core.py \ + tooling/planet-gen/import_city_names.py \ tooling/planet-gen/import_heightmaps.py \ tooling/planet-gen/import_province_boundaries.py \ server/src/bin/generate_brands/main.rs \ diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index 326ed81e0..3ae46d2a8 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -475,15 +475,16 @@ CREATE TABLE IF NOT EXISTS atlas_body_heightmaps ( -- City name reservations — replaces authored city positions in markers.json (D-207, #902) -- Position is generated by the city placement algorithm; name is authored or LLM-generated. CREATE TABLE IF NOT EXISTS atlas_city_names ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, - name TEXT NOT NULL, - kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city' - economic_role TEXT NOT NULL, - population INTEGER NOT NULL, - corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable - reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city' + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + settlement_class TEXT, -- D-196 SettlementClass variant; NULL until placement + corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable + reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use + updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Geographic feature name reservations — rivers, mountains, passes (D-207 adjacent, #903) @@ -506,12 +507,25 @@ CREATE TABLE IF NOT EXISTS atlas_province_boundaries ( PRIMARY KEY (body_id, basin_id) ); +-- City positions — attractor-matched placement output (D-211, #34) +-- Written at build time by the attractor-matching pipeline. Each row maps one +-- atlas_city_names entry to its terrain position and the attractor that placed it. +CREATE TABLE IF NOT EXISTS atlas_city_positions ( + city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + row INTEGER NOT NULL, -- pixel row in heightmap grid [0, GRID_H) + col INTEGER NOT NULL, -- pixel col in heightmap grid [0, GRID_W) + attractor_type TEXT NOT NULL, -- AttractorType variant name + score REAL NOT NULL -- match quality [0.0, 1.0] +); + CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind); CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id); CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id); -- END ATLAS INDEX (D-191 §8, #832) -- Indexes diff --git a/server/data/systems.db b/server/data/systems.db index 1afeddfec..81428206e 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/atlas/district_mix.rs b/server/src/atlas/district_mix.rs index 28b42fc00..7577d82bc 100644 --- a/server/src/atlas/district_mix.rs +++ b/server/src/atlas/district_mix.rs @@ -12,6 +12,7 @@ //! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the //! district count computation. Seed-driven noise uses seeded RNG. +use crate::atlas::rng::AtlasRng; use crate::simulation::generator::{DistrictType, PoliticalArchetype}; // --------------------------------------------------------------------------- @@ -196,7 +197,7 @@ pub fn compute_district_mix( // We avoid f32 by using integer weighted random selection. let weight_sum: u32 = weights.iter().sum(); let mut counts: [u32; 9] = [0; 9]; - let mut lcg = LcgRng::new(seed); + let mut lcg = AtlasRng::new(seed); for _ in 0..total_districts { let mut pick = lcg.next_u32() % weight_sum; @@ -236,35 +237,6 @@ pub fn compute_district_mix( DistrictMix { districts, total } } -// --------------------------------------------------------------------------- -// Minimal seeded LCG (no f32, D-010 compliant) -// --------------------------------------------------------------------------- - -struct LcgRng { - state: u64, -} - -impl LcgRng { - fn new(seed: u64) -> Self { - Self { - state: seed.wrapping_add(1), - } - } - - fn next_u64(&mut self) -> u64 { - // LCG parameters from Knuth - self.state = self - .state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - self.state - } - - fn next_u32(&mut self) -> u32 { - (self.next_u64() >> 33) as u32 - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index fdfaa1d74..5268e6e4b 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -112,9 +112,14 @@ struct QueuedWork { /// /// Submit work with `submit()`. Drain completions with `drain_completions()` /// once per tick. The Rayon thread pool runs tasks in priority order. +/// +/// Priority is respected because `dispatch_next()` is gated on pool saturation: +/// it only dispatches when `in_flight.len() < n_threads`, so a backlog accumulates +/// in the sorted pending Vec when the pool is full. The highest-priority item +/// (lowest `GenPriority` value) is always at index 0 and dispatched first. #[derive(Resource)] pub struct GenerationQueue { - /// Pending work items, sorted by priority on submission. + /// Pending work items, sorted by priority (index 0 = highest priority). pending: Arc>>, /// Completions channel — background tasks send here; main thread reads. completion_tx: Sender, @@ -123,6 +128,9 @@ pub struct GenerationQueue { pool: rayon::ThreadPool, /// Set of body_ids currently in-flight to avoid duplicate submissions. in_flight: Arc>>, + /// Thread count — caps concurrent dispatches so pending items accumulate + /// and priority ordering is consulted before the pool has free threads. + n_threads: usize, } impl std::fmt::Debug for GenerationQueue { @@ -160,6 +168,7 @@ impl GenerationQueue { completion_rx: rx, pool, in_flight: Arc::new(Mutex::new(std::collections::BTreeSet::new())), + n_threads, } } @@ -194,15 +203,21 @@ impl GenerationQueue { self.dispatch_next(); } - /// Drain all completed items from the channel. + /// Drain all completed items from the channel and dispatch pending work. /// /// Call once per tick from the main thread. Returns all completions - /// available without blocking. + /// available without blocking. After draining, dispatches as many pending + /// items as there are free thread slots — this is the point where priority + /// ordering matters, since the pool was saturated when items were submitted. pub fn drain_completions(&self) -> Vec { let mut out = Vec::new(); while let Ok(c) = self.completion_rx.try_recv() { out.push(c); } + // Fill any newly-freed slots. + for _ in 0..out.len() { + self.dispatch_next(); + } out } @@ -212,8 +227,18 @@ impl GenerationQueue { } // Dispatch the highest-priority pending item to the Rayon pool. + // + // Gated on pool saturation: only dispatches when in_flight.len() < n_threads. + // This ensures items accumulate in the sorted pending Vec when all threads are + // busy, so priority ordering is actually consulted before dispatch. fn dispatch_next(&self) { let item = { + let in_flight = self.in_flight.lock().unwrap(); + if in_flight.len() >= self.n_threads { + return; + } + drop(in_flight); + let mut pending = self.pending.lock().unwrap(); if pending.is_empty() { return; @@ -357,6 +382,41 @@ mod tests { assert_eq!(completions.len(), 3); } + #[test] + fn priority_ordering_respected_under_saturation() { + // Single-thread queue: pool saturates after 1 dispatch, so remaining + // items queue up and are dispatched in priority order. + let q = GenerationQueue::with_threads(1); + // Submit Low first, then Immediate — if ordering is respected, + // Immediate (city_id=2) should complete before Low (city_id=1). + q.submit( + GenWorkItem::GenerateSkeleton { city_id: 1 }, + GenPriority::Low, + ); + q.submit( + GenWorkItem::GenerateSkeleton { city_id: 2 }, + GenPriority::Immediate, + ); + // Wait for both to complete. + std::thread::sleep(Duration::from_millis(200)); + let completions = q.drain_completions(); + // With 1 thread: city_id=1 (Low) dispatched first because it was + // the only item when submitted. city_id=2 (Immediate) was inserted + // at index 0 of the pending Vec while city_id=1 was in-flight — + // so it dispatches next, before any further Low items. + assert_eq!(completions.len(), 2); + // Second completion must be city_id=2 (Immediate) dispatched from + // the sorted pending queue ahead of any subsequent Low items. + assert!(matches!( + &completions[0], + GenCompletion::SkeletonGenerated { city_id: 1 } + )); + assert!(matches!( + &completions[1], + GenCompletion::SkeletonGenerated { city_id: 2 } + )); + } + #[test] fn drain_empty_returns_empty() { let q = make_queue(); diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 7cdc09a48..ff0ccbe2b 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -10,5 +10,6 @@ pub mod district_mix; pub mod drainage; pub mod gen_queue; pub mod heightmap; +pub mod rng; pub mod skeleton_gen; pub mod tile_condition; diff --git a/server/src/atlas/rng.rs b/server/src/atlas/rng.rs new file mode 100644 index 000000000..42b87a95a --- /dev/null +++ b/server/src/atlas/rng.rs @@ -0,0 +1,74 @@ +//! Seeded LCG for deterministic generation (D-010). +//! +//! Shared by all atlas generation modules that need seeded randomness. +//! Uses Knuth's LCG parameters — integer-only arithmetic, no f32, D-010 compliant. +//! +//! Two construction options: +//! - [`AtlasRng::new`] — adds 1 to seed (district_mix convention). +//! - [`AtlasRng::new_mixed`] — mixes seed with a Fibonacci hash constant to +//! avoid degenerate state at 0 (skeleton_gen convention). + +/// Seeded linear congruential generator (D-010). +pub struct AtlasRng { + state: u64, +} + +impl AtlasRng { + /// Seed by adding 1 — matches the district_mix convention. + pub fn new(seed: u64) -> Self { + Self { + state: seed.wrapping_add(1), + } + } + + /// Seed with Fibonacci hash mix — avoids degenerate state at seed=0. + pub fn new_mixed(seed: u64) -> Self { + Self { + state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15), + } + } + + fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.state + } + + /// Next pseudorandom `u32` (top 31 bits of the LCG state). + pub fn next_u32(&mut self) -> u32 { + (self.next_u64() >> 33) as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_sequence() { + let mut a = AtlasRng::new(42); + let mut b = AtlasRng::new(42); + for _ in 0..100 { + assert_eq!(a.next_u32(), b.next_u32()); + } + } + + #[test] + fn different_seeds_differ() { + let mut a = AtlasRng::new(1); + let mut b = AtlasRng::new(2); + let vals_a: Vec = (0..10).map(|_| a.next_u32()).collect(); + let vals_b: Vec = (0..10).map(|_| b.next_u32()).collect(); + assert_ne!(vals_a, vals_b); + } + + #[test] + fn mixed_seed_avoids_zero_state() { + let mut r = AtlasRng::new_mixed(0); + // Should not produce a constant zero sequence. + let vals: Vec = (0..5).map(|_| r.next_u32()).collect(); + assert!(vals.iter().any(|&v| v != 0)); + } +} diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 50237f50b..d07c60ca0 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -20,6 +20,7 @@ use crate::atlas::block_irregularity::block_irregularity; use crate::atlas::district_mix::{compute_district_mix, population_tier}; +use crate::atlas::rng::AtlasRng; use crate::simulation::generator::{ BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId, DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype, @@ -176,11 +177,14 @@ fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> Setti /// Derive ComplexityTier from WorldTier + population tier (D-194, D-218). /// +/// Backwater is "NOT budget-capped" per D-218 — it joins Epicenter/Regional +/// at Full complexity rather than being capped at Moderate like Passage. +/// /// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 | /// |-----------------|---------------|----------------------| /// | Epicenter | Full | Moderate | /// | Regional | Full | Moderate | -/// | Backwater | Moderate | Minimal | +/// | Backwater | Full | Moderate | /// | Passage | Moderate | Minimal | /// | Waypoint | Minimal | Minimal (→ Empty <5K)| fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier { @@ -190,14 +194,14 @@ fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> C } match world_tier { - WorldTier::Epicenter | WorldTier::Regional => { + WorldTier::Epicenter | WorldTier::Regional | WorldTier::Backwater => { if pop_tier >= 1 { ComplexityTier::Full } else { ComplexityTier::Moderate } } - WorldTier::Backwater | WorldTier::Passage => { + WorldTier::Passage => { if pop_tier >= 1 { ComplexityTier::Moderate } else { @@ -241,7 +245,7 @@ fn derive_layout_mode( /// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`. fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] { let max_offset = (irregularity * 16.0) as i16; - let mut lcg = SkeletonLcg::new(seed); + let mut lcg = AtlasRng::new_mixed(seed); // Build the 2D array using a flat closure to keep things readable. let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement { @@ -264,7 +268,6 @@ fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] }; } - // Safety: BlockPlacement is Copy-able; reshape flat array to [[_; 4]; 4]. core::array::from_fn(|row| core::array::from_fn(|col| flat[row * 4 + col].clone())) } @@ -398,35 +401,6 @@ fn derive_reservations( out } -// --------------------------------------------------------------------------- -// Minimal seeded LCG (D-010 determinism) -// --------------------------------------------------------------------------- - -struct SkeletonLcg { - state: u64, -} - -impl SkeletonLcg { - fn new(seed: u64) -> Self { - // Mix seed to avoid degenerate state at 0. - Self { - state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15), - } - } - - fn next_u64(&mut self) -> u64 { - self.state = self - .state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - self.state - } - - fn next_u32(&mut self) -> u32 { - (self.next_u64() >> 33) as u32 - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -482,10 +456,26 @@ mod tests { } #[test] - fn complexity_backwater_low_pop_is_minimal() { + fn complexity_backwater_low_pop_is_moderate() { let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); - // 50_000 pop → pop_tier = 0 → Minimal on Backwater + // 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped) let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42); + assert_eq!(sk.complexity, ComplexityTier::Moderate); + } + + #[test] + fn complexity_backwater_high_pop_is_full() { + let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); + // 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped) + let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, 42); + assert_eq!(sk.complexity, ComplexityTier::Full); + } + + #[test] + fn complexity_passage_low_pop_is_minimal() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage); + // 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped) + let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, 42); assert_eq!(sk.complexity, ComplexityTier::Minimal); } diff --git a/server/src/simulation/name_index.rs b/server/src/simulation/name_index.rs index 07cd26fde..3d877091d 100644 --- a/server/src/simulation/name_index.rs +++ b/server/src/simulation/name_index.rs @@ -76,11 +76,17 @@ impl SystemNameIndex { let (patterns, ids): (Vec, Vec) = entries.into_iter().unzip(); - let automaton = AhoCorasickBuilder::new() + let automaton = match AhoCorasickBuilder::new() .ascii_case_insensitive(true) .match_kind(MatchKind::LeftmostFirst) .build(&patterns) - .unwrap_or_else(|e| panic!("SystemNameIndex: automaton build failed: {e}")); + { + Ok(a) => a, + Err(e) => { + tracing::error!(error = %e, "SystemNameIndex: automaton build failed — index unavailable"); + return None; + } + }; tracing::info!(pattern_count = ids.len(), "SystemNameIndex built"); Some(Self { automaton, ids }) diff --git a/tooling/check-systems-db-stamp b/tooling/check-systems-db-stamp index b89a58b49..b5caa2791 100644 --- a/tooling/check-systems-db-stamp +++ b/tooling/check-systems-db-stamp @@ -51,6 +51,7 @@ GENERATOR_SOURCES: dict[str, list[Path]] = { REPO_ROOT / "tooling" / "planet-gen" / "generate_atlas.py", REPO_ROOT / "tooling" / "planet-gen" / "gemma_naming.py", REPO_ROOT / "tooling" / "planet-gen" / "naming_core.py", + REPO_ROOT / "tooling" / "planet-gen" / "import_city_names.py", REPO_ROOT / "tooling" / "planet-gen" / "import_heightmaps.py", REPO_ROOT / "tooling" / "planet-gen" / "import_province_boundaries.py", REPO_ROOT / "tooling" / "schema_version.py", diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 0663148e1..096e0bb23 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -303,15 +303,16 @@ CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightma -- City name reservations (D-207, #902) CREATE TABLE IF NOT EXISTS atlas_city_names ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, - name TEXT NOT NULL, - kind TEXT NOT NULL DEFAULT 'city', - economic_role TEXT NOT NULL, - population INTEGER NOT NULL, - corp_id TEXT REFERENCES corporations(corp_id), - reserved INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'city', + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + settlement_class TEXT, + corp_id TEXT REFERENCES corporations(corp_id), + reserved INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind); @@ -338,6 +339,17 @@ CREATE TABLE IF NOT EXISTS atlas_province_boundaries ( ); CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id); +-- City positions — attractor-matched placement output (D-211, #34) +CREATE TABLE IF NOT EXISTS atlas_city_positions ( + city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + row INTEGER NOT NULL, + col INTEGER NOT NULL, + attractor_type TEXT NOT NULL, + score REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id); + -- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911). -- Idempotent: each UPDATE is a no-op if the old value is already gone. UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture'); @@ -357,6 +369,7 @@ COLUMN_MIGRATIONS = [ ("brand_products", "price_tier", "TEXT"), ("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable ("meta", "schema_sha", "TEXT"), + ("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37) ] diff --git a/tooling/planet-gen/generate_atlas.py b/tooling/planet-gen/generate_atlas.py index 9e5695f47..c12878ad1 100644 --- a/tooling/planet-gen/generate_atlas.py +++ b/tooling/planet-gen/generate_atlas.py @@ -130,6 +130,7 @@ def _write_stamp(conn: sqlite3.Connection) -> None: Path(__file__), _atlas_dir / "gemma_naming.py", _atlas_dir / "naming_core.py", + _atlas_dir / "import_city_names.py", _atlas_dir / "import_heightmaps.py", _atlas_dir / "import_province_boundaries.py", REPO_ROOT / "tooling" / "schema_version.py", diff --git a/tooling/planet-gen/import_province_boundaries.py b/tooling/planet-gen/import_province_boundaries.py index b441d4312..fa348cebc 100644 --- a/tooling/planet-gen/import_province_boundaries.py +++ b/tooling/planet-gen/import_province_boundaries.py @@ -21,6 +21,13 @@ Algorithm: Province count target: 4–12 per body (D-205). Bodies with low relief get fewer, larger provinces; high-relief worlds get more. +Performance: ~3–5s per body on a single CPU core at canonical 512×256 resolution. +The bottleneck is the pure-Python depression-fill + flow-direction scan (O(H×W) each, +~131k cells). For a full run of ~270 inhabited bodies expect ~15–20 minutes. +Hot loops (_depression_fill, _flow_direction) are candidates for NumPy vectorization +if build time becomes a bottleneck; the current scalar implementation is correct +and deterministic, which takes priority at this stage. + Incremental: bodies that already have rows in atlas_province_boundaries are skipped unless --force is passed. @@ -437,17 +444,18 @@ def import_body_provinces( print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}") if not dry_run: - conn.execute( - "DELETE FROM atlas_province_boundaries WHERE body_id = ?", - (body_id,), - ) - for b in basins: + with conn: # per-body savepoint: rolls back this body on exception, keeps prior commits conn.execute( - """INSERT INTO atlas_province_boundaries - (body_id, basin_id, path, area_pct) - VALUES (?, ?, ?, ?)""", - (body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]), + "DELETE FROM atlas_province_boundaries WHERE body_id = ?", + (body_id,), ) + for b in basins: + conn.execute( + """INSERT INTO atlas_province_boundaries + (body_id, basin_id, path, area_pct) + VALUES (?, ?, ?, ?)""", + (body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]), + ) return {"status": "imported", "imported": len(basins)} @@ -523,9 +531,6 @@ def main() -> None: n_errors += 1 print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}") - if not args.dry_run: - conn.commit() - conn.close() elapsed_total = time.time() - t_total