Files
settled-reach/tooling/domains/ledger/service.py
T
jpmschweitzerandClaude Opus 5.5 4537b71b92 refactor(tooling): T-1290 — the wiki domain, and the renderer that must not run
`reach wiki stats` and `reach wiki gttr-hook` replace tooling/db/wiki_sync.py
and populate_gttr_hook.py. Both are output-identical to the originals:
`stats` byte-for-byte, and all 301 extracted GTTR hooks line-for-line.

wiki_sync.py moved whole, but generate_wiki() and import_from_wiki() are NOT
verbs. Before porting, the old `--generate` was run against a clean tree to get
a parity baseline. It changed all 301 system pages, +940 / -10,761, and was
reverted at once. It deletes the Celestial Bodies / Stations blocks (owned by
the Rust atlas sync, which it does not know about), deletes the
Industries / Exports / Imports rows (nothing writes those any more), and
rewrites star types where systems.db and the pages disagree. D-262, CLAUDE.md
and the wiki skill all described it as the routine, prose-preserving render.
CLAUDE.md and the skill now say not to run it; D-262 needs amending — T-1292.

Provenance moves to tooling/archive/, with a README naming what each script
did and why it is not run:

- pql-migrate/ (the T-1271 ruling)
- wiki-bootstrap/: assign-astro-ids + its catalog, migrate-s-to-gj,
  patch-core-sector (hardcodes a dead path), fill-missing-globes,
  generate-stubs and find-stubs (finds 0 stubs — Phase 1 is done),
  backfill_cultural_corridor (a raw systems.db patch script, outside D-262),
  and process-wiki-system-changes, whose last step is the destructive render

Also:

- stats() printed "run import first" and exited 0 when a table was missing;
  it now fails with a remedy. generate_wiki() counted created pages after
  writing them, so `created` was always 0.
- tooling/godot-cold-parse and godot-parse-sweep were never retired after
  T-1283, and the pr-process skill still told agents to run them. Removed;
  the skill and parse_sweep.gd now name the reach verbs.
- systems.db re-stamped: schema comments changed, and the stamp records the
  schema file's SHA for tamper detection.

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

370 lines
16 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.
"""Logic for the `ledger` domain — the economics import into systems.db. Transport-agnostic (D-263).
Reads TOML/JSON source files and populates the economics tables:
- gate_links from docs/design/star-map.json (335 edges, bidirectional)
- commodities from wiki/economics/commodities.toml (36 types)
- production_chains + chain_inputs from wiki/economics/production_chains.toml
- currency_zone on star_systems (default TRACTUS_PRIMARY)
- gate_energy_connected on star_systems (D-186: false for MARK_PRIMARY zones)
- corporations from wiki/corporations/*.md (sync + insert new records)
- corp_specialization/hq_placement/headquarters_body/headquarters_city_id
on corporations (D-242: HQ-placement key + baked
CityTenant link, from corp_hq_placement.toml)
- corp_presence from wiki/corporations/*.md (headquarters location data)
- atlas_city_names Standalone-HQ settlement rows (D-242, T-1074)
Validation (hard errors, non-zero exit on any failure):
- Wiki corporation names must match DB proper_name records (D-182 sync constraint)
- Chain completeness: every intermediate commodity has at least one production chain
- Commodity coverage: 3+ corporations per major commodity type (D-175)
- System coverage: 1+ corporation per inhabited system with population > 100K (D-175)
This is the sole generator of `server/data/systems.db` and was
`tooling/economy-db/import_economics.py` until T-1289. The steps live in the
`economy_import` package; the stamped source set is defined in
`tooling/generator_sources.py`.
**Exit codes are a contract, kept exactly.** 0 = imported and every coverage
threshold met. 2 = imported AND stamped, but the D-175 coverage gate is not met
— `make regen-db` and the tooling gate both tolerate it, because the data is
usable and the gap is content, not code. 1 = nothing written.
**Two error types, deliberately.** `economy_import.errors.ImportAborted` is
internal control flow — a step raises it to request rollback after printing
what went wrong. It never escapes this module: `run()` rolls back and converts
it to a `ReachError` carrying the remedy. That is the explicit reconciliation
DOMAINS.md asked for, rather than a second error type with different semantics
reaching the caller.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from tooling.core import console
from tooling.core.errors import ReachError
from tooling.domains.ledger.economy_import import (
atlas,
bodies,
brands,
corporations,
db,
economy,
migration,
specialization,
stamp,
traits,
validators,
)
from tooling.domains.ledger.economy_import.errors import ImportAborted
from tooling.domains.ledger.economy_import.paths import DB_PATH
from tooling.generator_sources import IMPORT_ECONOMICS_SOURCES
__all__ = ["DB_PATH", "run"]
# Named once, so the progress fraction and the count cannot disagree.
STEPS = 24
ABORTED_FIX = (
"fix the errors listed above in their source (wiki/economics/*.toml, "
"wiki/corporations/*.md), then re-run: reach ledger import --dry-run"
)
class _Steps:
"""Numbered progress through the import, one event per step."""
def __init__(self) -> None:
self.n = 0
def __call__(self, message: str) -> None:
self.n += 1
console.event(message, phase=f"{self.n}/{STEPS}", progress=self.n / STEPS)
def run(db_path: Path, *, dry_run: bool = False, strict_specialization: bool = False) -> None:
"""Import every economics source into `db_path`, in one transaction.
Raises ReachError with exit code 1 when nothing was written, and with exit
code 2 when the import committed but the coverage gate is not met.
"""
if not db_path.exists():
raise ReachError(
f"{db_path} not found",
fix="pass --db with an existing systems.db, or restore it: git restore server/data/systems.db",
)
console.event(f"Economics import into {db_path}" + (" — DRY RUN" if dry_run else ""))
# Load wiki corps before opening the DB — allows early exit on parse failures.
console.event("Loading wiki corporations...")
wiki_corps = corporations.load_wiki_corps()
console.event(f"{len(wiki_corps)} corporation files parsed")
# Regenerate generated_brands.toml via the Rust binary before the import
# reads it — single pipeline, single stamp (review T2/H3). Skipped on a
# dry run to avoid a disk side effect during validation.
if not dry_run:
brands.regenerate_brands()
conn = db.connect(db_path)
try:
counts = _import(conn, wiki_corps, dry_run, strict_specialization)
except ImportAborted:
conn.rollback()
conn.close()
raise ReachError(
"economics import aborted — rolled back, nothing written", fix=ABORTED_FIX
) from None
except BaseException:
# Anything else (KeyboardInterrupt, MemoryError, a DB error, a bug)
# rolls back too, so the DB is never left half-imported. Re-raised so
# the traceback survives.
conn.rollback()
conn.close()
raise
# Stamp generator metadata (#855, #856) so the pre-push hook can detect a
# stale snapshot. Written BEFORE the coverage gate: the stamp records which
# code produced the DB, not whether the data is complete (#860).
if not dry_run:
try:
stamp.write_stamp(conn, "import_economics", *IMPORT_ECONOMICS_SOURCES)
conn.commit()
console.event("Stamped: import_economics (covers brand pipeline Rust sources)")
except Exception as exc: # noqa: BLE001
console.event(f"failed to write generator stamp: {exc}", level="warn")
coverage_errors = _coverage(conn, wiki_corps)
conn.close()
summary = (
f"{counts['links']} gate_links, {counts['commodities']} commodities, "
f"{counts['chains']} chains, {counts['inputs']} inputs, "
f"{counts['presence']} corp_presence, {counts['brands']} brand_products, "
f"{counts['brand_inputs']} brand_inputs, {counts['fiscal']} system_fiscal"
)
if coverage_errors:
for e in coverage_errors:
console.event(e, level="error")
raise ReachError(
f"imported ({summary}) but the D-175 Phase 2 coverage gate is not met — "
f"{len(coverage_errors)} gap(s) above. "
+ ("Nothing was written (dry run)." if dry_run else "Data and stamp are committed."),
fix="add corporations to wiki/corporations/ until the thresholds are met, then re-run",
exit_code=2,
)
written = "validated, nothing written (dry run)" if dry_run else "imported and stamped"
console.verdict(f"ledger import: {written} — {summary}")
def _import(
conn: sqlite3.Connection, wiki_corps, dry_run: bool, strict_specialization: bool
) -> dict[str, int]:
"""The clear-then-reimport cycle, as one explicit transaction.
Any crash, validation error or interrupt between the first DELETE and the
commit rolls everything back (the caller owns that): the DB never ends up
half-cleared. On success it commits exactly once, right after structural
validation passes. On a dry run the transaction is left open so the
coverage check can still SELECT the imported rows; closing discards it.
"""
step = _Steps()
conn.execute("BEGIN")
# Schema migration — idempotent, inside the tx so a crash leaves no
# half-applied ALTER TABLE.
step("Schema migration...")
migration.apply_schema_migrations(conn)
# Atlas index tables: canonical DDL + empty geometry (D-223, #951).
atlas.ensure_atlas_index_schema(conn, dry_run)
# Clear economics tables in FK-safe order (children before parents).
if not dry_run:
db.clear_economics_tables(conn)
step("Importing gate links...")
n_links = economy.import_gate_links(conn, dry_run)
console.event(f"{n_links} rows (bidirectional)")
step("Importing commodities...")
n_commodities = economy.import_commodities(conn, dry_run)
console.event(f"{n_commodities} commodities")
step("Importing production chains...")
n_chains, n_inputs = economy.import_chains(conn, dry_run)
console.event(f"{n_chains} chains, {n_inputs} inputs")
# D-237 authored layer — after commodities (FK), before currency zones.
# UPSERTs onto pre-existing system_economy / system_factions rows;
# unauthored systems stay NULL.
step("Importing system specialization (D-237)...")
spec = specialization.import_system_specialization(conn, dry_run, strict=strict_specialization)
console.event(
f"vocab {spec['vocab']} | economic {spec['economic']} | "
f"cultural {spec['cultural']} | faction {spec['faction']}"
)
if spec["missing_economy_row"]:
console.event(
f"{len(spec['missing_economy_row'])} authored system(s) lack a "
f"system_economy row (values dropped): {spec['missing_economy_row']}",
level="warn",
)
if spec["missing_faction_row"]:
console.event(
f"{len(spec['missing_faction_row'])} authored system(s) lack a "
f"system_factions row (faction dropped): {spec['missing_faction_row']}",
level="warn",
)
step("Setting currency zones...")
for zone, count in sorted(economy.set_currency_zones(conn, dry_run).items()):
console.event(f"{zone}: {count}")
# D-186 — must run after currency zones.
step("Setting gate energy connectivity...")
for label, count in sorted(economy.set_gate_energy(conn, dry_run).items()):
console.event(f"{label}: {count}")
# D-182: a name divergence is a hard error.
step("Syncing corporations...")
corp_errors = corporations.sync_corporations(conn, wiki_corps, dry_run)
if corp_errors:
console.event(f"name divergence detected (D-182) — {len(corp_errors)} error(s):", level="error")
for e in corp_errors:
console.event(e, level="error")
console.event("update the wiki title or the DB proper_name to match, then re-run", level="error")
raise ImportAborted()
n_db_corps = conn.execute("SELECT COUNT(*) FROM corporations").fetchone()[0]
console.event(f"{n_db_corps} corporations in DB ({len(wiki_corps)} from wiki)")
# D-242 Phase A — reset + derive every run (PR #177 H1/T2). After corp
# sync (rows must exist), before corp_presence (reads headquarters_body
# back), and before the city-presence tiebreak further down, which reads
# atlas_city_names BEFORE this run's rebuild — the previous run's settled
# state, by design.
step("Importing corp specialization + HQ placement (D-242)...")
corp_spec = corporations.populate_corp_specialization(conn, wiki_corps, dry_run)
console.event(
f"{corp_spec['specialized']}/{corp_spec['total']} corps specialized, "
f"{corp_spec['hq_resolved']} headquarters_body resolved (recomputed every run; "
f"{corp_spec['hq_overridden']} authored overrides)"
)
step("Importing corp presence...")
commodity_ids = {r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()}
n_presence = corporations.import_corp_presence(conn, wiki_corps, commodity_ids, dry_run)
console.event(f"{n_presence} corp_presence rows")
# D-189, #827.
step("Importing brand products and inputs...")
n_brands, n_brand_inputs = brands.import_brands(conn, dry_run)
console.event(f"{n_brands} brand_products, {n_brand_inputs} brand_inputs")
# D-189 §6.
step("Populating system_fiscal...")
n_fiscal = economy.import_system_fiscal(conn, dry_run)
console.event(f"{n_fiscal} system_fiscal rows")
# D-204, #910.
step("Populating body_radius_km fallback...")
console.event(f"{bodies.populate_body_radius_km(conn, dry_run)} bodies updated")
# D-207, #908.
step("Populating atlas_city_names from wiki content...")
console.event(f"{atlas.populate_atlas_city_names(conn, dry_run)} city name rows")
# D-223, T-1169 — mirrors the city pool's shape over a different
# names-only markers.json key set; independent of the settlement pool.
step("Populating atlas_feature_names from wiki content...")
console.event(f"{atlas.populate_atlas_feature_names(conn, dry_run)} feature name rows")
# D-242 Phase B (T-1074). SUPERSEDES the retired corp-HQ cross-reference
# (D-207, #909), which inserted one city row per corp HQ with no
# UNIQUE(body_id, name) — ten co-named Groombridge rows on GJ380c. Must
# follow the city pool and the corp-specialization step.
step("Emitting Standalone-HQ settlements + CityTenant links (D-242)...")
hq = corporations.populate_standalone_hq_settlements(conn, dry_run)
console.event(
f"{hq['standalone_inserted']} Standalone-HQ settlements, "
f"{hq['tenant_linked']} CityTenant links ({hq['tenant_unmatched']} unmatched)"
)
# D-242, T-1075 — over the CORRECTED pool, so Standalone-HQ settlements
# are part of the rank-size spread rather than bolted on after.
step("Baking settlement population + settlement_class (D-242)...")
pop = atlas.populate_settlement_population_class(conn, dry_run)
console.event(
f"{pop['cities_populated']} cities populated across {pop['bodies_spread']} bodies, "
f"{pop['name_locked_applied']} NameLocked pins applied"
)
# D-232, #993 — catalog first, then sparse per-body hero bias (FK).
step("Baking trait_templates catalog (D-232)...")
console.event(f"{traits.populate_trait_templates(conn, dry_run)} trait templates")
step("Baking atlas_body_trait_bias hero pins (D-232)...")
console.event(f"{traits.populate_atlas_body_trait_bias(conn, dry_run)} body trait-bias rows")
# T-1024, D-239 §2.
step("Populating axial_tilt_deg from body-def frontmatter...")
console.event(f"{bodies.populate_axial_tilt_deg(conn, dry_run)} bodies updated with axial_tilt_deg")
# D-247, T-1085 — frontmatter override + two-gate default.
step("Populating biosphere_class (D-247)...")
console.event(f"{bodies.populate_biosphere_class(conn, dry_run)} bodies updated with biosphere_class")
# T-988, D-235 — both follow trait_templates (V-TT-06 / V-TT-07 validate
# against it).
step("Baking architecture_zone_bias table (D-235)...")
console.event(f"{traits.populate_architecture_zone_bias(conn, dry_run)} zone-bias rows")
step("Baking color_register_bands table (D-235)...")
console.event(f"{traits.populate_color_register_bands(conn, dry_run)} color register bands")
# Structural integrity (FK, chain refs, chain completeness). These mean
# the imported data is broken — do NOT commit.
step("Validating structural integrity...")
struct_errors = validators.validate(conn) + brands.validate_brands(conn)
if struct_errors:
console.event(f"STRUCTURAL ERRORS ({len(struct_errors)}) — rolling back:", level="error")
for e in struct_errors:
console.event(e, level="error")
raise ImportAborted()
console.event("FK integrity, chain completeness, and brand layer (V-B01–V-B06) OK")
# Commit before the coverage check: coverage is a Phase 2 gate (D-175),
# and the data should be queryable so the gaps can be reported clearly.
if not dry_run:
conn.commit()
console.event("Data committed.")
else:
console.event("Dry run — no changes written.")
if step.n != STEPS:
# A step added without bumping STEPS would make every progress
# fraction wrong without failing anything. Cheap to refuse here.
raise AssertionError(f"STEPS is {STEPS} but the import ran {step.n} steps")
return {
"links": n_links,
"commodities": n_commodities,
"chains": n_chains,
"inputs": n_inputs,
"presence": n_presence,
"brands": n_brands,
"brand_inputs": n_brand_inputs,
"fiscal": n_fiscal,
}
def _coverage(conn: sqlite3.Connection, wiki_corps) -> list[str]:
"""The D-175 Phase 2 gate. Runs after the commit, so the data is usable."""
console.event("Validating coverage (D-175 Phase 2 gate)...")
commodity_ids = {r[0] for r in conn.execute("SELECT commodity_id FROM commodities").fetchall()}
errors = validators.validate_commodity_coverage(conn, wiki_corps, commodity_ids)
errors += validators.validate_system_coverage(conn, wiki_corps)
if not errors:
console.event("All coverage thresholds met — Phase 2 gate PASSED.")
return errors