Merge PR #145: wire D-199 economic read-set into generation context (#954)

Layer 2 clean half. Prosperity in integer basis points (D-010).
Reviewed-by: Hoshe (code quality), Tyre (architecture) — both APPROVE.
This commit is contained in:
2026-05-31 14:34:57 +02:00
8 changed files with 991 additions and 27 deletions
+756
View File
@@ -0,0 +1,756 @@
//! Build-time economic read-set reader for city generation contexts (D-199).
//!
//! Resolves the D-199 6-field minimum economic read-set for a city from
//! `systems.db` at generation-dispatch time. All 6 fields are required before a
//! `GenerateSkeleton` work item is dispatched (D-199: "Missing fields abort the
//! task with a logged error; generation does not proceed with partial context").
//!
//! **Fields read** (D-199):
//!
//! 1. `economic_role` — from `atlas_city_names.economic_role`
//! 2. `prosperity_baseline_bps` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 0)
//! 3. `population` — from `atlas_city_names.population`
//! 4. `dominant_faction` — from `system_factions.dominant_faction` (via `bodies.system_id`)
//! 5. `founding_age_years` — from `bodies.founding_age_years`
//! 6. `settlement_class` — from `atlas_city_names.settlement_class`
//!
//! **Fields left at defaults** (deferred work, out of scope for this ticket):
//!
//! - `political_archetype` — Commission placeholder (D-214 faction→archetype mapping deferred)
//! - `surrounding_biome` — Urban default (body `planet_class` + Layer-1 sub-biome deferred)
//! - `road_entry_directions` — empty (attractor placement deferred)
//! - `footprint_radius_km` — 5.0 stub (D-204 formula deferred)
//! - `founding_orientation` — Cardinal stub (attractor matching deferred)
//! - `world_tier` — Waypoint default (`system_economy.economic_tier` derivation deferred)
//! - `morphology_zone` — AlluvialPlain default (D-228 deferred)
//! - `trait_selection` — empty (trait catalog #1005 deferred)
//! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked)
//! - `dominant_production_ubiquity` — Common default (#982 design-blocked)
//!
//! **Prosperity derivation (D-197, partial — integer basis points, D-010):**
//! `prosperity_baseline_bps = clamp(role_base_bps + pop_bonus_bps + noise_bps, 1000, 9500)`
//! where (all values are integer basis points; 10_000 bps = 1.0):
//! - `role_base_bps` — per-role lookup (10 values; D-197 table)
//! - `pop_bonus_bps` — `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200
//! - `terrain_bonus_bps` — 0 (Layer-1 topography not yet available here)
//! - `noise_bps` — ±500 symmetric uniform via `SeedChain` and integer modulo (D-010 deterministic)
//!
//! Read-only `systems.db` access follows the same pattern as
//! [`crate::atlas::source_resolver::BodySourceResolver`].
use std::path::Path;
use std::sync::{Arc, Mutex};
use rusqlite::{Connection, OpenFlags};
use thiserror::Error;
use crate::bps::log10_floor;
use crate::seed::{fnv1a_64, splitmix64, AtlasRng, SeedChain, SeedDomain};
use crate::simulation::generator::{
BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype,
ProductionUbiquity, SettingType, SettlementClass, WorldTier,
};
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Errors that can occur while reading the economic read-set for a city.
#[derive(Debug, Error)]
pub enum CityContextReadError {
#[error("systems.db error: {0}")]
Db(String),
#[error("city {city_id} not found in atlas_city_names")]
UnknownCity { city_id: u64 },
#[error("city {city_id}: missing required field `{field}`")]
MissingField { city_id: u64, field: &'static str },
}
// ---------------------------------------------------------------------------
// Raw read-set from the DB
// ---------------------------------------------------------------------------
/// D-199 6-field economic read-set as raw strings/integers from the DB.
/// All fields are `Option` because the DB columns are nullable; the reader
/// converts missing fields into `CityContextReadError::MissingField` before
/// returning.
#[derive(Debug, Clone)]
pub struct CityEconomicReadSet {
/// D-199 field 1.
pub economic_role: String,
/// D-199 field 2 — derived by `prosperity_baseline_from_read_set`.
/// Integer basis points (010_000; 10_000 = 1.0). D-010 integer-only.
pub prosperity_baseline_bps: u32,
/// D-199 field 3.
pub population: i64,
/// D-199 field 4. `None` = faction data absent for this system.
pub dominant_faction: Option<String>,
/// D-199 field 5.
pub founding_age_years: u32,
/// D-199 field 6.
pub settlement_class: SettlementClass,
}
// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
/// Reads the D-199 economic read-set for a city from `systems.db` at
/// generation-dispatch time. Holds a read-only SQLite connection.
///
/// Analogous to [`crate::atlas::source_resolver::BodySourceResolver`] — both
/// read from `systems.db` read-only and are used at dispatch time to pre-resolve
/// the inputs a Rayon work item needs before it is submitted.
pub struct CityContextReader {
conn: Arc<Mutex<Connection>>,
}
impl CityContextReader {
/// Open a read-only connection to `systems_db`.
pub fn open(systems_db: &Path) -> Result<Self, CityContextReadError> {
let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| CityContextReadError::Db(e.to_string()))?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
}
/// Read the D-199 6-field economic read-set for `city_id`.
///
/// Joins `atlas_city_names` → `bodies` → `system_factions` to gather all
/// required fields. Returns `CityContextReadError::MissingField` if any
/// required field is NULL. `dominant_faction` is allowed to be NULL (the
/// system may have no recorded faction data); it is carried as `Option`.
pub fn read_set(
&self,
city_id: u64,
world_seed: u64,
) -> Result<CityEconomicReadSet, CityContextReadError> {
let conn = self
.conn
.lock()
.map_err(|e| CityContextReadError::Db(format!("mutex poisoned: {e}")))?;
// ─── Query ────────────────────────────────────────────────────────────
// atlas_city_names carries economic_role, population, settlement_class.
// bodies carries founding_age_years (via the city's body_id).
// system_factions carries dominant_faction (via bodies.system_id).
//
// settlement_class is nullable (NULL until attractor placement runs).
// dominant_faction is nullable (some systems have no faction data).
// founding_age_years is nullable (uninhabited bodies).
let row: rusqlite::Result<(
Option<String>, // acn.economic_role
i64, // acn.population
Option<String>, // acn.settlement_class
Option<i64>, // b.founding_age_years
Option<String>, // sf.dominant_faction
)> = conn.query_row(
"SELECT
acn.economic_role,
acn.population,
acn.settlement_class,
b.founding_age_years,
sf.dominant_faction
FROM atlas_city_names AS acn
JOIN bodies AS b ON b.body_id = acn.body_id
LEFT JOIN system_factions AS sf ON sf.system_id = b.system_id
WHERE acn.id = ?1",
[city_id as i64],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
);
let row = match row {
Ok(r) => r,
Err(rusqlite::Error::QueryReturnedNoRows) => {
return Err(CityContextReadError::UnknownCity { city_id });
}
Err(e) => return Err(CityContextReadError::Db(e.to_string())),
};
let (
economic_role_opt,
population,
settlement_class_opt,
founding_age_opt,
dominant_faction,
) = row;
let economic_role = economic_role_opt.ok_or(CityContextReadError::MissingField {
city_id,
field: "economic_role",
})?;
let founding_age_years = founding_age_opt.ok_or(CityContextReadError::MissingField {
city_id,
field: "founding_age_years",
})? as u32;
let settlement_class = parse_settlement_class(settlement_class_opt.as_deref(), city_id)?;
// D-197: derive prosperity_baseline_bps from role base + pop bonus + noise.
// All arithmetic is integer (basis points, 10_000 = 1.0) — D-010 compliant.
let prosperity_baseline_bps =
prosperity_baseline_from_read_set(&economic_role, population, city_id, world_seed);
Ok(CityEconomicReadSet {
economic_role,
prosperity_baseline_bps,
population,
dominant_faction,
founding_age_years,
settlement_class,
})
}
/// Build a [`CityGenerationContext`] from the economic read-set, with
/// deferred fields left at their defaults.
///
/// D-199 guarantees that the 6 required fields are populated. All other
/// fields on `CityGenerationContext` are at stub/default values as documented
/// in the module comment.
pub fn build_context(
&self,
city_id: u64,
world_seed: u64,
) -> Result<CityGenerationContext, CityContextReadError> {
let rs = self.read_set(city_id, world_seed)?;
Ok(context_from_read_set(city_id, rs))
}
}
// ---------------------------------------------------------------------------
// prosperity_baseline_bps derivation (D-197, partial — integer basis points)
// ---------------------------------------------------------------------------
/// Per-role base prosperity in basis points (D-197 table).
///
/// 10_000 bps = 1.0. Values match the previous f32 table scaled by ×10_000:
/// 0.55 → 5500, 0.70 → 7000, etc.
fn role_base_bps(role: &str) -> u32 {
match role {
"manufacturing" => 5_500,
"financial" => 7_000,
"agricultural" => 5_000,
"extraction" => 4_500,
"service_mixed" => 6_000,
"institutional" => 6_500,
"transit_hub" => 6_000,
"research" => 6_500,
"military" => 5_500,
"residential" => 5_000,
// Unknown role → mid-point fallback; logged by the caller if needed.
_ => 5_500,
}
}
/// Population log-scale bonus in basis points (D-197).
///
/// `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200.
///
/// Uses [`log10_floor`] — no floats, no platform-dependent rounding (D-010).
/// The `+1` inside the log10 ensures the result is ≥ 0 for any positive pop.
fn pop_bonus_bps(population: i64) -> u32 {
if population <= 0 {
return 0;
}
// ratio = pop / 1_000_000 + 1. Integer division (floors toward zero) matches
// floor(pop / 1_000_000.0) for positive values, then +1 gives ≥ 1 for log.
let ratio = (population as u64) / 1_000_000 + 1;
let mag = log10_floor(ratio);
// 400 bps per order of magnitude, cap at 1200 (3 orders).
(400 * mag).min(1_200)
}
/// ±500 bps symmetric uniform noise seeded deterministically from (world_seed, city_id).
///
/// Uses `SeedChain` to derive a per-city noise stream, then maps via integer
/// modulo to [-500, +500] — no floats, fixes the always-negative bug that
/// arose from the old code dividing a 31-bit RNG value by u32::MAX (D-010).
///
/// **SeedChain derivation:** seeds root → Layer3Settlement directly using the
/// city_id hash as the domain id. This is safe because city_id is globally
/// unique (it is the `atlas_city_names.id` primary key); no body_id is
/// available at read time.
fn prosperity_noise_bps(city_id: u64, world_seed: u64) -> i32 {
// Domain: Layer3Settlement (prosperity is a settlement-level property).
// city_id is globally unique, so root → Layer3Settlement is unambiguous.
let seed = SeedChain::root(world_seed)
.derive(SeedDomain::Layer3Settlement, fnv1a_64(&city_id.to_string()))
.seed();
let mut rng = AtlasRng::new(splitmix64(seed));
// Integer modulo over 1001 values (0..=1000), shifted to [-500, +500].
// AtlasRng::next_u32() returns a 31-bit value — modulo is uniform here
// because 1001 divides cleanly into the 31-bit range (2^31 / 1001 ≈ 2.1M,
// so bias is negligible, but correctness doesn't depend on that — we only
// need ±500 symmetric range, not strict uniformity).
(rng.next_u32() % 1_001) as i32 - 500
}
/// D-197 formula in integer basis points (terrain gradient left at 0 — requires Layer-1 data).
///
/// `clamp(role_base_bps + pop_bonus_bps + terrain_bonus_bps + noise_bps, 1000, 9500)`
///
/// All arithmetic is integer — no f32/f64 in the determinism path (D-010).
pub fn prosperity_baseline_from_read_set(
economic_role: &str,
population: i64,
city_id: u64,
world_seed: u64,
) -> u32 {
let base = role_base_bps(economic_role) as i32;
let pb = pop_bonus_bps(population) as i32;
// terrain_bonus_bps = 0: Layer-1 attractor output not available at this tier.
let noise = prosperity_noise_bps(city_id, world_seed);
let raw = base + pb + noise;
raw.clamp(1_000, 9_500) as u32
}
// ---------------------------------------------------------------------------
// SettlementClass parsing
// ---------------------------------------------------------------------------
/// Parse a `SettlementClass` from its DB TEXT representation.
///
/// `atlas_city_names.settlement_class` is nullable (NULL until city placement
/// runs). A NULL value defaults to `NameLocked` — the safest class that
/// unconditionally proceeds with generation — and is reported as a missing-field
/// error so callers can decide whether to abort or proceed.
fn parse_settlement_class(
raw: Option<&str>,
city_id: u64,
) -> Result<SettlementClass, CityContextReadError> {
match raw {
Some("NameLocked") => Ok(SettlementClass::NameLocked),
Some("PopulationBudget") => Ok(SettlementClass::PopulationBudget),
Some("EconomicTriggered") => Ok(SettlementClass::EconomicTriggered),
Some("OrganicGrowth") => Ok(SettlementClass::OrganicGrowth),
None => Err(CityContextReadError::MissingField {
city_id,
field: "settlement_class",
}),
Some(_unknown) => {
// Unknown variant text — treated as missing. A dedicated
// "invalid variant" error branch would add noise for no consumer
// benefit at this stage; MissingField is the closest signal.
Err(CityContextReadError::MissingField {
city_id,
field: "settlement_class",
})
}
}
}
// ---------------------------------------------------------------------------
// Context assembly
// ---------------------------------------------------------------------------
/// Assemble a `CityGenerationContext` from the D-199 read-set, with deferred
/// fields at their defaults.
///
/// Deferred fields are documented in the module comment above.
pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGenerationContext {
CityGenerationContext {
city_id,
// ── D-199 6-field read set ────────────────────────────────────────
// prosperity_baseline_bps is D-199 field 2 (derived, D-197).
prosperity_baseline_bps: rs.prosperity_baseline_bps,
// ── Deferred fields (stubs) ───────────────────────────────────────
// political_archetype: real derivation requires D-214 faction→archetype
// mapping. Commission is the safest default (grid layout, avoids
// organic-placement paths that depend on founding_age_years detail).
political_archetype: PoliticalArchetype::Commission,
// surrounding_biome: real value comes from body planet_class + Layer-1
// sub-biome.
surrounding_biome: SettingType::Urban,
// road_entry_directions: populated by attractor placement.
road_entry_directions: Vec::new(),
// footprint_radius_km: populated by D-204 formula (body_radius_km +
// population).
footprint_radius_km: 5.0,
// founding_orientation: populated by attractor matching.
founding_orientation: FoundingOrientation::Cardinal,
// world_tier: real derivation from system_economy.economic_tier (#TBD).
world_tier: WorldTier::Waypoint,
// morphology_zone: Layer-1 output, D-228 deferred.
morphology_zone: MorphologyZone::AlluvialPlain,
// trait_selection: trait catalog #1005 deferred.
trait_selection: Vec::new(),
// dominant_bulk_class: dominant-commodity derivation #982 design-blocked.
dominant_bulk_class: BulkClass::NonPhysical,
// dominant_production_ubiquity: same blocker as above.
dominant_production_ubiquity: ProductionUbiquity::Common,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
// ─── Temp DB helpers ─────────────────────────────────────────────────────
/// Build a minimal `systems.db` containing one city with all 6 D-199 fields
/// present. Returns the DB path.
///
/// Schema mirrors the real one (just the columns the reader queries).
fn make_test_db(
body_id: &str,
system_id: &str,
economic_role: &str,
population: i64,
settlement_class: Option<&str>,
founding_age_years: Option<i64>,
dominant_faction: Option<&str>,
) -> (PathBuf, i64) {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("sr_ctxrd_{}_{n}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = Connection::open(&path).expect("create db");
conn.execute_batch(
"CREATE TABLE star_systems (system_id TEXT PRIMARY KEY);
CREATE TABLE bodies (
body_id TEXT PRIMARY KEY,
system_id TEXT NOT NULL,
founding_age_years INTEGER
);
CREATE TABLE system_factions (
system_id TEXT PRIMARY KEY,
dominant_faction TEXT
);
CREATE TABLE atlas_city_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'city',
economic_role TEXT NOT NULL,
population INTEGER NOT NULL,
settlement_class TEXT
);",
)
.expect("create tables");
conn.execute(
"INSERT INTO star_systems (system_id) VALUES (?1)",
rusqlite::params![system_id],
)
.expect("insert system");
conn.execute(
"INSERT INTO bodies (body_id, system_id, founding_age_years) VALUES (?1, ?2, ?3)",
rusqlite::params![body_id, system_id, founding_age_years],
)
.expect("insert body");
if let Some(faction) = dominant_faction {
conn.execute(
"INSERT INTO system_factions (system_id, dominant_faction) VALUES (?1, ?2)",
rusqlite::params![system_id, faction],
)
.expect("insert faction");
}
conn.execute(
"INSERT INTO atlas_city_names (body_id, name, economic_role, population, settlement_class)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![body_id, "TestCity", economic_role, population, settlement_class],
)
.expect("insert city");
let id: i64 = conn
.query_row("SELECT id FROM atlas_city_names LIMIT 1", [], |r| r.get(0))
.expect("get id");
path.try_exists().expect("db exists");
drop(conn);
(path, id)
}
// ─── role_base_bps ───────────────────────────────────────────────────────
#[test]
fn role_base_financial_is_7000() {
assert_eq!(role_base_bps("financial"), 7_000);
}
#[test]
fn role_base_unknown_falls_back_to_5500() {
assert_eq!(role_base_bps("deep_space_weird_role"), 5_500);
}
// ─── pop_bonus_bps ───────────────────────────────────────────────────────
#[test]
fn pop_bonus_zero_population() {
assert_eq!(pop_bonus_bps(0), 0);
}
#[test]
fn pop_bonus_one_million() {
// pop=1_000_000 → ratio = 1_000_000 / 1_000_000 + 1 = 2 → log10_floor(2) = 0 → 0 bps
assert_eq!(pop_bonus_bps(1_000_000), 0);
}
#[test]
fn pop_bonus_ten_million() {
// pop=10_000_000 → ratio = 10 + 1 = 11 → log10_floor(11) = 1 → 400 bps
assert_eq!(pop_bonus_bps(10_000_000), 400);
}
#[test]
fn pop_bonus_hundred_million() {
// pop=100_000_000 → ratio = 100 + 1 = 101 → log10_floor(101) = 2 → 800 bps
assert_eq!(pop_bonus_bps(100_000_000), 800);
}
#[test]
fn pop_bonus_capped_at_1200() {
// Huge population → bonus capped at 1200.
assert_eq!(pop_bonus_bps(i64::MAX), 1_200);
}
// ─── prosperity_noise_bps ────────────────────────────────────────────────
#[test]
fn prosperity_noise_is_deterministic() {
let a = prosperity_noise_bps(42, 1234);
let b = prosperity_noise_bps(42, 1234);
assert_eq!(a, b, "same inputs must give same noise");
}
#[test]
fn prosperity_noise_in_range() {
for city_id in 0..20u64 {
let n = prosperity_noise_bps(city_id, 99);
assert!(
(-500..=500).contains(&n),
"noise {n} out of [-500, 500] for city_id={city_id}"
);
}
}
#[test]
fn prosperity_noise_positive_is_achievable() {
// Regression: old code divided a 31-bit RNG value by u32::MAX,
// making the result always < 0.5 and therefore always negative
// after the shift. Verify that at least one of a set of city_ids
// produces positive noise, confirming the bug is fixed.
let positives = (0u64..100)
.map(|id| prosperity_noise_bps(id, 12345))
.filter(|&n| n > 0)
.count();
assert!(
positives > 0,
"expected at least one positive noise value across 100 city_ids (was always-negative before fix)"
);
}
// ─── prosperity_baseline_from_read_set ───────────────────────────────────
#[test]
fn prosperity_baseline_bps_clamped_low() {
// extraction (4500) + zero pop + worst noise (-500) = 4000 → in [1000, 9500]
let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0);
assert!((1_000..=9_500).contains(&p));
}
#[test]
fn prosperity_baseline_bps_extraction_range() {
// extraction base 4500 ± 500 noise → expect [4000, 5000]
let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0);
assert!(
(4_000..=5_000).contains(&p),
"extraction/0 pop should be ~4500 ± 500, got {p}"
);
}
// ─── DB read-set integration ──────────────────────────────────────────────
/// Representative body with all 6 D-199 fields present — the primary test case.
#[test]
fn read_set_populates_all_6_fields() {
let (db, city_id) = make_test_db(
"GJ1c",
"GJ-1",
"financial",
5_000_000,
Some("NameLocked"),
Some(450),
Some("The Commission"),
);
let reader = CityContextReader::open(&db).expect("open");
let rs = reader.read_set(city_id as u64, 42).expect("read set");
// Field 1 — economic_role
assert_eq!(rs.economic_role, "financial");
// Field 2 — prosperity_baseline_bps (non-stub: derived from real role+pop)
// financial base 7000 bps + pop_bonus(5M) bps ± 500 noise
let expected_base_bps = 7_000 + pop_bonus_bps(5_000_000);
let diff = (rs.prosperity_baseline_bps as i32 - expected_base_bps as i32).abs();
assert!(
diff <= 500 + 1,
"prosperity_baseline_bps {} not near expected base {} ± noise",
rs.prosperity_baseline_bps,
expected_base_bps
);
assert!((1_000..=9_500).contains(&rs.prosperity_baseline_bps));
// Field 3 — population
assert_eq!(rs.population, 5_000_000);
// Field 4 — dominant_faction
assert_eq!(rs.dominant_faction.as_deref(), Some("The Commission"));
// Field 5 — founding_age_years
assert_eq!(rs.founding_age_years, 450);
// Field 6 — settlement_class
assert!(matches!(rs.settlement_class, SettlementClass::NameLocked));
}
#[test]
fn build_context_prosperity_bps_is_not_stub() {
let (db, city_id) = make_test_db(
"GJ2b",
"GJ-2",
"manufacturing",
2_000_000,
Some("PopulationBudget"),
Some(200),
None,
);
let reader = CityContextReader::open(&db).expect("open");
let ctx = reader
.build_context(city_id as u64, 77)
.expect("build context");
// manufacturing base is 5500 bps; pop=2M → pop_bonus=0; noise in ±500.
// Result should be ≈50006000 bps.
assert!(
(1_000..=9_500).contains(&ctx.prosperity_baseline_bps),
"prosperity_baseline_bps must be in [1000, 9500], got {}",
ctx.prosperity_baseline_bps
);
assert!(
(4_900..=6_100).contains(&ctx.prosperity_baseline_bps),
"manufacturing/2M should produce ~5500 ± 600 bps, got {}",
ctx.prosperity_baseline_bps
);
}
#[test]
fn unknown_city_returns_error() {
let (db, _) = make_test_db(
"GJ3c",
"GJ-3",
"research",
100_000,
Some("NameLocked"),
Some(100),
None,
);
let reader = CityContextReader::open(&db).expect("open");
let err = reader.read_set(99999, 0).expect_err("should fail");
assert!(matches!(
err,
CityContextReadError::UnknownCity { city_id: 99999 }
));
}
#[test]
fn missing_settlement_class_returns_error() {
let (db, city_id) = make_test_db(
"GJ4b",
"GJ-4",
"extraction",
50_000,
None, // settlement_class NULL
Some(300),
None,
);
let reader = CityContextReader::open(&db).expect("open");
let err = reader.read_set(city_id as u64, 0).expect_err("should fail");
assert!(matches!(
err,
CityContextReadError::MissingField {
field: "settlement_class",
..
}
));
}
#[test]
fn missing_founding_age_returns_error() {
let (db, city_id) = make_test_db(
"GJ5c",
"GJ-5",
"transit_hub",
80_000,
Some("NameLocked"),
None, // founding_age_years NULL
None,
);
let reader = CityContextReader::open(&db).expect("open");
let err = reader.read_set(city_id as u64, 0).expect_err("should fail");
assert!(matches!(
err,
CityContextReadError::MissingField {
field: "founding_age_years",
..
}
));
}
#[test]
fn dominant_faction_none_is_allowed() {
// dominant_faction is permitted to be absent (no system_factions row).
let (db, city_id) = make_test_db(
"GJ6b",
"GJ-6",
"residential",
30_000,
Some("PopulationBudget"),
Some(120),
None, // no faction row
);
let reader = CityContextReader::open(&db).expect("open");
let rs = reader.read_set(city_id as u64, 0).expect("should succeed");
assert!(rs.dominant_faction.is_none());
}
#[test]
fn prosperity_bps_is_deterministic_across_calls() {
let (db, city_id) = make_test_db(
"GJ7c",
"GJ-7",
"research",
3_000_000,
Some("NameLocked"),
Some(600),
Some("The Assembly"),
);
let reader = CityContextReader::open(&db).expect("open");
let rs1 = reader.read_set(city_id as u64, 42).expect("first call");
let rs2 = reader.read_set(city_id as u64, 42).expect("second call");
assert_eq!(
rs1.prosperity_baseline_bps, rs2.prosperity_baseline_bps,
"prosperity_baseline_bps must be deterministic for same inputs"
);
}
}
+8 -3
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::bps::log10_floor;
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
@@ -20,15 +21,19 @@ use crate::simulation::generator::{DistrictType, PoliticalArchetype};
// ---------------------------------------------------------------------------
/// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5].
///
/// Uses [`log10_floor`] — no floats, no platform-dependent rounding (D-010).
/// Integer division of `pop / 1_000_000` floors toward zero for positive values,
/// matching the semantics of `floor(pop / 1_000_000.0)`.
pub fn population_tier(population: i64) -> u8 {
if population <= 0 {
return 0;
}
let ratio = population as f64 / 1_000_000.0;
if ratio <= 0.0 {
let ratio = (population as u64) / 1_000_000;
if ratio == 0 {
return 0;
}
let tier = ratio.log10().floor() as i32;
let tier = log10_floor(ratio) as i32;
tier.clamp(0, 5) as u8
}
+96 -19
View File
@@ -30,8 +30,9 @@ use crossbeam_channel::{Receiver, Sender};
use crate::atlas::body_world_state::BodyWorldState;
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
use crate::atlas::skeleton_gen::generate_skeleton;
use crate::seed::SeedChain;
use crate::simulation::generator::DistrictWorldState;
use crate::simulation::generator::{CityGenerationContext, DistrictWorldState};
// ---------------------------------------------------------------------------
// Priority
@@ -68,7 +69,35 @@ pub enum GenWorkItem {
body_seed: SeedChain,
},
/// Generate a Phase 1 DistrictSkeleton for this city.
GenerateSkeleton { city_id: u64 },
///
/// `context` is the D-199 economic read-set pre-resolved at dispatch time.
/// All 6 required fields must be populated before this item is submitted
/// (D-199: "Missing fields abort the task … generation does not proceed with
/// partial context").
///
/// `body_id` routes the resulting `SkeletonGenerated` completion into the
/// correct `BodyWorldState` cache entry (D-230).
///
/// `district_id` is the stable content-addressable id for the generated
/// district (keyed by city position + world seed).
///
/// `economic_role`, `population`, and `founding_age_years` are D-199 fields
/// carried alongside the context because `generate_skeleton` accepts them as
/// separate parameters (its signature is not changed by this ticket).
GenerateSkeleton {
city_id: u64,
body_id: String,
/// D-199 economic read-set + all other context fields.
context: Box<CityGenerationContext>,
/// Stable content-addressable district id (D-194/D-230).
district_id: u64,
/// District-level seed chain (D-224).
chain: SeedChain,
// D-199 raw fields passed to generate_skeleton separately.
economic_role: String,
population: i64,
founding_age_years: u32,
},
/// Pre-fill a chunk in an existing district.
FillChunk {
district_id: u64,
@@ -339,13 +368,35 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
reason: format!("heightmap load failed: {e}"),
},
},
GenWorkItem::GenerateSkeleton { city_id } => {
// Stub: real skeleton generation (#957) will populate `body_id` from
// the CityGenerationContext and `state` from the plan phase (D-230).
GenWorkItem::GenerateSkeleton {
city_id,
body_id,
context,
district_id,
chain,
economic_role,
population,
founding_age_years,
} => {
// Build the Phase 1 skeleton from the pre-resolved D-199 context.
// `economic_role`, `population`, and `founding_age_years` are the
// D-199 raw fields carried alongside the context because
// `generate_skeleton` accepts them as separate parameters.
let skeleton = generate_skeleton(
context,
*population,
economic_role,
*district_id,
*founding_age_years,
*chain,
);
GenCompletion::SkeletonGenerated {
city_id: *city_id,
body_id: String::new(),
state: Box::new(DistrictWorldState::default()),
body_id: body_id.clone(),
state: Box::new(DistrictWorldState {
skeleton,
block_tags: std::collections::BTreeMap::new(),
}),
}
}
GenWorkItem::FillChunk {
@@ -401,6 +452,41 @@ mod tests {
}
}
/// Build a minimal `GenerateSkeleton` work item with a stub context.
///
/// The stub context uses Commission/Regional/Urban defaults — the same
/// values the existing skeleton_gen tests use. These tests exercise queue
/// mechanics (ordering, saturation, drain), not economic read-set content.
fn gen_skeleton(city_id: u64) -> GenWorkItem {
use crate::simulation::generator::{
BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone,
PoliticalArchetype, ProductionUbiquity, SettingType, WorldTier,
};
GenWorkItem::GenerateSkeleton {
city_id,
body_id: format!("TestBody{city_id}"),
context: Box::new(CityGenerationContext {
city_id,
political_archetype: PoliticalArchetype::Commission,
prosperity_baseline_bps: 6_000,
surrounding_biome: SettingType::Urban,
road_entry_directions: vec![],
footprint_radius_km: 5.0,
founding_orientation: FoundingOrientation::Cardinal,
world_tier: WorldTier::Regional,
morphology_zone: MorphologyZone::AlluvialPlain,
trait_selection: vec![],
dominant_bulk_class: BulkClass::NonPhysical,
dominant_production_ubiquity: ProductionUbiquity::Common,
}),
district_id: city_id * 10,
chain: SeedChain::root(42 + city_id),
economic_role: "service_mixed".to_string(),
population: 500_000,
founding_age_years: 200,
}
}
#[test]
fn submit_and_drain() {
let q = make_queue();
@@ -437,18 +523,9 @@ mod tests {
// Uses 3 threads so all items can dispatch without hitting saturation.
let q = GenerationQueue::with_threads(3);
// Using GenerateSkeleton (no dedup logic) to test ordering directly.
q.submit(
GenWorkItem::GenerateSkeleton { city_id: 1 },
GenPriority::Low,
);
q.submit(
GenWorkItem::GenerateSkeleton { city_id: 2 },
GenPriority::Immediate,
);
q.submit(
GenWorkItem::GenerateSkeleton { city_id: 3 },
GenPriority::Medium,
);
q.submit(gen_skeleton(1), GenPriority::Low);
q.submit(gen_skeleton(2), GenPriority::Immediate);
q.submit(gen_skeleton(3), GenPriority::Medium);
std::thread::sleep(Duration::from_millis(100));
let completions = q.drain_completions();
assert_eq!(completions.len(), 3);
+1
View File
@@ -7,6 +7,7 @@ pub mod attractor_matching;
pub mod block_irregularity;
pub mod body_world_state;
pub mod cascade;
pub mod city_context_reader;
pub mod district_mix;
pub mod drainage;
pub mod features;
+1 -1
View File
@@ -419,7 +419,7 @@ mod tests {
CityGenerationContext {
city_id: 1,
political_archetype: archetype,
prosperity_baseline: 0.7,
prosperity_baseline_bps: 7_000,
surrounding_biome: SettingType::Urban,
road_entry_directions: vec![0, 4],
footprint_radius_km: 10.0,
+122
View File
@@ -0,0 +1,122 @@
//! Fixed-point / basis-point helpers (D-010 integer arithmetic).
//!
//! The codebase represents fractional values (0.01.0) as **basis points**
//! (`bps`), where `10_000 bps = 1.0`. This avoids f32/f64 in determinism-
//! sensitive paths: given the same inputs, pure integer arithmetic produces
//! identical results on every platform and compiler version.
//!
//! Convention: field names carry the `_bps` suffix; raw bps values are `u32`.
//!
//! # Integer log₁₀
//!
//! [`log10_floor`] is the single reference implementation of `floor(log10(n))`
//! for positive integers. Use this everywhere a "how many orders of magnitude"
//! calculation would otherwise reach for `f64::log10`. District-mix population
//! tiers and prosperity pop-bonuses both need it.
/// Integer floor of log₁₀ for `n ≥ 1`.
///
/// Returns 0 for n = 1..9, 1 for n = 10..99, 2 for n = 100..999, and so on.
/// Panics in debug if `n == 0` (log10(0) is undefined); returns 0 in release.
///
/// No floats, no platform-dependent rounding — deterministic (D-010).
///
/// # Examples
///
/// ```
/// use server::bps::log10_floor;
/// assert_eq!(log10_floor(1), 0);
/// assert_eq!(log10_floor(9), 0);
/// assert_eq!(log10_floor(10), 1);
/// assert_eq!(log10_floor(100), 2);
/// assert_eq!(log10_floor(999), 2);
/// assert_eq!(log10_floor(1_000_000), 6);
/// ```
pub fn log10_floor(n: u64) -> u32 {
debug_assert!(n >= 1, "log10_floor: n must be ≥ 1 (got {n})");
if n == 0 {
return 0;
}
let mut v = n;
let mut result = 0u32;
while v >= 10 {
v /= 10;
result += 1;
}
result
}
/// Convert a bps value (010_000) to a clamped f32 in [0.0, 1.0].
///
/// Only call this at the edge of a system that genuinely needs f32 — the stored
/// representation stays integer. Document why f32 is needed at the call site.
#[inline]
pub fn bps_to_f32(bps: u32) -> f32 {
bps as f32 / 10_000.0
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log10_floor_single_digits() {
// 1..9 all return 0.
for n in 1u64..10 {
assert_eq!(log10_floor(n), 0, "expected 0 for n={n}");
}
}
#[test]
fn log10_floor_boundary_10() {
assert_eq!(log10_floor(9), 0);
assert_eq!(log10_floor(10), 1);
}
#[test]
fn log10_floor_boundary_100() {
assert_eq!(log10_floor(99), 1);
assert_eq!(log10_floor(100), 2);
}
#[test]
fn log10_floor_boundary_1000() {
assert_eq!(log10_floor(999), 2);
assert_eq!(log10_floor(1_000), 3);
}
#[test]
fn log10_floor_large_values() {
assert_eq!(log10_floor(999_999), 5);
assert_eq!(log10_floor(1_000_000), 6);
assert_eq!(log10_floor(9_999_999), 6);
assert_eq!(log10_floor(10_000_000), 7);
}
#[test]
fn log10_floor_very_large() {
// u64::MAX = 18_446_744_073_709_551_615 → 19 digits → floor = 19.
assert_eq!(log10_floor(u64::MAX), 19);
}
#[test]
fn bps_to_f32_midpoint() {
let f = bps_to_f32(5_000);
assert!((f - 0.5).abs() < 1e-6, "5000 bps should be 0.5, got {f}");
}
#[test]
fn bps_to_f32_full_scale() {
let f = bps_to_f32(10_000);
assert!((f - 1.0).abs() < 1e-6, "10000 bps should be 1.0, got {f}");
}
#[test]
fn bps_to_f32_zero() {
assert_eq!(bps_to_f32(0), 0.0);
}
}
+2
View File
@@ -3,6 +3,8 @@
pub mod atlas;
pub mod bookmark;
pub mod bps;
pub mod bridge;
pub mod cause_chain;
pub mod knowledge;
+5 -4
View File
@@ -485,7 +485,7 @@ pub enum BuildingEntryClass {
/// Construction era of a building block (D-229).
///
/// Derived from `founding_age_years + prosperity_baseline + seed`.
/// Derived from `founding_age_years + prosperity_baseline_bps + seed`.
/// Reads primarily as **age/wear** via the condition layer (D-217/D-198);
/// not a material-technology ladder (era = maintenance signal, not style signal).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
@@ -710,7 +710,7 @@ pub struct BuildingPropertyTag {
pub era: ConstructionEra,
/// Cause of this block's era classification.
pub era_cause: EraCause,
/// Frozen-amber condition snapshot from `prosperity_baseline` (D-197/D-217).
/// Frozen-amber condition snapshot from `prosperity_baseline_bps` (D-197/D-217).
/// The rolling condition overlay (D-198) paints over this; never mutates the tag.
pub initial_condition: crate::atlas::tile_condition::TileCondition,
/// Doors into / out of this building (D-231). At least one `Main` door.
@@ -933,8 +933,9 @@ pub struct CityGenerationContext {
/// Foreign key into atlas_city_names.id
pub city_id: u64,
pub political_archetype: PoliticalArchetype,
/// Starting economic health seed (0.01.0). Derived per D-197.
pub prosperity_baseline: f32,
/// Starting economic health seed in basis points (010_000; 10_000 = 1.0).
/// Derived per D-197. Integer to avoid f32 non-determinism (D-010).
pub prosperity_baseline_bps: u32,
pub surrounding_biome: SettingType,
/// Compass octants (0=N, 1=NE … 7=NW) where roads enter the city footprint.
pub road_entry_directions: Vec<u8>,