docs(governance): D-245 nature-layer believability acceptance gate + believability epic (T-1079..T-1084)
Lock the cascade's nature-half deliverable to "reads alive anywhere", not "implemented" — the holy-grail objective surfaced by probing the generation cascade. - D-245 (architecture): the nature layers are accepted only when the believability litmus passes at randomly-sampled locations across all habitable bodies/seeds — multi-scale + never-repeating (macro identity, meso non-stationarity, micro mosaic), coherence + non-stationarity + intra-class variety + relief + climate-appropriateness, automated screen under a human sign-off, budgeted -> strict. Q-123: threshold calibration. - T-1079 (epic, north-star, DoD=D-245) + findings T-1080 (uniform climate fields), T-1081 (near-zero relief), T-1082 (no water-body generator), T-1084 (intra-class micro-habitat mosaic), and T-1083 (the repeatable believability test protocol / D-245 enforcer). - aliveness_probe bin: the exploratory probe that found these (runs the deterministic cascade for a body+seed, reports per-district nature + verdict). Seed for T-1083. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ## 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.
|
||||
//!
|
||||
//! ```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::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());
|
||||
|
||||
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 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() {
|
||||
eprintln!("cascade produced no districts — cannot probe (body params missing?). Aborting.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
|
||||
eprintln!(
|
||||
"cascade: {} districts, {} settlement placements",
|
||||
districts.len(),
|
||||
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) ────────────
|
||||
if let Some(adp) = anchor_district {
|
||||
println!(
|
||||
"\n---- ANCHORED PROBE: ≈{ANCHOR_OFFSET_CHUNKS} chunks (9.6 km) west of the principal city ----"
|
||||
);
|
||||
match nearest_present(districts, adp) {
|
||||
Some((dp, prof)) => probe(
|
||||
&args.body,
|
||||
world_seed,
|
||||
dp,
|
||||
prof,
|
||||
if dp == adp {
|
||||
""
|
||||
} else {
|
||||
"(nearest land district to the target point — target itself is off-map / open ocean)"
|
||||
},
|
||||
),
|
||||
None => println!(" target {adp:?} and neighbours are off-map; nothing to sample."),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 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.)");
|
||||
}
|
||||
|
||||
/// Probe one district: print its profile + a derived voxel-column sample + verdict.
|
||||
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 = (
|
||||
dp.0 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
|
||||
dp.1 * CHUNKS_PER_DISTRICT + CHUNKS_PER_DISTRICT / 2,
|
||||
);
|
||||
let ctx = derive_chunk_context(world_seed, body, prof, chunk, None);
|
||||
|
||||
// Sample a 16×16 grid over the 64 m chunk (every 4 m).
|
||||
let mut terrain: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut veg: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut water: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut cover: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let (mut elev_min, mut elev_max, mut elev_sum, mut n) = (i32::MAX, i32::MIN, 0i64, 0i64);
|
||||
for sx in 0..16 {
|
||||
for sy in 0..16 {
|
||||
let tx = chunk.0 * CHUNK_M + sx * 4;
|
||||
let ty = chunk.1 * CHUNK_M + sy * 4;
|
||||
let col = derive_voxel_column(world_seed, body, prof, &ctx, tx, ty);
|
||||
*terrain.entry(format!("{:?}", col.terrain)).or_default() += 1;
|
||||
*veg.entry(format!("{:?}", col.vegetation)).or_default() += 1;
|
||||
*water.entry(format!("{:?}", col.water)).or_default() += 1;
|
||||
*cover.entry(format!("{:?}", col.cover)).or_default() += 1;
|
||||
elev_min = elev_min.min(col.elevation_m);
|
||||
elev_max = elev_max.max(col.elevation_m);
|
||||
elev_sum += col.elevation_m as i64;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n• district {dp:?} {note}");
|
||||
println!(
|
||||
" zone={:?} elev_q={} slope_q={} ocean%q={} moisture_q={} temp={} precip={:?} veg_class={:?} glaciation={:?} basin={:?}",
|
||||
prof.morphology_zone,
|
||||
prof.elev_q,
|
||||
prof.slope_q,
|
||||
prof.ocean_fraction_q,
|
||||
prof.moisture_q,
|
||||
prof.temperature_c.map(|t| format!("{t:.1}°C")).unwrap_or_else(|| "n/a".into()),
|
||||
prof.precipitation_class,
|
||||
prof.vegetation_class,
|
||||
prof.glaciation_grade,
|
||||
prof.basin_direction,
|
||||
);
|
||||
println!(
|
||||
" channel: {} width={} m elevation: {}–{} m (mean {})",
|
||||
if ctx.has_active_channel {
|
||||
"ACTIVE river/stream"
|
||||
} else {
|
||||
"none"
|
||||
},
|
||||
ctx.channel_width_m,
|
||||
elev_min,
|
||||
elev_max,
|
||||
elev_sum / n.max(1),
|
||||
);
|
||||
println!(" terrain: {}", fmt_hist(&terrain, n));
|
||||
println!(" vegetation: {}", fmt_hist(&veg, n));
|
||||
println!(
|
||||
" water: {} cover: {}",
|
||||
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(", ")
|
||||
)
|
||||
}
|
||||
|
||||
fn fmt_hist(h: &BTreeMap<String, u32>, n: i64) -> String {
|
||||
let n = n.max(1);
|
||||
let mut v: Vec<(&String, &u32)> = h.iter().collect();
|
||||
v.sort_by(|a, b| b.1.cmp(a.1));
|
||||
v.iter()
|
||||
.map(|(k, c)| format!("{} {}%", k, 100 * **c as i64 / n))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
/// Find the district at `dp`, else the nearest present district within a small ring.
|
||||
fn nearest_present(
|
||||
districts: &BTreeMap<DistrictPos, DistrictProfile>,
|
||||
dp: DistrictPos,
|
||||
) -> Option<(DistrictPos, &DistrictProfile)> {
|
||||
if let Some(p) = districts.get(&dp) {
|
||||
return Some((dp, p));
|
||||
}
|
||||
for r in 1..=8 {
|
||||
for dx in -r..=r {
|
||||
for dy in -r..=r {
|
||||
let q = (dp.0 + dx, dp.1 + dy);
|
||||
if let Some(p) = districts.get(&q) {
|
||||
return Some((q, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Args {
|
||||
body: String,
|
||||
seed: String,
|
||||
probes: u32,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: impl Iterator<Item = String>) -> Self {
|
||||
let mut body = "GJ338Bd".to_string();
|
||||
let mut seed = "yolo".to_string();
|
||||
let mut probes = 5u32;
|
||||
let mut it = args.peekable();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--body" => body = it.next().unwrap_or(body),
|
||||
"--seed" => seed = it.next().unwrap_or(seed),
|
||||
"--probes" => probes = it.next().and_then(|s| s.parse().ok()).unwrap_or(probes),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user