From 91a079718b9f95ea280f6908fd03d759a5656bb4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 14:55:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20believability=20test=20prot?= =?UTF-8?q?ocol=20=E2=80=94=20D-245=20enforcer=20(T-1083)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the throwaway aliveness probe into a committed, repeatable believability protocol — the instrument that found T-1080/T-1081/T-1082. - atlas/believability.rs (new): BelievabilityReport (serde) + analyze() computing CONTRAST (per-field min/max/distinct for moisture/elev/slope/ocean over all districts; distinct morphology zones / vegetation classes / terrain materials) and COHERENCE (water-renders-wet, drainage-monotonic, vegetation-present) — never marginal per-tile counts (the lesson: that called a broken uniform world ALIVE). evaluate_criteria() = advisory D-245 checks; cascade_for_body()/seed_to_u64() loader shared by the bin and the harness. Unit tests prove the metric tells a uniform world (fails) from a varied one (passes) + determinism. - bin/aliveness_probe.rs: refactored to a thin CLI over the module — prints the body-level report + advisory D-245 criteria, drops the naive per-tile verdict. - tests/believability_harness.rs (+ golden): runs the real cascade for Arbour (temperate/ocean) + Edict (frozen/ice), asserts determinism, golden-snapshots the reports (regression — updates when T-1080/T-1082 land), advisory criteria with BELIEVABILITY_STRICT=1 to fail on unmet D-245 criteria. Skips if committed data absent. x86_64 golden (cascade has f32 warp paths). Baseline @ believability-v1: Arbour 3/7, Edict 3/7 criteria pass — moisture gradient, water-renders-wet, drainage all FAIL (T-1080/T-1082); Edict vegetation 0/64 (blank tundra — the frozen 'reads dead' case D-245 targets). PNG layer maps (ticket item 3) deferred — explicitly optional; the contrast + coherence metrics are the core enforcer. clippy --all-targets -D warnings clean; 1575 lib tests + the harness pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 2 + server/src/atlas/believability.rs | 595 ++++++++++++++++++++++ server/src/atlas/mod.rs | 1 + server/src/bin/aliveness_probe.rs | 304 ++++------- server/tests/believability_harness.rs | 139 +++++ server/tests/golden/believability.json | 80 +++ 7 files changed, 905 insertions(+), 218 deletions(-) create mode 100644 server/src/atlas/believability.rs create mode 100644 server/tests/believability_harness.rs create mode 100644 server/tests/golden/believability.json diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index e2f2ecdcc..e48ea88eb 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -2686,3 +2686,5 @@ OUT OF SCOPE (other T-959 children): interstitial street/open-space fill + D-235 Tests: determinism, sparsity-vs-dense-volume (budget proxy — no flaky wall-clock asserts), wall/floor/roof correctness, basement z-origin, sub-chunk clipping, full submit→drain→ChunkFilled round-trip. cargo test (1571 lib) + clippy -D warnings + fmt all green.', NULL, '2026-06-27 21:34:18', '2026-06-27 21:34:18.369', '2026-06-27 21:34:18.369', NULL, '35133a94263116cb59fcaee570ea4630', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'status', 'in_progress', 'review', NULL, '2026-06-28 06:28:50', '2026-06-28 06:28:50.937', '2026-06-28 06:28:50.937', NULL, 'ee80d51608116d1aa23a8b641af13c22', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNSRYAE6MPE7720360WM4', 'status', 'review', 'done', NULL, '2026-06-28 09:12:18', '2026-06-28 09:12:18.668', '2026-06-28 09:12:18.668', NULL, 'd391d02650772178343c85f5e45ad857', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJZG3WFPQ3VXBFQFM4YEWW', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 09:13:25', '2026-06-28 09:13:25.900', '2026-06-28 09:13:25.900', NULL, '36d4aaf36b3426a4cff55211fe6b5dfb', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJZG3WFPQ3VXBFQFM4YEWW', 'status', 'in_progress', 'in_progress', NULL, '2026-06-28 09:15:04', '2026-06-28 09:15:04.069', '2026-06-28 09:15:04.069', NULL, '0f8d5bb873bbdc5dfbd5cbe73e94d7f8', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index ffb00b70e..3b3fb2242 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -3619,3 +3619,5 @@ SCOPE DECISION (deviates from the stale T-959 refinement note): the on-demand st OUT OF SCOPE (other T-959 children): interstitial street/open-space fill + D-235 surface material vocabulary (WallMaterial/RoofForm/StreetSurface) = T-988; condition overlay = T-999. Tests: determinism, sparsity-vs-dense-volume (budget proxy — no flaky wall-clock asserts), wall/floor/roof correctness, basement z-origin, sub-chunk clipping, full submit→drain→ChunkFilled round-trip. cargo test (1571 lib) + clippy -D warnings + fmt all green.', 'done', 'high', NULL, 'server', 'D-230', '2026-05-25 19:11:17', '2026-06-28 09:12:18.667', NULL, 'b56566b00340780e24ed21cdb69026d1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJZG3WFPQ3VXBFQFM4YEWW', 'story', '06FGSJNP8YM537DJCC82ZD6544', 'Repeatable believability test protocol — cross-district contrast + coherence metrics (promote aliveness_probe)', 'Promote the throwaway probe (server/src/bin/aliveness_probe.rs) into a committed, repeatable believability test protocol — the instrument that found T-1080/T-1081/T-1082. For a body+seed it runs the deterministic cascade and emits: (1) cross-district CONTRAST/variance metrics — moisture, elevation/relief, slope, morphology-zone diversity, terrain-material diversity, vegetation gradient — the screen that catches ''uniform/dead'' worlds; (2) COHERENCE checks with pass/fail — water zones must render wet, vegetation must correlate with moisture/water-distance/slope/aspect, drainage monotonic; (3) optional per-layer PNG maps (a stopgap for the T-960 Atlas viewer). LESSON BAKED IN: the first naive ''is it vegetated'' metric reported a broken uniform world as ALIVE — so the protocol MUST measure CONTRAST + COHERENCE across samples, never marginal per-tile counts. Make it runnable per-body, deterministic (string seed -> u64), snapshot a baseline for regression. This is the D-245 ENFORCER. NOTE: today it samples at the DISTRICT tier because no production code maps a world chunk -> covering DistrictProfile yet (voxel layer is a walking skeleton). Distinct from T-1031 (binding-LAW harness); believability sibling. Relates to T-960.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-28 05:55:19.712', '2026-06-28 09:13:25.900', NULL, 'bea5cbfc4e768e6e3188544f4dd9535c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSJZG3WFPQ3VXBFQFM4YEWW', 'story', '06FGSJNP8YM537DJCC82ZD6544', 'Repeatable believability test protocol — cross-district contrast + coherence metrics (promote aliveness_probe)', 'Promote the throwaway probe (server/src/bin/aliveness_probe.rs) into a committed, repeatable believability test protocol — the instrument that found T-1080/T-1081/T-1082. For a body+seed it runs the deterministic cascade and emits: (1) cross-district CONTRAST/variance metrics — moisture, elevation/relief, slope, morphology-zone diversity, terrain-material diversity, vegetation gradient — the screen that catches ''uniform/dead'' worlds; (2) COHERENCE checks with pass/fail — water zones must render wet, vegetation must correlate with moisture/water-distance/slope/aspect, drainage monotonic; (3) optional per-layer PNG maps (a stopgap for the T-960 Atlas viewer). LESSON BAKED IN: the first naive ''is it vegetated'' metric reported a broken uniform world as ALIVE — so the protocol MUST measure CONTRAST + COHERENCE across samples, never marginal per-tile counts. Make it runnable per-body, deterministic (string seed -> u64), snapshot a baseline for regression. This is the D-245 ENFORCER. NOTE: today it samples at the DISTRICT tier because no production code maps a world chunk -> covering DistrictProfile yet (voxel layer is a walking skeleton). Distinct from T-1031 (binding-LAW harness); believability sibling. Relates to T-960.', 'in_progress', 'high', NULL, 'server', 'D-239', '2026-06-28 05:55:19.712', '2026-06-28 09:15:04.069', NULL, 'a4a2d68ab98dec38c833616b5e7b14e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/server/src/atlas/believability.rs b/server/src/atlas/believability.rs new file mode 100644 index 000000000..f92acc23a --- /dev/null +++ b/server/src/atlas/believability.rs @@ -0,0 +1,595 @@ +//! 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 the first [`VOXEL_SAMPLE_DISTRICTS`] in `BTreeMap` order (sorted → +//! stable). 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 — the first N in `BTreeMap` order +/// (deterministic). 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; + +// --------------------------------------------------------------------------- +// 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, +} + +/// 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, +) -> 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-derived metrics over a deterministic subset of districts ──────── + let mut terrain_set: BTreeSet = BTreeSet::new(); + let mut coh = CoherenceMetrics::default(); + let mut sampled = 0usize; + + for (dp, prof) in districts.iter().take(VOXEL_SAMPLE_DISTRICTS) { + 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); + + 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)); + 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; + } + } + } + + // 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 (active-channel districts): a channel must render wet + // AND its water must sit at/below the dry-land mean elevation. A district whose + // channel renders no water (the T-1082 failure) fails this too — vacuously + // non-monotonic, which is the believability-correct verdict. + if ctx.has_active_channel { + coh.drainage_samples += 1; + let wet_below_land = + wet_n > 0 && (dry_n == 0 || wet_elev_sum / wet_n <= dry_elev_sum / dry_n.max(1)); + if wet_below_land { + coh.drainage_monotonic += 1; + } + } + } + + BelievabilityReport { + body_id: body_id.to_string(), + world_seed, + district_count: districts.len(), + voxel_sampled_districts: sampled, + contrast: ContrastMetrics { + terrain_materials: terrain_set.len(), + ..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 { + 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: "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: "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) -> FieldStats { + let mut set: BTreeSet = 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) -> usize { + vals.collect::>().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, + ) +} + +// --------------------------------------------------------------------------- +// 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 { + 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`. Per-city `settlement_class` is +/// unset in the committed pool, so it defaults to `PopulationBudget` (what the +/// city-context read-set assumes). +fn read_cities(db: &PathBuf, body_id: &str) -> Result, String> { + let conn = rusqlite::Connection::open(db).map_err(|e| format!("open db: {e}"))?; + let mut stmt = conn + .prepare( + "SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0) + FROM atlas_city_names WHERE body_id = ?1 ORDER BY id", + ) + .map_err(|e| format!("prepare city query: {e}"))?; + let rows = stmt + .query_map([body_id], |r| { + Ok(CityRecord { + city_id: r.get::<_, i64>(0)? as u64, + name: r.get(1)?, + settlement_class: SettlementClass::PopulationBudget, + economic_role: r.get(2)?, + population: r.get(3)?, + }) + }) + .map_err(|e| format!("city query: {e}"))? + .filter_map(Result::ok) + .collect(); + Ok(rows) +} + +/// Glob `*/bodies//heightmap.png` under the committed wiki tree (either CWD). +fn find_heightmap(body_id: &str) -> Option { + 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 { + 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 = 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 = 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 = 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); + } +} diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index bd9569dd0..b8359f660 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -4,6 +4,7 @@ //! populating BodyWorldState (D-203). They are never called on the main tick thread. pub mod attractor_matching; +pub mod believability; pub mod block_irregularity; pub mod body_params_reader; pub mod body_world_state; diff --git a/server/src/bin/aliveness_probe.rs b/server/src/bin/aliveness_probe.rs index 18dacd6be..634fc0742 100644 --- a/server/src/bin/aliveness_probe.rs +++ b/server/src/bin/aliveness_probe.rs @@ -1,96 +1,52 @@ -//! Aliveness probe — sample what the generated world *yields* at locations on a -//! body, to judge whether nature reads as a living, caused place (not just -//! law-correct). Exploratory tooling, not a test. -//! -//! It runs the real deterministic cascade (`run_cascade_from_heightmap`, up to -//! the road graph) for one body + seed, then probes a set of locations and -//! prints, for each, the authoritative per-area nature summary (`DistrictProfile` -//! — real elevation/slope/drainage/morphology/vegetation/climate derived from the -//! committed heightmap) plus a derived `VoxelColumn` sample (terrain / vegetation / -//! water / seasonal-cover / elevation) and a one-line "does it read alive" verdict. +//! Aliveness probe — the interactive front-end of the D-245 believability enforcer +//! (T-1083). It runs the real deterministic cascade for one body+seed and prints the +//! body-level [`BelievabilityReport`](settled_reach_server::atlas::believability) — +//! contrast + coherence, the verdict on whether nature reads alive — followed by a few +//! per-location samples for colour. The load+cascade+analyze logic lives in +//! `atlas::believability` so this binary and `tests/believability_harness` measure the +//! exact same thing. //! //! ## Why the *district* tier //! -//! As of this writing the voxel layer (T-1028/T-1029) is a walking skeleton: no -//! production code maps a world chunk → its covering `DistrictProfile` yet (every -//! `derive_voxel_column` caller is a test). The `DistrictProfile` (2 km cell) is -//! therefore the finest *authoritative* nature unit the cascade actually produces, -//! so probes are addressed in district space; the voxel sample inside each is -//! illustrative ground-truth of what that district's 1 m tiles derive to. +//! No production code maps a world chunk → its covering `DistrictProfile` yet (the voxel +//! layer is a walking skeleton), so analysis is addressed in district space; the voxel +//! sample inside each district is illustrative ground-truth of what its 1 m tiles derive to. //! //! ```sh //! cargo run --bin aliveness_probe -- --body GJ338Bd --seed yolo --probes 5 //! ``` use std::collections::BTreeMap; -use std::path::PathBuf; -use settled_reach_server::atlas::attractor_matching::CityRecord; -use settled_reach_server::atlas::body_params_reader::BodyParamsReader; -use settled_reach_server::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; +use settled_reach_server::atlas::believability::{ + analyze, cascade_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport, +}; use settled_reach_server::atlas::chunk_context::derive_chunk_context; use settled_reach_server::atlas::district_profile::DistrictProfile; -use settled_reach_server::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W}; use settled_reach_server::atlas::scale::{ self, ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M, }; use settled_reach_server::atlas::voxel::derive_voxel_column; -use settled_reach_server::seed::SeedChain; -use settled_reach_server::simulation::generator::SettlementClass; -const DEFAULT_SEA_LEVEL: f32 = 0.3; /// 150 chunks (the original question's offset) in metres = 9 600 m. const ANCHOR_OFFSET_CHUNKS: i32 = 150; fn main() { let args = Args::parse(std::env::args().skip(1)); - let world_seed = fnv1a64(args.seed.as_bytes()); + let world_seed = seed_to_u64(&args.seed); eprintln!( "aliveness probe — body={} seed=\"{}\" (u64={world_seed}) probes={}", args.body, args.seed, args.probes ); - // ── Inputs (DB-free cascade; we resolve its inputs here) ──────────────── - let db_path = first_existing(&["server/data/systems.db", "data/systems.db"]) - .expect("systems.db not found (run from repo root)"); - let hm_path = find_heightmap(&args.body).unwrap_or_else(|| { - panic!( - "no heightmap.png under wiki/star-systems/*/bodies/{}/", - args.body - ) - }); - - let params = BodyParamsReader::open(&db_path) - .expect("open systems.db") - .read_body_params(&args.body) - .expect("read body params"); - let cities = read_cities(&db_path, &args.body); - eprintln!( - "loaded heightmap {} | planet_class={:?} hydrosphere={:?} | {} settlements", - hm_path.display(), - params.planet_class, - params.hydrosphere, - cities.len() - ); - - // ── Run the real cascade through the road graph ───────────────────────── - let hm = load_heightmap_png(&hm_path, &args.body, DEFAULT_SEA_LEVEL).expect("load heightmap"); - let working = if hm.width > GRID_W || hm.height > GRID_H { - hm.downsample(GRID_W, GRID_H) - } else { - hm + let bws = match cascade_for_body(world_seed, &args.body) { + Ok(b) => b, + Err(e) => { + eprintln!("cannot run cascade for {}: {e}", args.body); + std::process::exit(1); + } }; - let body_seed = SeedChain::for_body(world_seed, &args.body); - let snapshot = run_cascade_from_heightmap( - body_seed, - working, - &cities, - None, - Some(¶ms), - CascadeLayer::RoadGraph, - ); - let bws = snapshot.into_body_world_state(); let districts = &bws.districts; if districts.is_empty() { @@ -104,37 +60,27 @@ fn main() { bws.placements.len() ); - // ── Principal city (ranked by placement score; per-city pop is 0 in DB) ── - let name_of: BTreeMap = cities - .iter() - .map(|c| (c.city_id, c.name.as_str())) - .collect(); - let principal = bws.placements.iter().max_by_key(|p| p.score); println!( "\n================ {} (seed \"{}\") ================", args.body, args.seed ); - let anchor_district = match principal { - Some(p) => { - let dp = scale::heightmap_pixel_to_district(p.position); - println!( - "principal settlement: {} @ pixel {:?} → district {:?} (ranked by placement score {}, since per-city population is 0 in the DB)", - name_of.get(&p.city_id).copied().unwrap_or(""), - p.position, - dp, - p.score - ); - // "≈150 chunks west" = 9 600 m west = ~4.7 districts. West = −x. - let west_districts = (ANCHOR_OFFSET_CHUNKS * CHUNK_M) / scale::DISTRICT_M; // = 4 - Some((dp.0 - west_districts.max(1) - 1, dp.1)) // round 4.7 → 5 west - } - None => { - println!("no settlement placements — skipping the anchored probe."); - None - } - }; - // ── The anchored probe (the location originally asked about) ──────────── + // ── Body-level believability report — the D-245 enforcer verdict ──────── + let report = analyze(world_seed, &args.body, districts); + print_report(&report); + + // ── Anchored probe: ≈150 chunks west of the principal settlement ──────── + // (highest placement score; per-city population is 0 in the DB). + let anchor_district = bws.placements.iter().max_by_key(|p| p.score).map(|p| { + let dp = scale::heightmap_pixel_to_district(p.position); + println!( + "\nprincipal settlement: city {} @ pixel {:?} → district {:?} (placement score {})", + p.city_id, p.position, dp, p.score + ); + let west_districts = (ANCHOR_OFFSET_CHUNKS * CHUNK_M) / scale::DISTRICT_M; + (dp.0 - west_districts.max(1) - 1, dp.1) + }); + if let Some(adp) = anchor_district { println!( "\n---- ANCHORED PROBE: ≈{ANCHOR_OFFSET_CHUNKS} chunks (9.6 km) west of the principal city ----" @@ -148,7 +94,7 @@ fn main() { if dp == adp { "" } else { - "(nearest land district to the target point — target itself is off-map / open ocean)" + "(nearest land district — target is off-map / open ocean)" }, ), None => println!(" target {adp:?} and neighbours are off-map; nothing to sample."), @@ -158,15 +104,58 @@ fn main() { // ── N random probes (deterministic from the seed) ─────────────────────── println!("\n---- {} RANDOM PROBES ----", args.probes); for i in 0..args.probes { - let pick = (fnv1a64(format!("{}/{i}", args.seed).as_bytes()) % keys.len() as u64) as usize; + let pick = (seed_to_u64(&format!("{}/{i}", args.seed)) % keys.len() as u64) as usize; let dp = keys[pick]; probe(&args.body, world_seed, dp, &districts[&dp], ""); } - println!("\n(district tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth derived for a representative chunk of each district.)"); + println!("\n(district tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth for a representative chunk of each district.)"); } -/// Probe one district: print its profile + a derived voxel-column sample + verdict. +/// Print the body-level believability report + the advisory D-245 criteria — the +/// enforcer verdict (T-1083, D-245). Contrast and coherence, never per-tile counts. +fn print_report(r: &BelievabilityReport) { + let c = &r.contrast; + let h = &r.coherence; + println!( + "\n-- BELIEVABILITY (D-245 enforcer) -- {} districts, {} voxel-sampled --", + r.district_count, r.voxel_sampled_districts + ); + println!( + " contrast: moisture_q[{}..{} ×{}] elev_q[{}..{} ×{}] slope_q[{}..{} ×{}] ocean%q[{}..{} ×{}]", + c.moisture_q.min, c.moisture_q.max, c.moisture_q.distinct, + c.elev_q.min, c.elev_q.max, c.elev_q.distinct, + c.slope_q.min, c.slope_q.max, c.slope_q.distinct, + c.ocean_fraction_q.min, c.ocean_fraction_q.max, c.ocean_fraction_q.distinct, + ); + println!( + " morphology zones={} vegetation classes={} terrain materials={}", + c.morphology_zones, c.vegetation_classes, c.terrain_materials + ); + println!( + " coherence: water wet {}/{} drainage monotonic {}/{} vegetated {}/{}", + h.water_districts_wet, + h.water_districts, + h.drainage_monotonic, + h.drainage_samples, + h.vegetated_districts, + h.vegetation_samples, + ); + let crit = evaluate_criteria(r); + let passes = crit.iter().filter(|c| c.pass).count(); + println!(" D-245 criteria (advisory — Q-123 calibrates thresholds):"); + for cr in &crit { + println!( + " [{}] {} — {}", + if cr.pass { "PASS" } else { "FAIL" }, + cr.name, + cr.detail + ); + } + println!(" → {}/{} criteria pass", passes, crit.len()); +} + +/// Probe one district: print its profile + a derived voxel-column sample for colour. fn probe(body: &str, world_seed: u64, dp: DistrictPos, prof: &DistrictProfile, note: &str) { // Representative chunk at the district centre (32 chunks / district). let chunk: ChunkPos = ( @@ -230,67 +219,8 @@ fn probe(body: &str, world_seed: u64, dp: DistrictPos, prof: &DistrictProfile, n fmt_hist(&water, n), fmt_hist(&cover, n) ); - println!( - " → {}", - verdict(&ctx_summary(&veg, &water, &cover, n), elev_max - elev_min) - ); -} - -struct Summary { - veg_alive_pct: u32, - water_pct: u32, - frozen_pct: u32, -} - -fn ctx_summary( - veg: &BTreeMap, - water: &BTreeMap, - cover: &BTreeMap, - n: i64, -) -> Summary { - let n = n.max(1) as u32; - let barren = veg.get("Barren").copied().unwrap_or(0); - let dry = water.get("Dry").copied().unwrap_or(0); - let none_cover = cover.get("None").copied().unwrap_or(0); - Summary { - veg_alive_pct: 100 * (n - barren) / n, - water_pct: 100 * (n - dry) / n, - frozen_pct: 100 * (n - none_cover) / n, - } -} - -/// A blunt heuristic "does this read as living nature" line. Not a metric of -/// record — a prompt for the eye. -fn verdict(s: &Summary, relief_m: i32) -> String { - let mut signals: Vec = Vec::new(); - if s.veg_alive_pct >= 50 { - signals.push(format!("vegetated ({}%)", s.veg_alive_pct)); - } else if s.veg_alive_pct > 0 { - signals.push(format!("sparse life ({}%)", s.veg_alive_pct)); - } else { - signals.push("barren".into()); - } - if s.water_pct > 0 { - signals.push(format!("water present ({}%)", s.water_pct)); - } - if s.frozen_pct >= 50 { - signals.push(format!("snow/ice {}%", s.frozen_pct)); - } - if relief_m >= 20 { - signals.push(format!("relief {relief_m} m (hills/landmarks)")); - } else if relief_m <= 2 { - signals.push("flat".into()); - } - let alive = s.veg_alive_pct >= 30 || s.water_pct >= 20 || relief_m >= 20; - format!( - "{} — {}", - if alive { - "reads ALIVE" - } else { - "reads thin/dead" - }, - signals.join(", ") - ) + // No per-location "reads ALIVE" line: a marginal per-tile count called a broken + // uniform world alive (the T-1083 lesson). The verdict is the body-level report above. } fn fmt_hist(h: &BTreeMap, n: i64) -> String { @@ -325,58 +255,7 @@ fn nearest_present( } // --------------------------------------------------------------------------- -// Inputs -// --------------------------------------------------------------------------- - -fn read_cities(db: &PathBuf, body: &str) -> Vec { - let conn = rusqlite::Connection::open(db).expect("open db for cities"); - let mut stmt = conn - .prepare( - "SELECT id, name, COALESCE(economic_role,'service_mixed'), COALESCE(population,0) - FROM atlas_city_names WHERE body_id = ?1 ORDER BY id", - ) - .expect("prepare city query"); - let rows = stmt - .query_map([body], |r| { - Ok(CityRecord { - city_id: r.get::<_, i64>(0)? as u64, - name: r.get(1)?, - // Per-city settlement_class is unset in the DB pool; the budget - // class is the default the read-set assumes (city_context_reader). - settlement_class: SettlementClass::PopulationBudget, - economic_role: r.get(2)?, - population: r.get(3)?, - }) - }) - .expect("city query") - .filter_map(Result::ok) - .collect(); - rows -} - -/// Glob `wiki/star-systems/*/bodies//heightmap.png`. -fn find_heightmap(body: &str) -> Option { - for base in ["wiki/star-systems", "../wiki/star-systems"] { - let root = PathBuf::from(base); - let Ok(systems) = std::fs::read_dir(&root) else { - continue; - }; - for sys in systems.flatten() { - let cand = sys.path().join("bodies").join(body).join("heightmap.png"); - if cand.is_file() { - return Some(cand); - } - } - } - None -} - -fn first_existing(paths: &[&str]) -> Option { - paths.iter().map(PathBuf::from).find(|p| p.is_file()) -} - -// --------------------------------------------------------------------------- -// Args + hashing +// Args // --------------------------------------------------------------------------- struct Args { @@ -402,14 +281,3 @@ impl Args { Args { body, seed, probes } } } - -/// FNV-1a 64-bit — a stable, dependency-free string→u64 so `--seed yolo` maps to -/// a deterministic world seed (reproducible across runs, unlike std's RandomState). -fn fnv1a64(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for &b in bytes { - h ^= b as u64; - h = h.wrapping_mul(0x0000_0100_0000_01b3); - } - h -} diff --git a/server/tests/believability_harness.rs b/server/tests/believability_harness.rs new file mode 100644 index 000000000..5cbe93477 --- /dev/null +++ b/server/tests/believability_harness.rs @@ -0,0 +1,139 @@ +//! Believability regression harness (T-1083) — the D-245 acceptance-gate enforcer. +//! +//! D-245 makes "the nature layers read alive *anywhere*" the deliverable of the +//! cascade's nature half. This harness is its repeatable instrument: for a set of +//! validation bodies at fixed seeds it runs the real deterministic cascade, computes +//! the [`BelievabilityReport`] (contrast + coherence — never marginal per-tile counts, +//! the T-1083 lesson), and: +//! +//! 1. **Determinism** — derives each body twice and asserts the reports are identical +//! (the D-010 contract; the strongest invariant, platform-independent). +//! 2. **Golden baseline** — snapshots the reports so any future cascade change that +//! moves believability (a regression *or* an improvement, e.g. when T-1080/T-1082 +//! land) is caught and must be acknowledged. Regenerate with +//! `UPDATE_GOLDEN=1 cargo test --test believability_harness`. +//! 3. **D-245 criteria (advisory → strict)** — prints each criterion's pass/fail per +//! body. Per D-245 the gate starts *budgeted + advisory* (thresholds are Q-123-TBD); +//! set `BELIEVABILITY_STRICT=1` to make unmet criteria *fail* the test (they do +//! today — that is the point: T-1080/T-1081/T-1082 are the open work to flip them). +//! +//! Distinct from `derivation_harness` (T-1031), which checks the binding *laws*; this +//! is its believability sibling — laws are necessary, this is the sufficiency gate. +//! +//! Bodies whose committed data (systems.db / heightmap) is absent are **skipped** with +//! a logged note (matching `derivation_harness`), so the harness is robust in trimmed +//! checkouts. The golden is an x86_64 reference (the cascade has f32 warp paths, per +//! `cascade_golden`). + +use settled_reach_server::atlas::believability::{ + analyze, cascade_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport, +}; + +const GOLDEN_FILE: &str = "tests/golden/believability.json"; + +/// `(label, body_id, seed)` — lore-anchored validation bodies spanning the climate +/// extremes the gate must handle: a temperate ocean world (should read lush) and a +/// frozen ice world (must read as a living *cold* landscape, not blank — D-245). +const VALIDATION_BODIES: &[(&str, &str, &str)] = &[ + ("Arbour (temperate/ocean)", "GJ338Bd", "believability-v1"), + ("Edict (frozen/ice)", "GJ244Ad", "believability-v1"), +]; + +/// Run the cascade + analyze for one body, or `None` if its committed data is absent. +fn report_for(body_id: &str, seed: &str) -> Option { + let world_seed = seed_to_u64(seed); + match cascade_for_body(world_seed, body_id) { + Ok(bws) => Some(analyze(world_seed, body_id, &bws.districts)), + Err(e) => { + eprintln!("[believability] SKIP {body_id}: {e}"); + None + } + } +} + +#[test] +fn believability_determinism_and_golden() { + let mut reports: Vec = Vec::new(); + + for (label, body_id, seed) in VALIDATION_BODIES { + let Some(first) = report_for(body_id, seed) else { + continue; + }; + // Determinism (D-010): a second full cascade + analyze must match exactly. + let second = report_for(body_id, seed).expect("body resolved once, must resolve again"); + assert_eq!( + first, second, + "[{label}] non-deterministic believability report — D-010 broken" + ); + + // Structural sanity that holds regardless of believability quality or platform. + assert!( + first.district_count > 0, + "[{label}] cascade produced no districts" + ); + assert!( + first.coherence.water_districts_wet <= first.coherence.water_districts, + "[{label}] wet water districts exceed total" + ); + + // Advisory D-245 criteria report (strict via BELIEVABILITY_STRICT=1). + let strict = std::env::var("BELIEVABILITY_STRICT").is_ok(); + let crit = evaluate_criteria(&first); + let passes = crit.iter().filter(|c| c.pass).count(); + eprintln!( + "[believability] {label}: {passes}/{} D-245 criteria pass", + crit.len() + ); + for c in &crit { + eprintln!( + " [{}] {} — {}", + if c.pass { "PASS" } else { "FAIL" }, + c.name, + c.detail + ); + if strict { + assert!( + c.pass, + "[{label}] D-245 criterion failed (strict): {}", + c.name + ); + } + } + + reports.push(first); + } + + if reports.is_empty() { + eprintln!("[believability] no validation bodies available — skipping golden"); + return; + } + + // ── Golden baseline ───────────────────────────────────────────────────── + let golden_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE); + let actual_json = serde_json::to_string_pretty(&reports).expect("serialize reports") + "\n"; + + if std::env::var("UPDATE_GOLDEN").is_ok() { + std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden"); + std::fs::write(&golden_path, &actual_json).expect("write golden"); + eprintln!("Golden written: {}", golden_path.display()); + return; + } + + let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| { + panic!( + "Golden not found: {}.\n\ + First run: UPDATE_GOLDEN=1 cargo test --test believability_harness\n{e}", + golden_path.display() + ) + }); + + let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual"); + let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden"); + assert!( + actual_v == golden_v, + "Believability golden mismatch — the cascade moved believability (regression OR \ + improvement). If intended (e.g. T-1080/T-1082 landed), update:\n\ + UPDATE_GOLDEN=1 cargo test --test believability_harness\n\nActual:\n{}", + actual_json.trim() + ); +} diff --git a/server/tests/golden/believability.json b/server/tests/golden/believability.json new file mode 100644 index 000000000..ae1468691 --- /dev/null +++ b/server/tests/golden/believability.json @@ -0,0 +1,80 @@ +[ + { + "body_id": "GJ338Bd", + "world_seed": 13200299156134074780, + "district_count": 2048, + "voxel_sampled_districts": 64, + "contrast": { + "moisture_q": { + "min": 80, + "max": 80, + "distinct": 1 + }, + "elev_q": { + "min": 0, + "max": 98, + "distinct": 99 + }, + "slope_q": { + "min": 0, + "max": 13, + "distinct": 14 + }, + "ocean_fraction_q": { + "min": 0, + "max": 100, + "distinct": 65 + }, + "morphology_zones": 9, + "vegetation_classes": 3, + "terrain_materials": 1 + }, + "coherence": { + "water_districts": 64, + "water_districts_wet": 4, + "drainage_samples": 16, + "drainage_monotonic": 4, + "vegetation_samples": 64, + "vegetated_districts": 64 + } + }, + { + "body_id": "GJ244Ad", + "world_seed": 13200299156134074780, + "district_count": 2048, + "voxel_sampled_districts": 64, + "contrast": { + "moisture_q": { + "min": 20, + "max": 20, + "distinct": 1 + }, + "elev_q": { + "min": 0, + "max": 99, + "distinct": 100 + }, + "slope_q": { + "min": 0, + "max": 6, + "distinct": 7 + }, + "ocean_fraction_q": { + "min": 0, + "max": 100, + "distinct": 65 + }, + "morphology_zones": 9, + "vegetation_classes": 1, + "terrain_materials": 2 + }, + "coherence": { + "water_districts": 20, + "water_districts_wet": 1, + "drainage_samples": 4, + "drainage_monotonic": 1, + "vegetation_samples": 64, + "vegetated_districts": 0 + } + } +]