Files
settled-reach/server/src/atlas/believability.rs
T
jpmschweitzerandClaude Fable 5 1fee6fd9da fix(simulation): PR #178 review round — H1-H4, T1, T2
H1: split_edge_at now splits length_cells geometric-distance proportionally (fixed-order f64 polyline sums, halves sum exactly to parent) — subsumes the 2-point-parent all-or-nothing bug. H2: chained-snap test (minor onto minor spur) + determinism assertion actually exercising attach_minors + direct 2-point split unit test. H3: cascade-level RailHeadFacing end-to-end assertion on a plus-shaped landmass fixture (2 deterministic degree-3 settlement junctions). H4+T2: STANDALONE_HQ_JOIN_SQL shared constant, both readers compose it; believability lockstep unit test. T1: HUB_SPACING_DIAG_PX doc cross-refs D-243 elastic seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:03:30 +02:00

845 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Believability analysis — the D-245 acceptance-gate instrument (T-1083).
//!
//! D-245 makes "the nature layers read alive *anywhere*" the deliverable of the
//! cascade's nature half. This module is the **enforcer**: given the cascade's
//! per-district output for a body, it computes the metrics that decide whether the
//! generated world reads as a living, caused place — and exposes them as a
//! serialisable [`BelievabilityReport`] for both the interactive probe
//! (`bin/aliveness_probe`) and the regression harness (`tests/believability_harness`).
//!
//! ## The lesson baked in (T-1083)
//!
//! The first naive probe metric ("what fraction of tiles are vegetated?") reported a
//! *broken, uniform* world as ALIVE — every district was the same wet forest, but
//! "vegetated %" was high everywhere. A marginal per-tile count cannot tell "alive"
//! from "uniformly dead." So this module measures two things a count cannot fake:
//!
//! - **Contrast** ([`ContrastMetrics`]) — *variation across districts*. A living world
//! has gradients (moisture, elevation, slope) and a mix of morphologies, vegetation
//! classes and ground materials; a dead one is uniform. Spread + distinct-value
//! counts catch the uniform case (T-1080: `moisture_q` = 80 everywhere → distinct 1).
//! - **Coherence** ([`CoherenceMetrics`]) — *does it render as caused?* Water zones must
//! render wet (T-1082: oceans rendered as dry land), channels must sit at/below their
//! banks (drainage monotonic), and life must actually appear where conditions allow.
//!
//! ## District tier (today)
//!
//! Per T-1083, analysis is addressed in **district** space: no production code maps a
//! world chunk → its covering [`DistrictProfile`] yet (the voxel layer is a walking
//! skeleton). The `DistrictProfile` (2 km) is the finest *authoritative* nature unit the
//! cascade produces; the voxel-derived metrics sample a representative chunk per district.
//!
//! ## Determinism
//!
//! [`analyze`] is a pure, deterministic function of `(world_seed, body_id, districts)`:
//! scalar/categorical contrast is computed over **all** districts; the voxel-derived
//! metrics sample a **seeded spread** of [`VOXEL_SAMPLE_DISTRICTS`] across the body
//! (deterministic from the world seed — D-245's "randomly-sampled locations", and
//! unbiased unlike a contiguous corner). Suitable for golden-snapshot regression.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::atlas::attractor_matching::CityRecord;
use crate::atlas::body_params_reader::BodyParamsReader;
use crate::atlas::body_world_state::BodyWorldState;
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
use crate::atlas::chunk_context::derive_chunk_context;
use crate::atlas::district_profile::DistrictProfile;
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
use crate::atlas::scale::{ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M};
use crate::atlas::voxel::{derive_voxel_column, Vegetation, Water};
use crate::seed::SeedChain;
use crate::simulation::generator::SettlementClass;
/// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk
/// (mirrors `layer_proxy::DEFAULT_SEA_LEVEL`).
const DEFAULT_SEA_LEVEL: f32 = 0.3;
/// `ocean_fraction_q` at or above this marks a district water-present (D-239 §10 uses
/// `ocean_fraction_q` as the chunk-scale water proxy).
const WATER_PRESENCE_Q: i32 = 10;
/// Districts voxel-sampled for the derived metrics — a deterministic seeded spread
/// across the body (D-245 randomly-sampled-anywhere). Scalar/categorical contrast
/// uses *all* districts (cheap).
const VOXEL_SAMPLE_DISTRICTS: usize = 64;
/// Stride over a district's representative 64 m chunk (8 → an 8×8 = 64-voxel sample).
const VOXEL_SAMPLE_STRIDE: usize = 8;
/// Chunk offsets within a district (×64 m ⇒ 128 m … 1920 m) for the mid-scale relief
/// transect (T-1081) — samples the elevation range a character crosses over the
/// district's ~2 km span (the voxel-relief band), which the single 64 m sample chunk
/// is too narrow to register.
const RELIEF_TRANSECT_CHUNK_OFFSETS: [i32; 6] = [2, 8, 14, 20, 26, 30];
// ---------------------------------------------------------------------------
// Report types (serde — golden-snapshot-able)
// ---------------------------------------------------------------------------
/// min / max / distinct-value count for one quantised district field (0..100).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct FieldStats {
pub min: i32,
pub max: i32,
pub distinct: usize,
}
impl FieldStats {
/// max − min. A spread of 0 means the field is flat across the whole body.
pub fn spread(&self) -> i32 {
self.max - self.min
}
}
/// Cross-district variation — the screen that catches uniform/dead worlds (T-1080).
/// Measures CONTRAST, never marginal per-tile counts (the T-1083 lesson).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ContrastMetrics {
pub moisture_q: FieldStats,
pub elev_q: FieldStats,
pub slope_q: FieldStats,
pub ocean_fraction_q: FieldStats,
/// Distinct `MorphologyZone` values across all districts.
pub morphology_zones: usize,
/// Distinct `VegetationClass` values across all districts.
pub vegetation_classes: usize,
/// Distinct voxel `TerrainMaterial` values across the voxel sample.
pub terrain_materials: usize,
/// Mean within-district voxel elevation *range* (metres) across a district-spanning
/// transect of the sampled land districts (T-1081). The walkable-relief screen:
/// near 0 on flat terrain (the "0–3 m, no hills" symptom), tens of metres once the
/// voxel tier carries mid-scale relief. Measured over the 0.25–2 km band a character
/// traverses — not the single 64 m sample chunk, which is too narrow to register it.
pub voxel_relief_m: i32,
/// Minimum distinct micro-habitat axis-triples (terrain × vegetation × water) found
/// in any single sampled VEGETATED-land district (T-1084, D-246); `0` = no vegetated
/// patch was sampled (barren / ocean world). The D-245 intra-class variety screen: a
/// vegetated patch must show ≥K distinct micro-features, never 100% one — near 1 on the
/// pre-mosaic uniform world ("Wetland 100%"). Barren / water patches are excluded
/// (legitimately uniform). NOTE (PR #172, C3): this is a *minimum over patches* against
/// palettes with a dominant entry (e.g. dense stand 40/90), so a single 64 m chunk
/// often sits in the dominant bucket → the provisional floor is K=2 ("no vegetated
/// patch is 100% one micro-habitat"), the achievable, meaningful bar. The estimator
/// (min vs mean/median), the final K, and same-class-only pooling are Q-123's to
/// calibrate against the enforcer's baselines.
pub micro_habitat_distinct: usize,
}
/// Coherence checks — does the world render as a *caused* place? Pass/fail counts
/// over the voxel-sampled districts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CoherenceMetrics {
/// Sampled districts with water present (`ocean_fraction_q` ≥ threshold or an
/// active channel).
pub water_districts: usize,
/// …that render at least one `Shallow`/`Deep` voxel (T-1082: oceans must be wet).
pub water_districts_wet: usize,
/// Sampled active-channel districts checked for drainage monotonicity.
pub drainage_samples: usize,
/// …where the wet voxels sit at/below the dry-land mean elevation (water runs low).
pub drainage_monotonic: usize,
/// Voxel-sampled districts checked for any vegetation.
pub vegetation_samples: usize,
/// …with at least one non-`Barren` voxel (life appears somewhere).
pub vegetated_districts: usize,
}
/// The full believability report for one body+seed — the D-245 gate's measurement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BelievabilityReport {
pub body_id: String,
pub world_seed: u64,
pub district_count: usize,
pub voxel_sampled_districts: usize,
pub contrast: ContrastMetrics,
pub coherence: CoherenceMetrics,
}
/// One advisory D-245 criterion result (thresholds are placeholders pending Q-123
/// calibration; the report exposes the raw numbers the thresholds judge).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Criterion {
pub name: &'static str,
pub pass: bool,
pub detail: String,
}
// ---------------------------------------------------------------------------
// Analysis
// ---------------------------------------------------------------------------
/// Compute the [`BelievabilityReport`] for a body's per-district cascade output.
///
/// Pure + deterministic (see module docs). `districts` is `BodyWorldState.districts`.
pub fn analyze(
world_seed: u64,
body_id: &str,
districts: &BTreeMap<DistrictPos, DistrictProfile>,
) -> BelievabilityReport {
// ── Contrast: scalar + categorical over ALL districts (cheap, no voxels) ──
let contrast_scalar = ContrastMetrics {
moisture_q: field_stats(districts.values().map(|d| d.moisture_q)),
elev_q: field_stats(districts.values().map(|d| d.elev_q)),
slope_q: field_stats(districts.values().map(|d| d.slope_q)),
ocean_fraction_q: field_stats(districts.values().map(|d| d.ocean_fraction_q)),
morphology_zones: distinct(
districts
.values()
.map(|d| format!("{:?}", d.morphology_zone)),
),
vegetation_classes: distinct(
districts
.values()
.map(|d| format!("{:?}", d.vegetation_class)),
),
terrain_materials: 0, // filled from the voxel sample below
voxel_relief_m: 0, // filled from the relief transect below
micro_habitat_distinct: 0, // filled from the per-district triple sets below
};
// ── Voxel-derived metrics over a deterministic seeded spread of districts ──
// Sampling must span the whole body, not a contiguous prefix: BTreeMap order is
// a spatial corner (e.g. an all-ocean edge), which would report a forested world
// as "0 vegetated". A seeded spread is both unbiased and matches D-245's
// "randomly-sampled locations across the body" wording.
let mut terrain_set: BTreeSet<String> = BTreeSet::new();
let mut coh = CoherenceMetrics::default();
let mut sampled = 0usize;
// Mid-scale relief accumulators (T-1081): sum of per-district transect ranges over
// land districts, and the count contributing, → mean within-district relief.
let (mut relief_range_sum, mut relief_district_count) = (0i64, 0i64);
// Intra-class variety (T-1084): the minimum distinct micro-habitat triples over the
// sampled vegetated-land districts — the worst patch, per the D-245 per-patch floor.
let mut min_habitat_distinct = usize::MAX;
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
for idx in sample_indices(world_seed, keys.len(), VOXEL_SAMPLE_DISTRICTS) {
let dp = keys[idx];
let prof = &districts[&dp];
sampled += 1;
let chunk = district_centre_chunk(dp);
let ctx = derive_chunk_context(world_seed, body_id, prof, chunk, None);
let mut any_wet = false;
let mut any_veg = false;
// Elevation accumulators for the drainage-monotonicity proxy.
let (mut wet_elev_sum, mut wet_n) = (0i64, 0i64);
let (mut dry_elev_sum, mut dry_n) = (0i64, 0i64);
let mut habitat_triples: BTreeSet<String> = BTreeSet::new();
for sx in (0..CHUNK_M as usize).step_by(VOXEL_SAMPLE_STRIDE) {
for sy in (0..CHUNK_M as usize).step_by(VOXEL_SAMPLE_STRIDE) {
let tx = chunk.0 * CHUNK_M + sx as i32;
let ty = chunk.1 * CHUNK_M + sy as i32;
let col = derive_voxel_column(world_seed, body_id, prof, &ctx, tx, ty);
terrain_set.insert(format!("{:?}", col.terrain));
habitat_triples.insert(format!(
"{:?}/{:?}/{:?}",
col.terrain, col.vegetation, col.water
));
if col.vegetation != Vegetation::Barren {
any_veg = true;
}
if col.water == Water::Dry {
dry_elev_sum += col.elevation_m as i64;
dry_n += 1;
} else {
any_wet = true;
wet_elev_sum += col.elevation_m as i64;
wet_n += 1;
}
}
}
// Intra-class micro-habitat variety (T-1084): distinct habitat triples in this
// patch; the report tracks the worst (minimum) vegetated patch.
if any_veg {
min_habitat_distinct = min_habitat_distinct.min(habitat_triples.len());
}
// Water coherence (T-1082): water-present districts must render wet voxels.
if prof.ocean_fraction_q >= WATER_PRESENCE_Q || ctx.has_active_channel {
coh.water_districts += 1;
if any_wet {
coh.water_districts_wet += 1;
}
}
// Vegetation presence.
coh.vegetation_samples += 1;
if any_veg {
coh.vegetated_districts += 1;
}
// Drainage monotonicity — LAND channels only (a river must sit at/below its
// banks). Water-body districts (ocean/lake/tidal) are all-wet at sea level with
// no dry banks, so they are excluded: counting them would pass the check
// vacuously (dry_n == 0) and inflate the metric. The check therefore requires
// both wet channel voxels AND dry bank voxels in the sample.
if ctx.has_active_channel && prof.ocean_fraction_q < WATER_PRESENCE_Q {
coh.drainage_samples += 1;
let wet_below_land =
wet_n > 0 && dry_n > 0 && wet_elev_sum / wet_n <= dry_elev_sum / dry_n;
if wet_below_land {
coh.drainage_monotonic += 1;
}
}
// Mid-scale relief transect (T-1081): the dry-ground elevation range across the
// district's ~2 km span — where the voxel tier's mid-scale relief lives. The
// single 64 m sample chunk above is narrower than the relief's 0.25–2 km band,
// so it would read flat even on rolling terrain; this transect is the screen.
// Only land-dominant districts count (≥3 of 6 transect points dry) — coastal
// districts with one stray dry point would otherwise contribute a 0 range and
// bias the mean toward "flat" on an ocean world (a measurement artifact, not
// flat land).
let (mut relief_lo, mut relief_hi, mut relief_dry) = (i32::MAX, i32::MIN, 0i32);
for &co in &RELIEF_TRANSECT_CHUNK_OFFSETS {
let cpos = (
dp.0 * CHUNKS_PER_DISTRICT + co,
dp.1 * CHUNKS_PER_DISTRICT + co,
);
let cctx = derive_chunk_context(world_seed, body_id, prof, cpos, None);
let tx = cpos.0 * CHUNK_M + CHUNK_M / 2;
let ty = cpos.1 * CHUNK_M + CHUNK_M / 2;
let col = derive_voxel_column(world_seed, body_id, prof, &cctx, tx, ty);
if col.water == Water::Dry {
relief_lo = relief_lo.min(col.elevation_m);
relief_hi = relief_hi.max(col.elevation_m);
relief_dry += 1;
}
}
if relief_dry >= 3 {
relief_range_sum += (relief_hi - relief_lo) as i64;
relief_district_count += 1;
}
}
let voxel_relief_m = if relief_district_count > 0 {
(relief_range_sum / relief_district_count) as i32
} else {
0
};
let micro_habitat_distinct = if min_habitat_distinct == usize::MAX {
0
} else {
min_habitat_distinct
};
BelievabilityReport {
body_id: body_id.to_string(),
world_seed,
district_count: districts.len(),
voxel_sampled_districts: sampled,
contrast: ContrastMetrics {
terrain_materials: terrain_set.len(),
voxel_relief_m,
micro_habitat_distinct,
..contrast_scalar
},
coherence: coh,
}
}
/// Evaluate the report against the D-245 criteria (advisory — thresholds are
/// placeholders pending Q-123 calibration). Returns one [`Criterion`] per check.
pub fn evaluate_criteria(r: &BelievabilityReport) -> Vec<Criterion> {
let c = &r.contrast;
let h = &r.coherence;
let pct = |num: usize, den: usize| if den == 0 { 100 } else { num * 100 / den };
vec![
Criterion {
name: "moisture gradient",
pass: c.moisture_q.distinct >= 3,
detail: format!(
"moisture_q distinct={} spread={}",
c.moisture_q.distinct,
c.moisture_q.spread()
),
},
Criterion {
name: "elevation relief",
pass: c.elev_q.spread() >= 10,
detail: format!("elev_q spread={}", c.elev_q.spread()),
},
Criterion {
name: "voxel relief",
pass: c.voxel_relief_m >= 8,
detail: format!("mean within-district relief {} m", c.voxel_relief_m),
},
Criterion {
name: "morphology variety",
pass: c.morphology_zones >= 2,
detail: format!("{} distinct zones", c.morphology_zones),
},
Criterion {
name: "terrain-material variety",
pass: c.terrain_materials >= 2,
detail: format!("{} distinct materials", c.terrain_materials),
},
Criterion {
name: "intra-class variety",
// 0 = no vegetated patch sampled (barren/ocean world — legitimately nothing to
// vary). Otherwise the provisional floor is K=2 = "no vegetated patch is 100%
// one micro-habitat" — the achievable bar for a min-over-patches estimator;
// ≥3 + the estimator are Q-123's to calibrate (PR #172, C3).
pass: c.micro_habitat_distinct == 0 || c.micro_habitat_distinct >= 2,
detail: format!(
"min {} distinct micro-habitats per vegetated patch (K≥2 provisional; Q-123 calibrates)",
c.micro_habitat_distinct
),
},
Criterion {
name: "water renders wet",
pass: pct(h.water_districts_wet, h.water_districts) >= 50,
detail: format!(
"{}/{} water districts render wet",
h.water_districts_wet, h.water_districts
),
},
Criterion {
name: "drainage monotonic",
pass: pct(h.drainage_monotonic, h.drainage_samples) >= 80,
detail: format!(
"{}/{} channel districts monotonic",
h.drainage_monotonic, h.drainage_samples
),
},
Criterion {
name: "vegetation present",
pass: pct(h.vegetated_districts, h.vegetation_samples) >= 25,
detail: format!(
"{}/{} sampled districts vegetated",
h.vegetated_districts, h.vegetation_samples
),
},
]
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn field_stats(vals: impl Iterator<Item = i32>) -> FieldStats {
let mut set: BTreeSet<i32> = BTreeSet::new();
for v in vals {
set.insert(v);
}
match (set.iter().next(), set.iter().next_back()) {
(Some(&min), Some(&max)) => FieldStats {
min,
max,
distinct: set.len(),
},
_ => FieldStats::default(),
}
}
fn distinct(vals: impl Iterator<Item = String>) -> usize {
vals.collect::<BTreeSet<String>>().len()
}
/// The chunk at the centre of a district (32 chunks/district) — the representative
/// chunk the voxel-derived metrics sample.
fn district_centre_chunk(dp: DistrictPos) -> ChunkPos {
(
dp.0 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
dp.1 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
)
}
/// Deterministic seeded spread of `take` distinct indices in `0..n` — a reproducible
/// "random" sample across the whole district list (D-245's randomly-sampled-anywhere
/// requirement), unbiased unlike a contiguous prefix. Returns all of `0..n` when
/// `n <= take`.
///
/// Partial Fisher-Yates over a splitmix64 stream keyed on the world seed: O(n),
/// **guaranteed to terminate**, distinct by construction, sorted for stable
/// (BTreeMap-order) consumption. (A rejection-sampling loop would stall as `n → take`,
/// since splitmix64 output is not a permutation — the hazard this replaces.)
fn sample_indices(world_seed: u64, n: usize, take: usize) -> Vec<usize> {
if n <= take {
return (0..n).collect();
}
let mut idx: Vec<usize> = (0..n).collect();
let mut state = world_seed;
for i in 0..take {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
let j = i + (z % (n - i) as u64) as usize;
idx.swap(i, j);
}
let mut chosen = idx[..take].to_vec();
chosen.sort_unstable();
chosen
}
// ---------------------------------------------------------------------------
// Loader — resolve committed data + run the real cascade (shared by the probe
// binary and the regression harness, so both measure the same thing)
// ---------------------------------------------------------------------------
/// FNV-1a 64-bit — a stable, dependency-free `seed string → u64` so `--seed yolo`
/// (and the harness's fixed seeds) map deterministically and reproducibly across
/// runs (unlike std's randomised `RandomState`). D-245 wants a string seed; this is it.
pub fn seed_to_u64(seed: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in seed.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
/// Resolve the committed inputs for `body_id` and run the real deterministic cascade
/// (through the road graph), returning the per-body world state to [`analyze`].
///
/// `Err` if `systems.db` or the body's `heightmap.png` cannot be found, or the body
/// has no params — callers (the regression harness) may *skip* on that rather than
/// fail, so this is the one believability entry point that does I/O. Tries both the
/// repo-root and `server/`-relative paths so it works from either CWD.
pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState, String> {
let db = first_existing(&["server/data/systems.db", "data/systems.db"])
.ok_or_else(|| "systems.db not found".to_string())?;
let hm_path =
find_heightmap(body_id).ok_or_else(|| format!("no heightmap.png for {body_id}"))?;
let params = BodyParamsReader::open(&db)
.map_err(|e| format!("open systems.db: {e:?}"))?
.read_body_params(body_id)
.map_err(|e| format!("read body params: {e:?}"))?;
let cities = read_cities(&db, body_id)?;
let hm = load_heightmap_png(&hm_path, body_id, DEFAULT_SEA_LEVEL)
.map_err(|e| format!("load heightmap: {e:?}"))?;
let working = if hm.width > GRID_W || hm.height > GRID_H {
hm.downsample(GRID_W, GRID_H)
} else {
hm
};
let snapshot = run_cascade_from_heightmap(
SeedChain::for_body(world_seed, body_id),
working,
&cities,
None,
Some(&params),
CascadeLayer::RoadGraph,
);
Ok(snapshot.into_body_world_state())
}
/// Read a body's settlements from `atlas_city_names`, mirroring
/// `CityContextReader::read_body_settlements` (keep the two in lockstep):
/// baked `settlement_class` is honoured (D-242/T-1075 bakes it on every row;
/// unknown/NULL falls back to `PopulationBudget`), and the T-1076
/// `is_standalone_hq` flag comes from the shared
/// [`STANDALONE_HQ_JOIN_SQL`](crate::atlas::city_context_reader) join
/// fragment — single source of truth for the join shape (PR #178 T2).
fn read_cities(db: &PathBuf, body_id: &str) -> Result<Vec<CityRecord>, String> {
use crate::atlas::city_context_reader::STANDALONE_HQ_JOIN_SQL;
let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?;
let sql = format!(
"SELECT acn.id, acn.name, COALESCE(acn.economic_role,'service_mixed'),
COALESCE(acn.population,0), COALESCE(acn.kind,'city'),
acn.settlement_class, c.corp_id IS NOT NULL
FROM atlas_city_names AS acn
{STANDALONE_HQ_JOIN_SQL}
WHERE acn.body_id = ?1 ORDER BY acn.id"
);
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("prepare city query: {e}"))?;
let rows = stmt
.query_map([body_id], |r| {
let kind: String = r.get(4)?;
let sclass: Option<String> = r.get(5)?;
let settlement_class = match sclass.as_deref() {
Some("NameLocked") => SettlementClass::NameLocked,
Some("EconomicTriggered") => SettlementClass::EconomicTriggered,
Some("OrganicGrowth") => SettlementClass::OrganicGrowth,
// "PopulationBudget", NULL, or unknown → the default class.
_ => SettlementClass::PopulationBudget,
};
Ok(CityRecord {
city_id: r.get::<_, i64>(0)? as u64,
name: r.get(1)?,
settlement_class,
economic_role: r.get(2)?,
population: r.get(3)?,
is_capital: kind == "capital",
is_standalone_hq: r.get(6)?,
})
})
.map_err(|e| format!("city query: {e}"))?
.filter_map(|r| match r {
Ok(c) => Some(c),
Err(e) => {
// A dropped city changes downstream placements, so never drop silently.
eprintln!("[believability] skipped a malformed city row for {body_id}: {e}");
None
}
})
.collect();
Ok(rows)
}
/// Glob `*/bodies/<body>/heightmap.png` under the committed wiki tree (either CWD).
fn find_heightmap(body_id: &str) -> Option<PathBuf> {
for base in ["wiki/star-systems", "../wiki/star-systems"] {
let Ok(systems) = std::fs::read_dir(PathBuf::from(base)) else {
continue;
};
for sys in systems.flatten() {
let cand = sys
.path()
.join("bodies")
.join(body_id)
.join("heightmap.png");
if cand.is_file() {
return Some(cand);
}
}
}
None
}
fn first_existing(paths: &[&str]) -> Option<PathBuf> {
paths.iter().map(PathBuf::from).find(|p| p.is_file())
}
// ---------------------------------------------------------------------------
// Tests — the instrument's own regression guard: it must tell alive from dead
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::district_profile::{
GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
};
use crate::atlas::scale::BasinDirection;
use crate::simulation::generator::MorphologyZone;
fn district(
zone: MorphologyZone,
elev_q: i32,
slope_q: i32,
moisture_q: i32,
ocean_fraction_q: i32,
veg: VegetationClass,
) -> DistrictProfile {
DistrictProfile {
morphology_zone: zone,
tectonic_class: TectonicClass::Stable,
glaciation_grade: GlaciationGrade::None,
precipitation_class: PrecipitationClass::Temperate,
slope_q,
elev_q,
ocean_fraction_q,
river_threshold: 200,
temperature_c: Some(15.0),
moisture_q,
vegetation_class: veg,
basin_direction: BasinDirection::South,
}
}
/// A uniform world — every district identical — must FAIL the contrast criteria.
/// This is the case the naive per-tile metric called ALIVE.
#[test]
fn uniform_world_fails_contrast_criteria() {
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
for x in 0..8 {
for y in 0..8 {
districts.insert(
(x, y),
district(
MorphologyZone::AlluvialPlain,
20,
0,
80,
0,
VegetationClass::Forest,
),
);
}
}
let report = analyze(42, "uniform", &districts);
assert_eq!(report.contrast.moisture_q.distinct, 1, "moisture is flat");
assert_eq!(report.contrast.morphology_zones, 1);
assert_eq!(report.contrast.elev_q.spread(), 0);
let crit = evaluate_criteria(&report);
let failed = |name: &str| {
crit.iter()
.find(|c| c.name == name)
.is_some_and(|c| !c.pass)
};
assert!(failed("moisture gradient"), "uniform moisture must fail");
assert!(failed("morphology variety"), "single zone must fail");
assert!(failed("elevation relief"), "flat must fail");
}
/// A varied world — gradients + a mix of zones — passes the contrast criteria the
/// uniform world failed. Proves the metric discriminates (the T-1083 point).
#[test]
fn varied_world_passes_contrast_criteria() {
let zones = [
MorphologyZone::AlluvialPlain,
MorphologyZone::MeanderReach,
MorphologyZone::CliffCoast,
MorphologyZone::DuneStrand,
];
let vegs = [
VegetationClass::Barren,
VegetationClass::Scrub,
VegetationClass::Forest,
VegetationClass::RiparianThicket,
];
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
for x in 0..8 {
for y in 0..8 {
let i = (x * 8 + y) as usize;
districts.insert(
(x, y),
district(
zones[i % zones.len()],
(i as i32 * 7) % 100, // varied elevation
(i as i32 * 3) % 60, // varied slope
(i as i32 * 11) % 100, // varied moisture
(i as i32 * 13) % 40,
vegs[i % vegs.len()],
),
);
}
}
let report = analyze(42, "varied", &districts);
assert!(report.contrast.moisture_q.distinct >= 3);
assert!(report.contrast.morphology_zones >= 2);
assert!(report.contrast.elev_q.spread() >= 10);
let crit = evaluate_criteria(&report);
let passed = |name: &str| crit.iter().find(|c| c.name == name).is_some_and(|c| c.pass);
assert!(passed("moisture gradient"));
assert!(passed("morphology variety"));
assert!(passed("elevation relief"));
}
#[test]
fn analyze_is_deterministic() {
let mut districts: BTreeMap<DistrictPos, DistrictProfile> = BTreeMap::new();
districts.insert(
(0, 0),
district(
MorphologyZone::MeanderReach,
30,
10,
55,
15,
VegetationClass::Forest,
),
);
districts.insert(
(1, 0),
district(
MorphologyZone::CliffCoast,
70,
40,
20,
60,
VegetationClass::Scrub,
),
);
let a = analyze(7, "GJ1c", &districts);
let b = analyze(7, "GJ1c", &districts);
assert_eq!(a, b);
}
/// PR #178 H4 — the believability settlement reader honours baked
/// settlement_class (D-242/T-1075) and derives is_standalone_hq from the
/// shared corporations join, in lockstep with
/// `CityContextReader::read_body_settlements` (T2). Mirrors that module's
/// synthetic-DB fixture pattern.
#[test]
fn read_cities_honours_baked_class_and_standalone_hq_flag() {
use crate::simulation::generator::SettlementClass;
let path =
std::env::temp_dir().join(format!("sr_believability_cities_{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let conn = rusqlite::Connection::open(&path).expect("create db");
conn.execute_batch(
"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,
population INTEGER NOT NULL,
settlement_class TEXT
);
CREATE TABLE corporations (
corp_id TEXT PRIMARY KEY,
proper_name TEXT NOT NULL,
hq_placement TEXT,
headquarters_body TEXT
);",
)
.expect("create tables");
for (name, pop, sclass) in [
("Hero City", 2_000_000i64, Some("NameLocked")),
("Plain Town", 300_000, Some("PopulationBudget")),
("Weird Row", 50_000, Some("TotallyBogus")), // unknown → default
("Gate Corporation", 900_000, Some("PopulationBudget")),
] {
conn.execute(
"INSERT INTO atlas_city_names
(body_id, name, economic_role, population, settlement_class)
VALUES ('PlanetX', ?1, 'manufacturing', ?2, ?3)",
rusqlite::params![name, pop, sclass],
)
.expect("insert city");
}
conn.execute(
"INSERT INTO corporations (corp_id, proper_name, hq_placement, headquarters_body)
VALUES ('gate-corporation', 'Gate Corporation', 'Standalone', 'PlanetX')",
[],
)
.expect("insert corp");
drop(conn);
let cities = read_cities(&path, "PlanetX").expect("read");
assert_eq!(cities.len(), 4);
let by_name = |n: &str| cities.iter().find(|c| c.name == n).unwrap();
assert_eq!(
by_name("Hero City").settlement_class,
SettlementClass::NameLocked,
"baked NameLocked must be honoured, not flattened to the default"
);
assert_eq!(
by_name("Plain Town").settlement_class,
SettlementClass::PopulationBudget
);
assert_eq!(
by_name("Weird Row").settlement_class,
SettlementClass::PopulationBudget,
"unknown class text falls back to the default"
);
assert!(
by_name("Gate Corporation").is_standalone_hq,
"registered Standalone corp row is flagged via the shared join"
);
assert!(
!by_name("Plain Town").is_standalone_hq,
"unregistered rows stay unflagged"
);
}
}