feat(simulation): believability test protocol — D-245 enforcer (T-1083)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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-derived metrics over a deterministic subset of districts ────────
|
||||
let mut terrain_set: BTreeSet<String> = 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<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: "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<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,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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`. 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<Vec<CityRecord>, 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/<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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u64, &str> = 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("<unnamed>"),
|
||||
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<String, u32>,
|
||||
water: &BTreeMap<String, u32>,
|
||||
cover: &BTreeMap<String, u32>,
|
||||
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<String> = 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<String, u32>, n: i64) -> String {
|
||||
@@ -325,58 +255,7 @@ fn nearest_present(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn read_cities(db: &PathBuf, body: &str) -> Vec<CityRecord> {
|
||||
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/<body>/heightmap.png`.
|
||||
fn find_heightmap(body: &str) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<BelievabilityReport> {
|
||||
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<BelievabilityReport> = 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()
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user