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>
845 lines
35 KiB
Rust
845 lines
35 KiB
Rust
//! 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(¶ms),
|
||
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"
|
||
);
|
||
}
|
||
}
|