fix(simulation): address PR #142 review — 11 issues resolved

Rust fixes: D-218 Backwater complexity (moved to Full arm), priority
queue dispatch gated on thread saturation, panic→soft-fail in
name_index, duplicated LCG unified into atlas::rng module, misleading
Safety comment removed, D-210 SubBiomeVariant deferred to #948.

Schema/data fixes: atlas_city_positions table (D-211 persistence),
settlement_class column on atlas_city_names, per-body transaction
boundaries in import_province_boundaries, import_city_names.py added
to GENERATOR_SOURCES, Python perf documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 15:30:50 +02:00
co-authored by Claude Opus 4.6
parent 1a2bc58e27
commit 332b9404be
13 changed files with 239 additions and 101 deletions
+1
View File
@@ -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 \
+23 -9
View File
@@ -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
Binary file not shown.
+2 -30
View File
@@ -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
// ---------------------------------------------------------------------------
+63 -3
View File
@@ -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<Mutex<Vec<QueuedWork>>>,
/// Completions channel — background tasks send here; main thread reads.
completion_tx: Sender<GenCompletion>,
@@ -123,6 +128,9 @@ pub struct GenerationQueue {
pool: rayon::ThreadPool,
/// Set of body_ids currently in-flight to avoid duplicate submissions.
in_flight: Arc<Mutex<std::collections::BTreeSet<String>>>,
/// 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<GenCompletion> {
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();
+1
View File
@@ -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;
+74
View File
@@ -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<u32> = (0..10).map(|_| a.next_u32()).collect();
let vals_b: Vec<u32> = (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<u32> = (0..5).map(|_| r.next_u32()).collect();
assert!(vals.iter().any(|&v| v != 0));
}
}
+26 -36
View File
@@ -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);
}
+8 -2
View File
@@ -76,11 +76,17 @@ impl SystemNameIndex {
let (patterns, ids): (Vec<String>, Vec<String>) = 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 })
+1
View File
@@ -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",
+22 -9
View File
@@ -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)
]
+1
View File
@@ -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",
@@ -21,6 +21,13 @@ Algorithm:
Province count target: 412 per body (D-205). Bodies with low relief get fewer,
larger provinces; high-relief worlds get more.
Performance: ~35s 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 ~1520 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