Files
settled-reach/tooling/domains/ledger/economy_import/bodies.py
T
jpmschweitzerandClaude Opus 5.5 23538d640f refactor(tooling): T-1289 — economy-db becomes reach ledger
The sole generator of systems.db moves to tooling/domains/ledger/ and is
now `reach ledger import`. economy_import/ keeps its name (Rust comments in
server/src cite it); the entrypoint becomes service.py; schema_version.py
moves with the importer, which is where the version is defined.

The stamp survived the move, which is the thing that had to hold:

- generated_brands.toml is byte-identical (sha256 e748531…) before and after
- `reach check systems-db-stamp` reported STALE after the move (the registry
  saw it) and OK after the regen
- the dry-run carries every count and warning of the baseline transcript,
  and exit 2 — imported and stamped, coverage gate unmet — still reaches the
  caller through @command

`make regen-db` survives as a one-line delegate, per D-263's muscle-memory
clause: about fifty files name it, including the headers of generated wiki
TOMLs and the remedies the push gate prints. `make economy-db` is retired;
it ran `reach generate brands` before the import, which the import already
does as its first step.

economy_import.errors is reconciled as DOMAINS.md asked. ImportAborted stays
as internal rollback control flow and never reaches a caller; the service
converts it to a ReachError carrying the remedy.

regenerate_brands caught cargo_binary's ReachError, printed it and raised
ImportAborted, dropping the remedy. It runs before the import transaction
opens, so there is nothing to roll back — it now propagates.

Both sys.path bootstraps are gone; they existed only because the directory
was hyphenated. The step labels ran [1/10]…[10/13]…[17/19]; one 24-step
counter now drives the event phase and progress.

Stale pointers fixed on the way: MIGRATION_SQL has lived in
economy_import/migration.py since T-1067, but the asset-pipeline rule,
DEVOPS and the schema comments still sent readers to import_economics.py;
the rule and DEVOPS also still named the check scripts T-1281 retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:18:59 +02:00

354 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
from tooling.core import console
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:
console.event(f"{fpath}: biosphere_class {bc!r} not in {_VALID}; ignored", level="warn")
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)