Files
settled-reach/tooling/economy-db/economy_import/bodies.py
T
jpmschweitzerandClaude Opus 4.8 c17b4f5442 fix(simulation): address PR #172 review — C1 over-vegetation + H1 biosphere gate (T-1084/T-1085)
Hoshe + Tyre review of the micro-habitat-mosaic branch requested changes on two
data-confirmed correctness bugs plus follow-through. All addressed.

Blocking:
- C1 (voxel.rs): the mosaic re-granted vegetation the climate withheld — a frozen
  ice world (Edict) read 61% vegetated tundra. The Grassland apply-gate now fires
  only where the climate already grants cover (!Barren); a climatically-barren
  district (frozen < -50C = surface ice/geology D-239 §2, or hyper-arid moisture<5,
  both resolved to VegetationClass::Barren upstream) is skipped. Genuine tundra
  (cold + moisture>=15) is Scrub upstream and still reaches the tundra palette via
  the same !Barren path. believability.json regenerated: Edict vegetated_districts
  39->0, Arbour 20->8 (its real cold-pole/arid districts revert to barren);
  intra-class variety preserved (Arbour vegetation_classes 3, micro_habitat_distinct 2).
- H1 (bodies.py): biosphere Gate 0 keyed on `inhabited`, conflating settlement with
  biosphere and force-Airless'ing uninhabited-but-alive worlds (D-247: worlds were
  alive before humans). Gate 0 is now settlement-independent — keyed on
  atmosphere/hydrosphere/planet_class. 24 uninhabited-alive worlds now classified
  (was 0); GJ0g-1 (dense atm + rivers) -> NativeMirror. Split 122 Compatible :
  121 Mirror; all 9 canon exemplars hold.

Follow-through:
- C2 (bodies.py + D-247): Gate 0 now reads planet_class (barren -> Airless) as
  D-247's "+ planet_class" phrasing names. D-247 amended to record the actual
  two-gate signal set (atm/hydro/planet_class habitability; economic_base_primary
  chirality; settlement-independence).
- C3 (believability.rs): micro_habitat_distinct criterion relaxed to `== 0 || >= 2`
  — the min-over-patches estimator makes K=3 unreachable against a dominant-entry
  palette; K=2 provisional, Q-123 calibrates.
- C4 + H2 (D-246): amended to record the v1 scope cut — only the Soil-derived
  vegetated/wet classes take the mosaic; material-driven families are `_ => false`
  so Hardpan/Scree are authored but unreachable in v1; no elevation change in v1;
  the mosaic OWNS the §8 climate->vegetation law for the classes it touches.
- H3 (voxel.rs): three dedicated mosaic-pass regression tests — C1 barren-respect,
  §8 material-untouched + forest-textured, T-1040 no-water-without-channel.
- H4 (voxel.rs): relief_signal comment corrected to ~[-1,+1].
- H5 (bodies.py): frontmatter override now strips quotes + validates against the
  4-value enum (warn-and-skip on typo).

systems.db regenerated + stamped (H1). believability golden regenerated (C1).
Full cargo test green; clippy -D warnings + ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:46:19 +02:00

353 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Body-attribute backfills: body_radius_km (D-204) and axial_tilt_deg (T-1024)."""
import hashlib
import sqlite3
from pathlib import Path
from .paths import WIKI_STAR_SYSTEMS
def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Populate body_radius_km column from planet_class fallback (D-204, #910).
Applies the fallback lookup table to rows where body_radius_km IS NULL.
Does not overwrite rows where body_radius_km is already set (authoritative data).
Fallback values (km):
super_earth -> 8000
earth_like -> 6371
earth -> 6371 (alternate spelling)
sub_earth -> 4500
ocean_world -> 6500
arid -> 5800
frozen -> 4500
ice_world -> 3000
barren -> 4500
volcanic -> 5500
gas_giant -> 0 (no settlements, skip)
moon -> 1737
other/unknown -> 6371 (Earth default)
"""
PLANET_CLASS_RADIUS = {
"super_earth": 8000.0,
"earth_like": 6371.0,
"earth": 6371.0,
"sub_earth": 3500.0,
"ocean_world": 6500.0,
"arid": 5800.0,
"frozen": 3500.0,
"ice_world": 3000.0,
"barren": 3500.0,
"volcanic": 5500.0,
"temperate": 6371.0,
"moon": 1737.0,
}
DEFAULT_RADIUS = 6371.0
SKIP_RADIUS_TYPES = {"oort_cloud", "asteroid_belt"}
GAS_GIANT_RADIUS = {
"gas_giant": 50000.0,
"ice_giant": 25000.0,
}
GAS_GIANT_DEFAULT = 45000.0
GAS_GIANT_SCATTER = 0.20 # ±20%
rows = conn.execute(
"SELECT body_id, planet_class, body_type, mass_class FROM bodies WHERE body_radius_km IS NULL"
).fetchall()
SCATTER_FRACTION = 0.15 # ±15% for rocky bodies
updates: list[tuple[float, str]] = []
for body_id, planet_class, body_type, mass_class in rows:
if body_type in SKIP_RADIUS_TYPES:
continue
h = int(hashlib.sha256(body_id.encode()).hexdigest()[:8], 16)
scatter_val = (h / 0xFFFFFFFF) * 2.0 - 1.0 # [-1.0, 1.0]
if body_type == "gas_giant":
mc = (mass_class or "").lower()
base_radius = GAS_GIANT_RADIUS.get(mc, GAS_GIANT_DEFAULT)
radius = round(base_radius * (1.0 + scatter_val * GAS_GIANT_SCATTER), 1)
elif body_type == "moon":
base_radius = 1400.0
scatter_frac = 0.86 # ±86% → ~196–2604 km
radius = round(base_radius * (1.0 + scatter_val * scatter_frac), 1)
else:
base_radius = PLANET_CLASS_RADIUS.get(
(planet_class or "").lower(), DEFAULT_RADIUS
)
radius = round(base_radius * (1.0 + scatter_val * SCATTER_FRACTION), 1)
updates.append((radius, body_id))
if not dry_run and updates:
conn.executemany(
"UPDATE bodies SET body_radius_km = ? WHERE body_id = ?", updates
)
return len(updates)
def populate_axial_tilt_deg(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Populate bodies.axial_tilt_deg from planet-gen body-def frontmatter (T-1024).
Reads wiki/star-systems/*/bodies/*/index.md YAML frontmatter and extracts
``orbit.axial_tilt_deg``. Only updates rows where axial_tilt_deg IS NULL
(preserves any future authoritative column writes).
Source: body_definition_parser.py writes ``orbit.axial_tilt_deg`` into each
body's index.md during the planet-gen batch run. This function mirrors
``populate_body_radius_km`` in structure.
"""
import yaml # stdlib-compatible subset via PyYAML if available, else manual parse
def _parse_frontmatter_yaml(text: str) -> dict:
"""Extract YAML frontmatter block from a markdown file."""
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return {}
end_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
end_idx = i
break
if end_idx is None:
return {}
fm_text = "\n".join(lines[1:end_idx])
try:
result = yaml.safe_load(fm_text)
return result if isinstance(result, dict) else {}
except Exception:
return {}
# Check if yaml is available; if not, use manual extraction.
try:
import yaml as _yaml_check # noqa: F401
has_yaml = True
except ImportError:
has_yaml = False
if not has_yaml:
# Fallback: manual extraction of axial_tilt_deg from YAML frontmatter.
# Scans for " axial_tilt_deg: <value>" under an "orbit:" block.
def _parse_frontmatter_manual(text: str) -> dict:
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return {}
in_orbit = False
result: dict = {}
for line in lines[1:]:
if line.strip() == "---":
break
stripped = line.strip()
if stripped == "orbit:":
in_orbit = True
continue
if in_orbit:
# Detect leaving the orbit block (non-indented key).
if line and not line.startswith(" ") and not line.startswith("\t"):
in_orbit = False
elif stripped.startswith("axial_tilt_deg:"):
_, _, val = stripped.partition(":")
try:
result["axial_tilt_deg"] = float(val.strip())
except ValueError:
pass
return result
def _parse_frontmatter_yaml(text: str) -> dict: # type: ignore[misc]
return _parse_frontmatter_manual(text)
# Find all body index.md files.
body_dir_pattern = WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "index.md"
import glob as _glob
body_files = sorted(_glob.glob(str(body_dir_pattern)))
# Build body_id → axial_tilt_deg mapping from frontmatter.
tilt_map: dict[str, float] = {}
for fpath in body_files:
try:
text = Path(fpath).read_text(encoding="utf-8")
except OSError:
continue
fm = _parse_frontmatter_yaml(text)
if not isinstance(fm, dict):
continue
body_id = fm.get("id")
orbit = fm.get("orbit", {})
if isinstance(orbit, dict):
tilt = orbit.get("axial_tilt_deg")
else:
tilt = None
if body_id and tilt is not None:
try:
tilt_map[str(body_id)] = float(tilt)
except (TypeError, ValueError):
pass
# Get all bodies where axial_tilt_deg IS NULL and body_id is in tilt_map.
rows = conn.execute(
"SELECT body_id FROM bodies WHERE axial_tilt_deg IS NULL"
).fetchall()
updates: list[tuple[float, str]] = []
for (body_id,) in rows:
if body_id in tilt_map:
updates.append((tilt_map[body_id], body_id))
if not dry_run and updates:
conn.executemany(
"UPDATE bodies SET axial_tilt_deg = ? WHERE body_id = ?", updates
)
return len(updates)
def populate_biosphere_class(conn: sqlite3.Connection, dry_run: bool) -> int:
"""Populate bodies.biosphere_class (D-247, T-1085).
Reads ``environment.biosphere_class`` from each body's index.md frontmatter (the
AUTHORED override) and, where absent, applies the D-247 two-gate default from the
body's atmosphere / hydrosphere / planet_class + the system's
``economic_base_primary``. Recomputes for ALL bodies each run (the value is fully
determined by frontmatter + the default, with no other authoritative writer to
preserve), so an edited override re-applies on the next regen.
Authored input, not seed entropy (D-247): the default is a function of other
AUTHORED fields, and explicit frontmatter always wins.
- Gate 0 (habitability — independent of SETTLEMENT, since D-247 holds worlds were
alive before humans): no atmosphere or an explicitly ``barren`` planet_class ->
Airless; ``thin`` atmosphere -> TerraformedSterile if inhabited (engineered),
else Airless (marginal natural); real atmosphere + open liquid water -> Alive ->
Gate 1; real atmosphere but no liquid water -> Airless (sealed / marginal).
- Gate 1 (Compatible vs Mirror): agriculture/service exporters -> Compatible;
penal/extraction/manufacturing -> Mirror; the neutral remainder -> a stable
body_id split toward the ~50/50 design target (canon worlds carry explicit
frontmatter overrides, so they never depend on this split).
"""
import glob as _glob
import hashlib
try:
import yaml as _yaml
_has_yaml = True
except ImportError:
_has_yaml = False
def _biosphere_from_frontmatter(text: str) -> tuple[str | None, str | None]:
"""Return ``(body_id, biosphere_class)`` from a body's frontmatter, or (None, None)."""
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return (None, None)
end = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
end = i
break
if end is None:
return (None, None)
block_lines = lines[1:end]
if _has_yaml:
try:
fm = _yaml.safe_load("\n".join(block_lines))
except Exception:
fm = None
if isinstance(fm, dict):
env = fm.get("environment")
bc = env.get("biosphere_class") if isinstance(env, dict) else None
return (str(fm.get("id")) if fm.get("id") else None, str(bc) if bc else None)
return (None, None)
# Manual fallback: scan `id:` and `environment.biosphere_class`.
body_id = None
bc = None
in_env = False
for line in block_lines:
stripped = line.strip()
if stripped.startswith("id:"):
body_id = stripped.partition(":")[2].strip() or None
if stripped == "environment:":
in_env = True
continue
if in_env:
if line and not line[0].isspace():
in_env = False
elif stripped.startswith("biosphere_class:"):
bc = stripped.partition(":")[2].strip() or None
return (body_id, bc)
# 1. Authored overrides from frontmatter (quote-stripped + enum-validated — H5).
_VALID = {"NativeCompatible", "NativeMirror", "TerraformedSterile", "Airless"}
override: dict[str, str] = {}
body_files = sorted(_glob.glob(str(WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "index.md")))
for fpath in body_files:
try:
text = Path(fpath).read_text(encoding="utf-8")
except OSError:
continue
body_id, bc = _biosphere_from_frontmatter(text)
if body_id and bc:
bc = bc.strip().strip("'\"")
if bc not in _VALID:
print(f" WARNING: {fpath}: biosphere_class {bc!r} not in {_VALID}; ignored")
continue
override[body_id] = bc
# 2. Bodies needing a value + the inputs for the two-gate default.
# Recompute for ALL bodies every run: biosphere_class has no other authoritative
# writer (unlike axial_tilt/radius, which preserve future column writes) — the
# frontmatter override + two-gate default are its complete, deterministic source, so
# an edited override must re-apply on the next regen.
rows = conn.execute(
"""
SELECT b.body_id, b.inhabited, b.atmosphere, b.hydrosphere, b.planet_class,
e.economic_base_primary
FROM bodies b
LEFT JOIN system_economy e ON e.system_id = b.system_id
"""
).fetchall()
_LIQUID = ("ocean", "liquid", "rivers", "moderate", "extensive", "coastal", "lakes")
def _default(inhabited, atmosphere, hydrosphere, planet_class, econ, body_id) -> str:
atm = (atmosphere or "").strip().lower()
hyd = (hydrosphere or "").strip().lower()
pclass = (planet_class or "").strip().lower()
# Gate 0 — habitability. Independent of SETTLEMENT (H1 fix): D-247's premise is
# that habitable worlds were alive before humans, so an UNINHABITED world with a
# real atmosphere + open liquid water is a living world nobody has settled — not
# Airless. Keyed on atmosphere / hydrosphere / planet_class (C2: these are the
# real habitability signals; D-247's "+ planet_class" phrasing amended to match).
if atm in ("", "none", "trace") or pclass == "barren":
return "Airless" # no atmosphere, or an explicitly lifeless world
if atm == "thin":
# A thin *breathable* atmosphere is an engineering achievement only where
# INHABITED (maintained infrastructure — D-247 Oshima); an uninhabited
# thin-atmosphere world is a marginal natural one with no open-air biosphere.
return "TerraformedSterile" if inhabited else "Airless"
if not any(k in hyd for k in _LIQUID):
return "Airless" # real atmosphere but no open liquid water -> sealed / marginal
# Gate 1 — Compatible vs Mirror (Alive).
base = (econ or "").strip().lower()
if "agricultur" in base or "service" in base:
return "NativeCompatible"
if "penal" in base or "extract" in base or "manufactur" in base:
return "NativeMirror"
h = int(hashlib.sha1(str(body_id).encode()).hexdigest(), 16)
return "NativeCompatible" if h % 2 == 0 else "NativeMirror"
updates: list[tuple[str, str]] = []
for body_id, inhabited, atmosphere, hydrosphere, planet_class, econ in rows:
val = override.get(body_id) or _default(
inhabited, atmosphere, hydrosphere, planet_class, econ, body_id
)
updates.append((val, body_id))
if not dry_run and updates:
conn.executemany(
"UPDATE bodies SET biosphere_class = ? WHERE body_id = ?", updates
)
return len(updates)