chore(tooling): retire atlas geometry generator + LLM naming cluster (D-223 #951)

The procedural server cascade (Phase 4) and the frozen names-only pool
supersede the Python atlas geometry generator and the LLM namer. Retire:

- generate_atlas.py (geometry production — cities/roads/rivers placement)
- gemma_naming.py, naming_core.py + tests (test_batch_naming,
  test_register_selection, qa_naming) and run-atlas-naming.sh (the LLM
  place-namer; its output is now the frozen pool)
- apply_name_fixes.py (name-field patches), fix_fewshot_bleed.py /
  prune_atlas_features.py (geometry tools)
- import_city_names.py (redundant with import_economics name-pool path)

Pipeline updates: drop the generate_atlas step + atlas-generate /
test-atlas-determinism targets from the Makefile; remove generate_atlas
from the stamp registry (import_economics is the sole regen-db generator);
drop run-atlas-determinism from tests/run-all; refresh stale references in
schema_version, backfill_cultural_corridor, earth_blocklist (kept as
reference data), populate_terrain_reference, and heightmap.rs.

The Gemma prompting methodology is preserved in
docs/gemma-naming-methodology.md (separate commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 23:29:08 +02:00
co-authored by Claude Opus 4.7
parent b0bfbfc7dd
commit b11e847337
20 changed files with 19 additions and 6530 deletions
+5 -23
View File
@@ -3,7 +3,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan decisions-orphan-tickets \
db-backup db-install validate-content check-fact-ids setup-hooks install-hooks \
audit deny atlas-verify economy-db atlas-generate regen-db check-systems-db \
audit deny atlas-verify economy-db regen-db check-systems-db \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
@@ -58,11 +58,9 @@ help:
@echo " make star-map-data Regenerate client/data/star_map_data.json from systems.db + wiki"
@echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)"
@echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)"
@echo " make atlas-generate Generate atlas city/road/rail markers for all inhabited bodies"
@echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)"
@echo " make check-systems-db Verify systems.db meta stamp matches current generator sources"
@echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)"
@echo " make test-atlas-determinism Determinism smoke test for generate_atlas.py (#847)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@echo " make golden-update Regenerate golden file and stage for commit"
@@ -232,9 +230,6 @@ test-ipc-integration:
test-ipc-benchmark:
tests/run-ipc-benchmark
test-atlas-determinism: ## Determinism smoke test for generate_atlas.py (#847)
tests/run-atlas-determinism
# --- Clean ---
clean-imports:
@@ -345,20 +340,6 @@ economy-db: ## Import economics data (commodities, chains, gate links) into sys
@tooling/generate-brands
@python3 tooling/economy-db/import_economics.py
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
@# Loud guard: generate_atlas.py reads bodies with a non-NULL terrain_reference.
@# If populate_terrain_reference.py has not run on a fresh DB, the generator
@# silently processes zero bodies and exits 0 — fail fast instead.
@count=$$(python3 -c "import sqlite3; c = sqlite3.connect('server/data/systems.db'); print(c.execute('SELECT COUNT(*) FROM bodies WHERE terrain_reference IS NOT NULL').fetchone()[0])"); \
if [ "$$count" = "0" ]; then \
echo "ERROR: no bodies have terrain_reference populated yet."; \
echo "Run: python3 tooling/planet-gen/populate_terrain_reference.py"; \
echo "(This is a prerequisite for atlas-generate — see D-191 §9 pipeline order.)"; \
exit 1; \
fi; \
echo " [guard] $$count bodies with terrain_reference — proceeding."
@python3 tooling/planet-gen/generate_atlas.py --seed 42
regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, #856)
@# Run as a single shell so `set -e` covers all steps. Without this
@# each recipe line was a fresh shell and a failure in step 1 did not
@@ -366,13 +347,14 @@ regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855,
@# (PR #136 review T4). import_economics' exit code 2 is a valid
@# coverage-gate-warning state (DB and stamp committed), not an error,
@# so it's explicitly tolerated. Any other non-zero exit halts the
@# pipeline immediately.
@# pipeline immediately. The atlas city/road/river geometry generator
@# was retired in #951 (D-223); import_economics now owns the atlas
@# index — it loads the names-only pool into atlas_city_names and empties
@# the geometry tables (the server cascade fills them, Phase 4).
@set -e; \
echo " [regen-db] Importing economics data (runs generate_brands internally)..."; \
ec=0; python3 tooling/economy-db/import_economics.py || ec=$$?; \
if [ $$ec -ne 0 ] && [ $$ec -ne 2 ]; then exit $$ec; fi; \
echo " [regen-db] Running atlas generator..."; \
python3 tooling/planet-gen/generate_atlas.py --seed 42; \
echo ""; \
echo " regen-db complete — systems.db is up to date and stamped."; \
echo " Stage it with: git add server/data/systems.db"
+1 -1
View File
@@ -13,7 +13,7 @@
use rusqlite::{params, Connection};
use thiserror::Error;
/// Canonical grid dimensions matching the Python pipeline (generate_atlas.py).
/// Canonical grid dimensions matching the Python pipeline (atlas_common.py).
pub const GRID_W: u32 = 512;
pub const GRID_H: u32 = 256;
+1 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# tests/run-all: Run all test suites in order (D-030)
# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol,
# run-ipc-integration, run-visual, run-atlas-determinism.
# run-ipc-integration, run-visual.
# Exit: 0 = all suites pass, non-zero = any suite failed
# Stdout: {"suite":"all","total":N,"passed":N,"failed":N,"duration_ms":N,"suites":[...]}
set -euo pipefail
@@ -27,7 +27,6 @@ SUITES=(
run-ipc-protocol
run-ipc-integration
run-visual
run-atlas-determinism
)
START_MS=$(date +%s%3N)
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
# tests/run-atlas-determinism: Determinism smoke test for generate_atlas.py (#847)
#
# Imports generate_atlas as a Python module, calls process_body() twice with
# seed=42 and dry_run=True, compares the returned markers dicts as JSON.
# No wiki files are written or modified.
#
# Purpose: cheap guardrail against determinism regressions in terrain analysis,
# city placement, A* road routing, infrastructure MST, and gate terminal
# placement. GJ892f is a domed body (population=300, 1 city) — the smallest
# well-exercised case in the atlas pipeline.
#
# Spec ref: #847
# Exit: 0 = deterministic (pass), non-zero = failure
# Stdout: {"suite":"atlas-determinism","total":1,"passed":N,"failed":N,"duration_ms":N}
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
START_MS=$(date +%s%3N)
# Write the comparison script to a real file so that the generate_atlas venv
# bootstrap (os.execv) can re-exec it from the venv Python when needed.
# A heredoc (python3 - <<'EOF') does not work after os.execv because stdin
# has already been consumed.
HELPER=$(mktemp /tmp/atlas_det_helper.XXXXXX.py)
trap "rm -f '$HELPER'" EXIT
cat > "$HELPER" << 'PYEOF'
"""Atlas determinism helper — called by tests/run-atlas-determinism (#847).
Imports generate_atlas as a module and calls process_body() twice with
dry_run=True. Compares the returned markers dicts as JSON. Exits 0 if
identical, 1 if they differ, 2 on setup/import failure.
"""
import json
import os
import sqlite3
import sys
from pathlib import Path
REPO_ROOT = Path(os.environ["SR_REPO_ROOT"])
sys.path.insert(0, str(REPO_ROOT / "tooling" / "planet-gen"))
try:
import generate_atlas
except ImportError as e:
print(f"SKIP: generate_atlas import failed (missing deps?): {e}", file=sys.stderr)
sys.exit(2)
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
BODY_ID = "GJ892f" # domed, pop=300 → exactly 1 city; minimal and fast
SEED = 42
if not DB_PATH.exists():
print(f"SKIP: systems.db not found: {DB_PATH}", file=sys.stderr)
sys.exit(2)
conn = sqlite3.connect(str(DB_PATH))
conn.execute("PRAGMA foreign_keys=ON")
row = conn.execute("""
SELECT b.body_id, b.system_id, b.terrain_reference, b.population,
b.settlement_pattern, b.planet_class, b.economic_role,
COALESCE(b.cultural_corridor, s.cultural_corridor)
FROM bodies b JOIN star_systems s ON b.system_id = s.system_id
WHERE b.body_id = ?
""", (BODY_ID,)).fetchone()
conn.close()
if not row:
print(f"SKIP: {BODY_ID} not found in systems.db", file=sys.stderr)
sys.exit(2)
body_info = dict(zip(
("body_id", "system_id", "terrain_reference", "population",
"settlement_pattern", "planet_class", "economic_role", "cultural_corridor"),
row,
))
body_info["population"] = body_info["population"] or 0
kwargs = dict(
body_info=body_info,
seed=SEED,
noise_factor=0.25,
dry_run=True, # no files written
force=True, # skip the already-populated check
verbose=False,
)
a = generate_atlas.process_body(**kwargs)
b = generate_atlas.process_body(**kwargs)
if a["status"] != "generated":
print(f"FAIL: run 1 status={a['status']} — {a.get('message', '')}", file=sys.stderr)
sys.exit(1)
if b["status"] != "generated":
print(f"FAIL: run 2 status={b['status']} — {b.get('message', '')}", file=sys.stderr)
sys.exit(1)
json_a = json.dumps(a["markers"], indent=2)
json_b = json.dumps(b["markers"], indent=2)
if json_a == json_b:
print(f"PASS: {BODY_ID} markers identical on two runs (seed={SEED})", file=sys.stderr)
sys.exit(0)
else:
import difflib
diff = "\n".join(list(difflib.unified_diff(
json_a.splitlines(), json_b.splitlines(), lineterm="",
fromfile="run1", tofile="run2",
))[:40])
print(f"FAIL: {BODY_ID} markers differ between run 1 and run 2 (seed={SEED})", file=sys.stderr)
print(diff, file=sys.stderr)
sys.exit(1)
PYEOF
set +e
SR_REPO_ROOT="$REPO_ROOT" python3 "$HELPER" 2>&1 >&2
EXIT_CODE=$?
set -e
END_MS=$(date +%s%3N)
DURATION_MS=$((END_MS - START_MS))
# Exit code 2 = setup failure / missing deps → count as 0 tests (skip, not fail)
if [[ $EXIT_CODE -eq 0 ]]; then
PASSED=1; FAILED=0; TOTAL=1
elif [[ $EXIT_CODE -eq 2 ]]; then
PASSED=0; FAILED=0; TOTAL=0
echo " [atlas-determinism] SKIPPED (import failure or missing DB)" >&2
else
PASSED=0; FAILED=1; TOTAL=1
fi
printf '{"suite":"atlas-determinism","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \
"$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS"
# Exit code 2 = venv/DB missing → treated as skip, not failure (exit 0 for run-all).
# Exit code 1 = determinism failure → exit 1 to fail CI.
[[ $EXIT_CODE -eq 2 ]] && exit 0 || exit $EXIT_CODE
+6 -9
View File
@@ -39,6 +39,12 @@ DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
# import_economics' source set includes the Rust generate_brands binary it now
# invokes as a subroutine (#136 review T2/H3). Keep this list in sync with
# IMPORT_ECONOMICS_SOURCES in tooling/economy-db/import_economics.py.
# The atlas geometry generator (generate_atlas) was retired in #951 (D-223).
# import_economics is now the sole regen-db generator that writes systems.db; it
# owns the atlas index (names-only pool + empty geometry tables). The surviving
# planet-gen importers (import_heightmaps, import_province_boundaries) are
# one-time build imports baked into the committed DB, not part of regen-db, so
# they are intentionally not stamped here.
GENERATOR_SOURCES: dict[str, list[Path]] = {
"import_economics": [
REPO_ROOT / "tooling" / "economy-db" / "import_economics.py",
@@ -47,15 +53,6 @@ GENERATOR_SOURCES: dict[str, list[Path]] = {
REPO_ROOT / "tooling" / "generate-brands",
REPO_ROOT / "tooling" / "schema_version.py",
],
"generate_atlas": [
REPO_ROOT / "tooling" / "planet-gen" / "generate_atlas.py",
REPO_ROOT / "tooling" / "planet-gen" / "gemma_naming.py",
REPO_ROOT / "tooling" / "planet-gen" / "naming_core.py",
REPO_ROOT / "tooling" / "planet-gen" / "import_city_names.py",
REPO_ROOT / "tooling" / "planet-gen" / "import_heightmaps.py",
REPO_ROOT / "tooling" / "planet-gen" / "import_province_boundaries.py",
REPO_ROOT / "tooling" / "schema_version.py",
],
}
+1 -1
View File
@@ -10,7 +10,7 @@ south_reach, east_reach, west_reach, deep_frontier). These two fields
refer to the same concept: which arc of the reach the system belongs
to. Leaving `cultural_corridor` NULL on 99%+ of rows defeats every
downstream consumer that actually wants to filter by corridor
(gemma_naming.py, future atlas UI queries, narrative tools).
(the atlas UI, narrative tools, and the server generation cascade).
This script treats `geographic_sector` as the source of truth and
copies it into `cultural_corridor`:
-300
View File
@@ -1,300 +0,0 @@
#!/usr/bin/env python3
"""
apply_name_fixes.py — Apply curated name replacements to markers.json files.
Usage:
python3 tooling/planet-gen/apply_name_fixes.py [--dry-run]
Edits name fields only; all geometry (path, center, area_fraction) is preserved.
After running, call generate_atlas.py --body <id> for each body to sync DB.
Decisions: D-191 (markers.json format, corridor palettes)
"""
import json
import sys
from pathlib import Path
REPO_ROOT = (Path(__file__).resolve().parent / ".." / "..").resolve()
WIKI = REPO_ROOT / "wiki" / "star-systems"
DRY_RUN = "--dry-run" in sys.argv
# ---------------------------------------------------------------------------
# Name replacement tables per body
# Format: { body_dir_key: { "feature_type": { "old_name": "new_name" } } }
# feature_type: cities | rivers | oceans | mountain_ranges | pois
# ---------------------------------------------------------------------------
FIXES: dict[str, dict[str, dict[str, str]]] = {
# -----------------------------------------------------------------------
# GJ 144 — Ran system
# Verified against actual markers.json files (DB is stale).
# -----------------------------------------------------------------------
# Kallast (GJ144d) — 2B pop, agricultural, urban_concentrated, ocean world
# Current state (from markers.json): good Nordic naming pass done; residual
# lazy/generic oceans remain. Fix: rename remaining generics, add cross-ref
# with mountain "Seterfjellet" (ocean → "Seterfjord"), fix POI, and rename
# "Aldren Pass" to avoid cross-system collision with Lendel (GJ380c capital
# "Aldren" and river "The Aldren").
# Narrative hook: "Rán's Landing" city (names the star), "Rán's Run" and
# "Ranfall Beck" rivers (shared Rán stem = cross-referencing done). Adding
# Seterfjord → Seterfjellet link completes the second cross-reference arc.
"GJ-144/bodies/GJ144d": {
"rivers": {
# Avoid cross-system stem collision with Lendel (GJ380c) "Aldren"
"Aldren Pass": "Randalfoss",
},
"oceans": {
# Still generic — large ocean near mountains → name ties to Seterfjellet
"Boulder Bluff": "Keldmere",
# "High Mesa" for an ocean is nonsensical
"High Mesa": "Seterfjord",
},
"pois": {
"Summit Point": "Kallast Gate Terminal",
},
},
# Vethis (GJ144e) — 1.2B pop, agricultural, dispersed_rural
# Current state: partial naming pass done. Remaining: cardinals (West Bend),
# earth-echoes (Blue Ridge), generics (Stone Point, Ironside Run, Golden
# Valley, Broad Fork), "Ash" stem overuse (Ashbluff Sea + Ashstone Ridge +
# Ashvale = 3 features), "Iron" stem overuse (Irongate + Ironside Run = 2).
# Fixes cross-reference existing named features:
# Grey- stem: Greywash + Greywash Fork + Greystone Ridge (intentional arc)
# Thorn- stem: Thorncrests + Thornrun (mountain visible from river)
# Kel- stem: Kelbridge + Kelside Run (river serving the city)
# Veth- stem: Vethis + Veth Delta + Veth Mere (planet name echoed in geography)
"GJ-144/bodies/GJ144e": {
"rivers": {
"West Bend": "Greywash Fork", # cardinal → cross-refs Greywash river
"Blue Ridge": "Ashvale Beck", # earth-echo → cross-refs Ashvale city
"Stone Point": "Thornrun", # generic → cross-refs Thorncrests mtn
"Ironside Run": "Kelside Run", # Iron overuse → cross-refs Kelbridge
},
"oceans": {
# Ashbluff Sea: "Ash" prefix already on Ashvale city + Ashstone Ridge mtn
"Ashbluff Sea": "Veth Mere", # Ash overuse → cross-refs Veth Delta
},
"mountain_ranges": {
"Golden Valley": "Greymoor Range", # generic → cross-refs Greywash river
"Broad Fork": "Keld Spur", # generic → Nordic "keld" (spring)
# Ashstone Ridge: "Ash" overuse (Ashvale + Veth Mere rename frees slot)
"Ashstone Ridge": "Greystone Ridge", # Ash overuse → cross-refs Greywash
},
"pois": {
"Ridge Crossing": "Vethis Gate Terminal",
},
},
# -----------------------------------------------------------------------
# GJ 71 — Tau Ceti system
# Verified against actual markers.json files (DB is stale).
# -----------------------------------------------------------------------
# Threshold (GJ71c) — 600M pop, agricultural, urban_concentrated
# Fix: replace "Aethelred" (Anglo-Saxon; breaks the all-Latin-personal-name
# pattern of the six rivers: Octavius, Septimus, Quintus, Valeria, Marcus).
# Narrative: All six rivers named after the original Commission survey team.
"GJ-71/bodies/GJ71c": {
"rivers": {
"Aethelred": "Gaius",
},
},
# Arden (GJ71d) — 500M pop, agricultural, dispersed_rural
# Fix cross-body stem duplicates:
# "Concordia Hall" city → exact stem match with GJ71c ocean "Concordia"
# "Basilica Nova" river → exact name match with GJ71c POI "Basilica Nova"
# GJ71d-1 has been independently updated — Forum Major no longer conflicts.
# Capital "Palaestra" and other names are clean — keep.
"GJ-71/bodies/GJ71d": {
"cities": {
"Concordia Hall": "The Praxis", # Concordia = GJ71c sea
},
"rivers": {
"Basilica Nova": "Via Principia", # exact match = GJ71c POI name
},
},
# Verantis (GJ71d-1) — 20M pop, transit moon of Arden
# Already updated in a prior pass (capital=Praetorium, mountains all
# renamed to unique Latin institutional names). No changes needed.
# "GJ-71/bodies/GJ71d-1": {}, # skip
# -----------------------------------------------------------------------
# GJ 244A — Sirius system
# Verified against actual markers.json files (DB matches).
# Note: "Westwall" flagged by Gestalt does NOT appear in Edict markers.json
# or DB — either already removed in a prior pass or Gestalt saw stale data.
# -----------------------------------------------------------------------
# Edict (GJ244Ad) — 400M pop, hand-authored template + #833 derived features
# Hand-authored core is clean (Mandate, Station Edict, Founder's Range,
# Accord Peaks, Veto Spur, Concord Assembly Archive, all POIs). Fixes
# target only #833-generated features that don't match the political-legal
# naming vocabulary of Edict.
# Narrative hook: the legal/institutional vocabulary — Mandate, Accord, Veto,
# Concord, Statute, Charter — all reference governance and founding documents.
# Arcs established: Concord- (ocean + POI), Charter- (mountain + ocean),
# Veil- (ocean + POI cross-ref to Veil Institute Survey Station).
"GJ-244A/bodies/GJ244Ad": {
"mountain_ranges": {
# "Keel" is a nautical term — wrong vocabulary for a political world
"Keel Ridge": "Charter Spur", # charter = Edict's founding document
# "Sanction" fits but "Ridge" suffix is lazy
"Sanction Ridge": "The Statute", # distinctive; drops generic suffix
},
"oceans": {
# Cardinal direction names; replace with Edict-vocabulary terms
"Upper Shelf": "Veil Shelf", # cross-refs Veil Institute Survey Station
"Southern Melt": "Concord Mere", # cross-refs Concord Assembly Archive POI
"Eastern Tarn": "Charter Tarn", # cross-refs mountain "Charter Spur"
},
},
# -----------------------------------------------------------------------
# GJ 380 — Groombridge system
# Verified against actual markers.json files.
# Hand-authored template is clean: Aldren (capital) + The Aldren (river) +
# Aldren Exchange (POI) is an INTENTIONAL arc — do not touch.
# "Meridian Risk HQ" POI is a corporation proper noun — not geography.
# -----------------------------------------------------------------------
# Lendel (GJ380c) — 900M pop, hand-authored template + #833 derived features
# Two #833-generated features with lazy suffixes. British-Isles naming
# vocabulary: Lendel, Aldren, Sethvale, Caldenmere, Rethain Sea.
# "Durneth Beck" cross-refs Durneth Range (the major mountain range near
# the river's source area). "Tember Spine" keeps the "Tember" stem but
# drops the lazy "Ridge" suffix.
"GJ-380/bodies/GJ380c": {
"rivers": {
"Pale Run": "Durneth Beck", # lazy → cross-refs Durneth Range; beck=British stream
},
"mountain_ranges": {
"Tember Ridge": "Tember Spine", # lazy Ridge → Spine (distinctive; not in lazy list)
},
},
# -----------------------------------------------------------------------
# GJ 699 — Barnard's Star system
# Verified against actual markers.json files.
# -----------------------------------------------------------------------
# Verada (GJ699b) — 1.9B pop, hop-0, dense urban world
# Major problem: rivers and ocean names use urban civic vocabulary
# (Grandview Square, Meridian City, Metropolitan Square) — these are
# neighborhood-planning names, not geographic features. Rivers especially
# cannot be named "Grandview Square" or "Meridian City". Oceans have
# district-style names that duplicate on sibling bodies.
# Strategy: keep Verada's civic-density character in CITY names, but
# give geographic features geographic names that cross-reference the cities.
# Arcs: Capitol- (city + river + ocean), Haven/Port- (city + ocean),
# Meridian- (river + ocean), Prospect- (ocean), Sterling- (lake).
"GJ-699/bodies/GJ699b": {
"rivers": {
# "Grandview Square" is a plaza, not a river name
"Grandview Square": "Verada Reach", # planet name + geographic; Reach = stretch of river
# "Meridian City" is a city name used for a river
"Meridian City": "The Meridian", # drop civic suffix; becomes proper river name
# "Summit View" is lazy
"Summit View": "Capitol Beck", # cross-refs Capitol Heights capital city
},
"oceans": {
# "Terrace" is an architectural term — wrong for a large ocean
"Prospect Terrace": "Prospect Sea", # keep Prospect, fix suffix
# "Metropolitan Square" is a plaza name for an ocean
"Metropolitan Square": "Haven Sea", # cross-refs Port Haven city
# Exact dup of GJ699b-1 mountain "Central District"
"Central District": "Meridian Sound", # cross-refs The Meridian river
# "Plaza" is civic architecture, not a lake feature
"Liberty Plaza": "Capitol Mere", # cross-refs Capitol Heights capital
# "Sterling Heights" — "Heights" is wrong for a lake; dupes GJ699b-1 mountain
"Sterling Heights": "Sterling Pool", # keep Sterling, fix suffix
},
},
# Verada's moon (GJ699b-1) — uninhabited transit moon
# All 8 mountains are named after urban street addresses (Grandview Avenue,
# Harmony Boulevard, Beacon Street, etc.) — this is entirely wrong for
# mountain ranges. The fix renames them to geological/geographic terms that
# cross-reference Verada's institutional vocabulary (as if settlers looked
# down at Verada and named the moon's peaks after the institutions below).
# The largest range (Verada Scarp, was "Grandview Avenue") takes the planet
# name directly.
"GJ-699/bodies/GJ699b-1": {
"mountain_ranges": {
"Grandview Avenue": "Verada Scarp", # largest range; named after parent body
"Central District": "Barnard Heights", # names the star; distinct from all Verada names
"Harmony Boulevard": "Keystone Scarp", # cross-refs Keystone Junction on Verada
"Capitol Heights": "Tribunal Spur", # cross-refs Tribunal Tower on Verada
"Meridian Plaza": "Zenith Spine", # cross-refs Zenith Station on Verada
"Liberty Square": "Ironwood Spur", # cross-refs Ironwood Borough on Verada
"Metropolitan Way": "Consulate Scarp", # cross-refs Consulate Way on Verada
"Beacon Street": "Prefecture Spur", # cross-refs Prefecture Hall on Verada
},
},
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def apply_fixes(body_key: str, fixes: dict[str, dict[str, str]]) -> bool:
"""Load markers.json for body_key, apply name fixes, write back. Returns True on change."""
parts = body_key.split("/")
path = WIKI / Path(*parts) / "markers.json"
if not path.exists():
print(f" SKIP {body_key}: markers.json not found at {path}")
return False
with open(path) as f:
markers = json.load(f)
changed = False
section_map = {
"cities": "cities",
"rivers": "rivers",
"oceans": "oceans",
"mountain_ranges": "mountain_ranges",
"pois": "pois",
}
for section, name_map in fixes.items():
feature_list = markers.get(section_map[section], [])
for feature in feature_list:
old_name = feature.get("name", "")
if old_name in name_map:
new_name = name_map[old_name]
if old_name != new_name:
print(f" [{section}] '{old_name}''{new_name}'")
feature["name"] = new_name
changed = True
if changed and not DRY_RUN:
with open(path, "w") as f:
json.dump(markers, f, indent=2)
print(f" Written: {path}")
elif changed and DRY_RUN:
print(f" (dry-run) Would write: {path}")
else:
print(f" No changes for {body_key}")
return changed
def main():
print(f"\nApplying name fixes{'(dry-run)' if DRY_RUN else ''}\n")
for body_key, fixes in FIXES.items():
print(f"=== {body_key} ===")
apply_fixes(body_key, fixes)
print()
print("Done.\n")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -1,4 +1,6 @@
# Earth-name blocklist for gemma_naming.py (#833, D-191 §4).
# Earth-name blocklist (#833, D-191 §4). Retained as curated reference data
# after the Gemma naming pipeline was retired (#951, D-223) — see
# docs/gemma-naming-methodology.md.
#
# Per memory feedback_earth_echo_names: Earth-sounding names are OK and
# expected — the setting is a blended reality. This file is a small,
-206
View File
@@ -1,206 +0,0 @@
#!/usr/bin/env python3
"""Replace few-shot example names that bled into the output.
The batch naming prompt uses Scottish Highland and Dutch colonial
examples. The Scottish ones (Glen Moray, Dunvegan Ridge, Torridon,
Cairn Brae, The Kelpie's Spine) leaked into 270 features. This script
replaces them with unique names from a combined Scottish/Welsh/Irish
pool, ensuring no collisions with the existing corpus.
"""
import json
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
sys.path.insert(0, str(TOOLING_DIR))
from generate_atlas import sync_markers_to_db
# The few-shot names to replace
FEWSHOT_NAMES = {
"glen moray", "dunvegan ridge", "torridon", "cairn brae", "the kelpie's spine",
"kloosterbeek", "nieuw rijn", "hoogland run", "van diemen's creek",
}
# Scottish / Welsh / Irish replacement pool — 300+ names to cover 270 replacements
# with room for Levenshtein filtering. Mix of geographic feature styles.
REPLACEMENT_POOL = [
# Scottish
"Glenfinnan", "Dalwhinnie Pass", "Cairngorm", "Loch Maree",
"Kinlochleven", "Strathspey", "Brae Morar", "Skye Reach",
"Ardnamurchan", "Kintail", "Glen Affric", "Lochaber",
"Killiecrankie", "Rannoch Moor", "Glen Coe", "Strathnaver",
"Applecross", "Torrisdale", "Durness", "Assynt",
"Coigach", "Inverpolly", "Sandwood", "Cape Wrath",
"Sutherland", "Helmsdale", "Brora", "Golspie",
"Cromarty", "Dornoch", "Nairn", "Forres",
"Culbin", "Findhorn", "Spey Bay", "Buckie",
"Banff", "Fraserburgh", "Peterhead", "Cruden Bay",
"Slains", "Ythan", "Bennachie", "Morven",
"Lochnagar", "Braemar", "Balmoral", "Crathie",
"Ballater", "Dinnet", "Tarland", "Lumphanan",
"Corgarff", "Tomintoul", "Glenlivet", "Dufftown",
"Craigellachie", "Aberlour", "Knockando", "Archiestown",
"Rothes", "Elgin", "Lossiemouth", "Burghead",
"Kinloss", "Alves", "Pluscarden", "Dallas",
# Welsh
"Cwm Idwal", "Beddgelert", "Crib Goch", "Tryfan",
"Ogwen", "Llyn Padarn", "Dolgellau", "Harlech",
"Rhinog", "Cader Idris", "Barmouth", "Aberdovey",
"Tywyn", "Machynlleth", "Pumlumon", "Hafren",
"Elan Valley", "Claerwen", "Llandrindod", "Brecon",
"Pen y Fan", "Corn Du", "Crickhowell", "Llangorse",
"Talgarth", "Hay Bluff", "Mynydd Troed", "Mynydd Llangorse",
"Skirrid", "Blorenge", "Llanfoist", "Govilon",
"Gilwern", "Llangattock", "Crug Hywel", "Cwm Clydach",
"Pontneddfechan", "Ystradfellte", "Sgwd yr Eira", "Henrhyd",
"Carreg Cennen", "Dinefwr", "Llandeilo", "Dryslwyn",
"Tywi Valley", "Carmarthen", "Kidwelly", "Pembrey",
"Gower", "Rhossili", "Oxwich", "Port Eynon",
"Pennard", "Langland", "Caswell", "Mumbles",
"Merthyr Mawr", "Ogmore", "Dunraven", "Llantwit",
"Monknash", "Nash Point", "Aberthaw", "Fonmon",
# Irish
"Glendalough", "Lugnaquilla", "Glen Imaal", "Wicklow Gap",
"Sally Gap", "Kippure", "Djuce", "Maulin",
"Djouce", "Great Sugar Loaf", "Bray Head", "Killiney",
"Dalkey", "Howth", "Lambay", "Ireland's Eye",
"Malahide", "Portmarnock", "Donabate", "Skerries",
"Balbriggan", "Gormanston", "Bettystown", "Laytown",
"Slane", "Newgrange", "Dowth", "Knowth",
"Tara", "Trim", "Navan", "Kells",
"Loughcrew", "Oldcastle", "Castlepollard", "Fore",
"Delvin", "Mullingar", "Kilbeggan", "Tullamore",
"Clara", "Ferbane", "Banagher", "Shannonbridge",
"Clonmacnoise", "Ballinasloe", "Aughrim", "Loughrea",
"Portumna", "Mountshannon", "Killaloe", "Ballina",
"Nenagh", "Roscrea", "Templemore", "Thurles",
"Cashel", "Cahir", "Clonmel", "Carrick-on-Suir",
"Piltown", "Mooncoin", "Waterford", "Tramore",
"Bunmahon", "Ardmore", "Youghal", "Midleton",
"Cobh", "Crosshaven", "Kinsale", "Clonakilty",
"Skibbereen", "Bantry", "Glengarriff", "Kenmare",
"Sneem", "Caherdaniel", "Waterville", "Cahersiveen",
"Valentia", "Portmagee", "Skellig", "Dingle",
"Brandon", "Castlegregory", "Fenit", "Tralee",
"Listowel", "Ballybunion", "Tarbert", "Glin",
"Foynes", "Askeaton", "Adare", "Patrickswell",
# More Scottish/Gaelic to fill
"Stornoway", "Tarbert", "Scalpay", "Eriskay",
"Barra", "Vatersay", "Minguilay", "Pabbay",
"Berneray", "Monach Isles", "Balranald", "Lochmaddy",
"Benbecula", "Grimsay", "Ronay", "Wiay",
"Canna", "Rum", "Eigg", "Muck",
"Ardnish", "Arisaig", "Morar", "Mallaig",
"Knoydart", "Barrisdale", "Arnisdale", "Glenelg",
"Sandaig", "Brochs of Borve", "Callanish", "Garenin",
"Carloway", "Arnol", "Barvas", "Tolsta",
"Ness", "Europie", "Swainbost", "Skigersta",
# Additional Welsh/Irish
"Aberystwyth", "Llanberis", "Betws-y-Coed", "Conwy",
"Caernarfon", "Pwllheli", "Abersoch", "Nefyn",
"Llanbedrog", "Criccieth", "Porthmadog", "Portmeirion",
"Trawsfynydd", "Ffestiniog", "Blaenau", "Llyn Tegid",
"Corwen", "Llangollen", "Chirk", "Oswestry",
]
def load_global_names(conn):
"""Load all existing names globally for uniqueness checking."""
names = set()
for table in ['atlas_cities', 'atlas_rivers', 'atlas_mountain_ranges',
'atlas_oceans', 'atlas_pois']:
rows = conn.execute(
f"SELECT lower(name) FROM {table} WHERE name IS NOT NULL AND name != ''"
).fetchall()
names.update(r[0] for r in rows)
return names
def main():
conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=15000")
global_names = load_global_names(conn)
print(f"Loaded {len(global_names)} existing names")
# Build available replacements (not already in corpus)
available = [n for n in REPLACEMENT_POOL if n.lower() not in global_names]
print(f"Available replacements: {len(available)} (from pool of {len(REPLACEMENT_POOL)})")
# Find all features that need replacement
replacements_needed = []
for markers_path in sorted(WIKI_SYSTEMS.glob("*/bodies/*/markers.json")):
body_id = markers_path.parent.name
m = json.loads(markers_path.read_text())
for section in ("cities", "rivers", "oceans", "mountain_ranges", "pois"):
for feat in m.get(section, []):
name = feat.get("name", "")
if name and name.lower() in FEWSHOT_NAMES:
replacements_needed.append((markers_path, body_id, section, feat))
print(f"Features to replace: {len(replacements_needed)}")
if len(available) < len(replacements_needed):
print(f"WARNING: only {len(available)} replacements for {len(replacements_needed)} features")
print(" some features will keep their few-shot names")
# Group by file, reload, replace, write
replacement_idx = 0
used_per_body = defaultdict(set)
changed_bodies = []
# Group by file
by_file = defaultdict(list)
for markers_path, body_id, section, feat in replacements_needed:
by_file[markers_path].append((body_id, section, feat["id"] if "id" in feat else None))
for markers_path, entries in by_file.items():
body_id = markers_path.parent.name
m = json.loads(markers_path.read_text())
changed = False
for _, section, feat_id in entries:
for feat in m.get(section, []):
name = feat.get("name") or ""
if not name or name.lower() not in FEWSHOT_NAMES:
continue
assigned = None
for attempt in range(len(available)):
candidate = available[(replacement_idx + attempt) % len(available)]
if candidate.lower() not in used_per_body[body_id]:
assigned = candidate
replacement_idx = (replacement_idx + attempt + 1) % len(available)
break
if assigned:
feat["name"] = assigned
used_per_body[body_id].add(assigned.lower())
changed = True
if changed:
markers_path.write_text(json.dumps(m, indent=2) + "\n")
changed_bodies.append(body_id)
# Sync to DB if body exists
try:
sync_markers_to_db(conn, body_id, m)
except Exception:
pass # orphan body
conn.commit()
conn.close()
print(f"\nReplaced few-shot names on {len(changed_bodies)} bodies")
print("Done.")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-205
View File
@@ -1,205 +0,0 @@
#!/usr/bin/env python3
"""
import_city_names.py — Populate atlas_city_names from wiki markers.json content.
For each inhabited body, reads city records from markers.json and inserts rows
into atlas_city_names with:
- name, kind, population from markers.json
- economic_role from bodies table
- corp_id from corporations.headquarters_body cross-reference (#909)
Incremental: clears and reimports all rows for each body on every run (the
table has no stable local IDs — city identity is name × body_id). Use --body
to restrict to a single body.
Usage:
tooling/planet-gen/import_city_names.py
tooling/planet-gen/import_city_names.py --body GJ380c
tooling/planet-gen/import_city_names.py --dry-run
Exit codes:
0 completed
1 fatal error (missing DB, schema error)
"""
import argparse
import json
import sys
import time
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
import os
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
import sqlite3
from generate_atlas import (
DB_PATH,
ensure_atlas_schema,
query_inhabited_bodies,
)
def _build_hq_index(conn: sqlite3.Connection) -> dict[str, str]:
"""Build a mapping of body_id → corp_id for all corp HQ locations."""
rows = conn.execute(
"SELECT headquarters_body, corp_id FROM corporations "
"WHERE headquarters_body IS NOT NULL"
).fetchall()
index: dict[str, str] = {}
for body_id, corp_id in rows:
# If multiple corps have the same HQ body, take the first (alphabetical
# corp_id for determinism). This is unlikely but safe.
if body_id not in index:
index[body_id] = corp_id
return index
def _load_city_records(body_dir: Path) -> list[dict]:
"""Load named city records from markers.json. Returns empty list if none."""
markers_path = body_dir / "markers.json"
if not markers_path.exists():
return []
try:
markers = json.loads(markers_path.read_text())
except json.JSONDecodeError:
return []
return [
c for c in (markers.get("cities") or [])
if c.get("name") and isinstance(c["name"], str) and c["name"].strip()
]
def import_body_cities(
body_info: dict,
conn: sqlite3.Connection,
hq_index: dict[str, str],
dry_run: bool,
verbose: bool,
) -> dict:
"""Import atlas_city_names rows for one body.
Returns a dict with:
status: 'imported' | 'no_cities' | 'error'
imported: count of rows written
message: detail (on error)
"""
body_id = body_info["body_id"]
terrain_ref = body_info["terrain_reference"]
economic_role = body_info.get("economic_role") or "unknown"
corp_id = hq_index.get(body_id)
body_dir = REPO_ROOT / Path(terrain_ref).parent
cities = _load_city_records(body_dir)
if not cities:
return {"status": "no_cities", "imported": 0}
if verbose:
print(f" {body_id}: {len(cities)} cities, economic_role={economic_role}"
+ (f", corp_hq={corp_id}" if corp_id else ""))
if not dry_run:
# Full rebuild for this body: delete existing rows, re-insert.
conn.execute("DELETE FROM atlas_city_names WHERE body_id = ?", (body_id,))
for city in cities:
name = city["name"].strip()
kind = city.get("kind") or "city"
population = int(city.get("population") or 0)
# Only set corp_id on the capital city of a corp HQ body.
city_corp_id = corp_id if (kind == "capital" and corp_id) else None
conn.execute(
"""INSERT INTO atlas_city_names
(body_id, name, kind, economic_role, population, corp_id,
reserved, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, datetime('now'))""",
(body_id, name, kind, economic_role, population, city_corp_id),
)
return {"status": "imported", "imported": len(cities)}
def main() -> None:
parser = argparse.ArgumentParser(
description="Populate atlas_city_names from wiki markers.json (#908, #909)"
)
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
parser.add_argument("--body", help="Process only this body_id")
parser.add_argument("--dry-run", action="store_true",
help="Read and validate without writing to DB")
parser.add_argument("--verbose", action="store_true",
help="Print per-body detail")
args = parser.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
print(f"\n City Names Import (#908 + #909)")
print(f" DB: {db_path}")
if args.dry_run:
print(f" Mode: DRY RUN (no DB writes)")
print()
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON")
ensure_atlas_schema(conn)
hq_index = _build_hq_index(conn)
bodies = query_inhabited_bodies(conn)
if args.body:
bodies = [b for b in bodies if b["body_id"] == args.body]
if not bodies:
print(f"error: body '{args.body}' not found or has no terrain_reference",
file=sys.stderr)
conn.close()
sys.exit(1)
print(f" {len(bodies)} inhabited bodies with terrain_reference")
print(f" {len(hq_index)} corp HQ body mappings\n")
t_total = time.time()
n_imported = 0
n_no_cities = 0
n_errors = 0
total_rows = 0
for i, body_info in enumerate(bodies):
body_id = body_info["body_id"]
result = import_body_cities(body_info, conn, hq_index, args.dry_run, args.verbose)
status = result["status"]
if status == "imported":
n_imported += 1
total_rows += result["imported"]
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} cities")
elif status == "no_cities":
n_no_cities += 1
elif status == "error":
n_errors += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
if not args.dry_run:
conn.commit()
conn.close()
elapsed = time.time() - t_total
print(f"\n Done in {elapsed:.1f}s")
print(f" bodies_with_cities={n_imported} no_cities={n_no_cities} "
f"errors={n_errors} total_rows={total_rows}")
if __name__ == "__main__":
main()
-430
View File
@@ -1,430 +0,0 @@
"""naming_core.py — Shared algorithms for the atlas naming pipeline.
Contains: Levenshtein distance, distinctiveness ranking, batch prompt
building, mood pool, name validation, and response parsing. Used by
both gemma_naming.py (production pipeline) and test scripts.
Version history:
0.1 2026-04-16 Initial extraction from test_batch_naming.py
- levenshtein, word_avg_distance, select_distinct
- build_batch_prompt with mood injection
- parse_batch_response with dedup
- name_features_batch with adjacent-register refill
- is_valid_name prompt-fragment filter
0.2 2026-04-17 Post-QA hardening
- few-shot example blocklist (prevents prompt bleed)
- minimum name length raised to 3 chars
- bracket/number rejection in is_valid_name
- parse_batch_response filters few-shot examples
0.3 2026-04-21 Generator-patch follow-up (#853)
- compass-direction negative example in build_batch_prompt
- river flow/current filter in is_valid_name(feature_type)
- parse_batch_response / name_features_batch pass feature_type
"""
__version__ = "0.3"
import hashlib
# ---------------------------------------------------------------------------
# Mood pool — randomized per-body emotional seed for vocabulary divergence
# ---------------------------------------------------------------------------
MOOD_POOL = [
"ambition", "family", "wealth", "community", "industry",
"pride", "fleeting", "hope", "fear", "isolation",
"devotion", "defiance", "loss",
]
def mood_for_body(body_id: str, world_seed: int = 42) -> str:
"""Deterministic mood selection per body."""
h = int(hashlib.sha256(f"mood|{body_id}|{world_seed}".encode()).hexdigest()[:8], 16)
return MOOD_POOL[h % len(MOOD_POOL)]
# ---------------------------------------------------------------------------
# Levenshtein distance and distinctiveness ranking
# ---------------------------------------------------------------------------
def levenshtein(a: str, b: str) -> int:
"""Standard Levenshtein edit distance."""
if len(a) < len(b):
return levenshtein(b, a)
if not b:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a):
curr = [i + 1]
for j, cb in enumerate(b):
cost = 0 if ca == cb else 1
curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost))
prev = curr
return prev[-1]
def _words(name: str) -> list[str]:
"""Split a name into lowercase words for per-word comparison."""
return [w for w in name.lower().split() if w]
def word_avg_distance(a: str, b: str) -> float:
"""Average Levenshtein distance across word pairs.
Compares each word in the shorter name against the closest word in
the longer name, then averages. Shared structural words (Serra,
The, Mount) lower the score but don't block — the unique words
pull the average up.
Returns 0.0 for identical, higher = more distinct.
"""
wa, wb = _words(a), _words(b)
if not wa or not wb:
return float(levenshtein(a.lower(), b.lower()))
shorter, longer = (wa, wb) if len(wa) <= len(wb) else (wb, wa)
total = 0.0
for sw in shorter:
best = min(levenshtein(sw, lw) for lw in longer)
total += best
return total / len(shorter)
def min_avg_distance_to_set(name: str, existing: list[str]) -> float:
"""Minimum word-average distance from name to any name in the set."""
if not existing:
return 999.0
return min(word_avg_distance(name, e) for e in existing)
def select_distinct(
candidates: list[str],
count: int,
taken: list[str],
) -> list[str]:
"""Greedily select the N most distinct names from candidates.
No hard rejection — all candidates are eligible except exact
matches to taken names. Ranked by distinctiveness (word-average
Levenshtein distance) against both taken names and previously
selected names. The most distinct candidate is picked first, then
the next most distinct relative to the growing set, until the
quota is filled.
Shared words (Serra, The, Forum) lower a candidate's preference
but never block it. The greedy approach naturally spaces out
selections.
"""
# Hard-filter exact matches to taken (case-insensitive)
taken_lower = {t.lower() for t in taken}
pool = [c for c in candidates if c.lower() not in taken_lower]
selected: list[str] = []
reference: list[str] = list(taken)
for _ in range(min(count, len(pool))):
if not pool:
break
best_idx = 0
best_score = -1.0
for i, name in enumerate(pool):
score = min_avg_distance_to_set(name, reference) if reference else 999.0
if score > best_score:
best_score = score
best_idx = i
chosen = pool.pop(best_idx)
selected.append(chosen)
reference.append(chosen)
return selected
# ---------------------------------------------------------------------------
# Name validation
# ---------------------------------------------------------------------------
# Few-shot examples used in batch prompts. These must be blocked from
# appearing as output — Gemma pattern-completes them verbatim, and
# without this blocklist they appear 50-100x across the reach.
FEWSHOT_BLOCKLIST = {
"glen moray", "dunvegan ridge", "torridon", "cairn brae",
"the kelpie's spine", "kloosterbeek", "nieuw rijn",
"hoogland run", "van diemen's creek",
}
def is_valid_name(name: str, feature_type: str = "") -> bool:
"""Filter out garbage: too short, too long, contains periods/brackets,
looks like a prompt fragment, matches a few-shot example, or contains
digits.
Pass feature_type="river" to also reject navigational vocabulary
(Flow, Current) that bleeds from ocean naming into river names (#853).
"""
if not name or len(name) < 3 or len(name) > 50:
return False
# Brackets, periods, digits — structural garbage
if "." in name or "(" in name or ")" in name or "[" in name or "]" in name:
return False
if any(c.isdigit() for c in name):
return False
low = name.lower()
# Prompt fragment echoes
reject_phrases = [
"names", "style:", "answer:", "must be", "avoid", "distinct",
"already used", "do not", "need", "generate", "list",
"number only", "comma-separated", "best:",
]
if any(phrase in low for phrase in reject_phrases):
return False
# Few-shot example bleed
if low in FEWSHOT_BLOCKLIST:
return False
# River-specific: reject navigational/oceanic vocabulary (#853 §6)
# "X Flow", "X Current" read oddly for rivers — these are ocean terms.
if feature_type == "river":
words = low.split()
if words and words[-1] in ("flow", "current"):
return False
return True
def parse_batch_response(raw: str, feature_type: str = "") -> list[str]:
"""Parse a batch naming response into a list of clean, unique name strings.
Takes the first line only (model often continues with explanations
or more styles), splits on commas, strips quotes/whitespace,
filters invalid names, and deduplicates (preserving order).
Pass feature_type to enable feature-specific filtering (e.g. river
flow/current rejection via is_valid_name).
"""
first_line = raw.strip().split("\n")[0] if raw.strip() else ""
candidates = [
n.strip().strip('"').strip("'").strip()
for n in first_line.split(",")
]
# Deduplicate preserving order (model often repeats names in batch)
seen: set[str] = set()
unique: list[str] = []
for n in candidates:
if is_valid_name(n, feature_type) and n.lower() not in seen:
unique.append(n)
seen.add(n.lower())
return unique
# ---------------------------------------------------------------------------
# Batch prompt building
# ---------------------------------------------------------------------------
def build_batch_prompt(
feature_type: str,
inflection: str,
count: int,
taken: list[str],
prompt_config: dict,
system_name: str | None = None,
body_name: str | None = None,
system_hook: str | None = None,
mood: str | None = None,
cultural_history: str | None = None,
ctx_size: int = 1024,
) -> str:
"""Build a batch naming prompt asking for N names in one call.
Uses the same preamble structure as the single-name prompts but
with few-shot examples showing comma-separated lists. The model
pattern-completes the list.
`cultural_history` threads secondary cultural registers into the
prompt so names reflect the layered settlement history of a corridor
rather than only the primary inflection style (#886 §6).
The prompt is truncated to fit within ctx_size tokens (rough
estimate: 1 token ≈ 4 chars).
"""
cfg = prompt_config.get(feature_type)
if not cfg:
return ""
subject = cfg["subject"]
verb = "called" if subject.startswith("their ") else "named"
mood_clause = ""
if mood:
mood_clause = f" The settlers here had a sense of {mood}. "
preamble = (
f"Settlers {verb} {subject} after themselves, after what they saw, "
f"or after places back home. Most names are mundane, short, and "
f"direct — a surname, a landform, a family name, a practical "
f"description.{mood_clause}Avoid the obvious choice. Each name must "
f"be distinct — no two names may share a root word. "
f"Do NOT name features after compass directions "
f"(Eastern Range, Northern Heights, Western Pass — "
f"settlers name places after people and events, not bearings).\n"
f"Reply with ONLY a comma-separated list, no numbering, no markdown."
)
lines = [preamble, ""]
if system_name or body_name:
ident = []
if system_name:
ident.append(f"System: {system_name}")
if body_name:
ident.append(f"Planet: {body_name}")
lines.append(". ".join(ident) + ".")
if system_hook:
lines.append(f"About the system: {system_hook}")
if cultural_history:
lines.append(f"Settlement history: {cultural_history}")
if taken:
lines.append(f"Already used (do NOT repeat): {', '.join(taken)}")
if system_name or body_name or system_hook or cultural_history or taken:
lines.append("")
# Few-shot examples showing batch format
lines.append("Style: Scottish Highland. 5 names: Glen Moray, Dunvegan Ridge, Torridon, Cairn Brae, The Kelpie's Spine")
lines.append("Style: Dutch colonial. 4 names: Kloosterbeek, Nieuw Rijn, Hoogland Run, Van Diemen's Creek")
lines.append("")
ask_for = count * 2 # oversample, then rank by distinctiveness
tail = f"Style: {inflection}. {ask_for} names:"
# Truncate context to fit within ctx_size
fixed = "\n".join(lines)
max_chars = (ctx_size - 16) * 4 # 16 tokens headroom for output
budget = max_chars - len(fixed) - len(tail) - 2 # 2 for newlines
if budget < 0:
# Trim taken list first (preserving cultural_history context)
while taken and budget < 0:
taken = taken[:-1]
lines_rebuild = [preamble, ""]
if system_name or body_name:
ident = []
if system_name:
ident.append(f"System: {system_name}")
if body_name:
ident.append(f"Planet: {body_name}")
lines_rebuild.append(". ".join(ident) + ".")
if system_hook:
lines_rebuild.append(f"About the system: {system_hook}")
if cultural_history:
lines_rebuild.append(f"Settlement history: {cultural_history}")
if taken:
lines_rebuild.append(f"Already used (do NOT repeat): {', '.join(taken)}")
lines_rebuild.append("")
lines_rebuild.append("Style: Scottish Highland. 5 names: Glen Moray, Dunvegan Ridge, Torridon, Cairn Brae, The Kelpie's Spine")
lines_rebuild.append("Style: Dutch colonial. 4 names: Kloosterbeek, Nieuw Rijn, Hoogland Run, Van Diemen's Creek")
lines_rebuild.append("")
fixed = "\n".join(lines_rebuild)
budget = max_chars - len(fixed) - len(tail) - 2
return fixed + "\n" + tail
# ---------------------------------------------------------------------------
# Batch naming with refill
# ---------------------------------------------------------------------------
def name_features_batch(
voice, # VoiceSubprocess — not typed to avoid circular import
feature_type: str,
count: int,
inflection: str,
corridor: str,
corridor_substyles: list[dict[str, str]],
taken: list[str],
prompt_config: dict,
system_name: str | None,
body_name: str | None,
system_hook: str | None,
mood: str | None,
body_id: str,
world_seed: int,
cultural_history: str | None = None,
ctx_size: int = 1024,
) -> list[str]:
"""Generate `count` names for a feature type using batch prompting.
Flow:
1. Build a batch prompt asking for count*2 names.
2. Send to voice, parse response.
3. Rank by distinctiveness via Levenshtein, pick top `count`.
4. If short, refill from the next adjacent register in the corridor.
5. Return the final list of names.
`cultural_history` is forwarded to build_batch_prompt to enrich the
prompt with secondary cultural context (#886 §6).
"""
prompt = build_batch_prompt(
feature_type=feature_type,
inflection=inflection,
count=count,
taken=taken,
prompt_config=prompt_config,
system_name=system_name,
body_name=body_name,
system_hook=system_hook,
mood=mood,
cultural_history=cultural_history,
ctx_size=ctx_size,
)
seed = int(hashlib.sha256(
f"batch|{body_id}|{feature_type}|{world_seed}".encode()
).hexdigest()[:8], 16)
try:
raw = voice.request(prompt, seed)
except RuntimeError:
raw = ""
candidates = parse_batch_response(raw, feature_type)
selected = select_distinct(candidates, count, taken)
# Refill from adjacent register if we didn't fill the quota
if len(selected) < count and corridor_substyles:
shortfall = count - len(selected)
refill_taken = taken + selected
# Find the primary register's index and pick the next one
primary_idx = next(
(i for i, s in enumerate(corridor_substyles)
if s["inflection"] == inflection),
0,
)
refill_idx = (primary_idx + 1) % len(corridor_substyles)
refill_inflection = corridor_substyles[refill_idx]["inflection"]
refill_prompt = build_batch_prompt(
feature_type=feature_type,
inflection=refill_inflection,
count=shortfall * 3,
taken=refill_taken,
prompt_config=prompt_config,
system_name=system_name,
body_name=body_name,
system_hook=system_hook,
mood=mood,
cultural_history=cultural_history,
ctx_size=ctx_size,
)
refill_seed = int(hashlib.sha256(
f"refill|{body_id}|{feature_type}|{world_seed}".encode()
).hexdigest()[:8], 16)
try:
refill_raw = voice.request(refill_prompt, refill_seed)
except RuntimeError:
refill_raw = ""
refill_candidates = parse_batch_response(refill_raw, feature_type)
extra = select_distinct(refill_candidates, shortfall, refill_taken)
selected.extend(extra)
return selected
@@ -10,7 +10,7 @@ For each inhabited body with NULL terrain_reference:
3. Update terrain_reference to the relative path.
Bodies with missing heightmaps are logged to stdout for remediation.
This is the prerequisite for generate_atlas.py (#832).
This is the prerequisite for the atlas importers (import_heightmaps.py, #901).
Usage:
python3 tooling/planet-gen/populate_terrain_reference.py
@@ -131,7 +131,7 @@ def main():
if missing:
print(f" Action required: generate heightmaps for {len(missing)} bodies "
f"before running generate_atlas.py (#832).")
f"before running the atlas importers (#901).")
print(f" Use: make generate-terrain (or run generate.py per body)\n")
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env python3
"""
prune_atlas_features.py — Cap per-body feature counts so markers.json
files stay readable at atlas scale.
Background: the upstream terrain pipeline detects every distinct mountain
cluster as a separate `mountain_range` entry, which produces bodies with
40-80 ranges at 512×256 grid resolution. Similarly for rivers. At atlas
zoom those are noise, not information — a planet doesn't need 48 named
ridges for the player to recognise the continent shape.
This pass ranks each feature type by a size proxy and keeps the top N:
- mountain_ranges: sorted by `area_cells` descending, top 8
- rivers: sorted by `len(path)` descending, top 6
- oceans/seas/lakes: untouched (already small per body)
- cities/pois: untouched (generated by generate_atlas.py, not here)
Excluded: anything under `wiki/star-systems/GJ-0/` (Sol). Sol bodies
will be hand-authored and must not be touched by automated pruning.
Each pruned body gets its atlas_* rows re-synced via sync_markers_to_db
so the DB mirror stays consistent with the on-disk JSON.
Usage:
tooling/planet-gen/prune_atlas_features.py
tooling/planet-gen/prune_atlas_features.py --max-mtns 8 --max-rivers 6
tooling/planet-gen/prune_atlas_features.py --dry-run
tooling/planet-gen/prune_atlas_features.py --body GJ144d
Safe to re-run — idempotent when a body is already within the caps.
"""
import argparse
import json
import sqlite3
import sys
import time
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
# Path fragments that are never pruned. Sol is hand-authored.
EXCLUDED_SYSTEMS = {"GJ-0"}
sys.path.insert(0, str(TOOLING_DIR))
from generate_atlas import ensure_atlas_schema, sync_markers_to_db # noqa: E402
def _system_slug(markers_path: Path) -> str:
# wiki/star-systems/GJ-244A/bodies/GJ244Ab/markers.json → GJ-244A
return markers_path.parent.parent.parent.name
def _body_id(markers_path: Path) -> str:
return markers_path.parent.name
def prune_mountains(markers: dict, cap: int) -> int:
mtns = markers.get("mountain_ranges") or []
if len(mtns) <= cap:
return 0
# Named features sort first (preserve hand-authored names),
# then by area_cells descending.
ranked = sorted(
mtns,
key=lambda m: (0 if m.get("name") else 1, -(int(m.get("area_cells") or 0))),
)
markers["mountain_ranges"] = ranked[:cap]
return len(mtns) - cap
def prune_rivers(markers: dict, cap: int) -> int:
rivers = markers.get("rivers") or []
if len(rivers) <= cap:
return 0
# Named features sort first (preserve hand-authored names),
# then by path length descending.
ranked = sorted(
rivers,
key=lambda r: (0 if r.get("name") else 1, -len(r.get("path") or [])),
)
markers["rivers"] = ranked[:cap]
return len(rivers) - cap
def main():
parser = argparse.ArgumentParser(
description="Prune oversized mountain_ranges / rivers in every "
"markers.json (except Sol)"
)
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
parser.add_argument("--body", help="Process only this body_id")
parser.add_argument("--max-mtns", type=int, default=8,
help="Max mountain_ranges per body (default: 8)")
parser.add_argument("--max-rivers", type=int, default=6,
help="Max rivers per body (default: 6)")
parser.add_argument("--dry-run", action="store_true",
help="Report counts without writing")
parser.add_argument("--verbose", action="store_true",
help="Print every body's before/after counts")
args = parser.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(str(db_path), timeout=30.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=15000")
conn.execute("PRAGMA foreign_keys=ON")
ensure_atlas_schema(conn)
# Preload the set of valid body_ids so we can skip orphan wiki
# folders that have no matching row in the bodies table. Otherwise
# sync_markers_to_db hits a FK violation on atlas_body_grids insert.
valid_body_ids = {
r[0] for r in conn.execute("SELECT body_id FROM bodies").fetchall()
}
all_markers = sorted(WIKI_SYSTEMS.glob("*/bodies/*/markers.json"))
if args.body:
all_markers = [p for p in all_markers if _body_id(p) == args.body]
total_bodies = 0
skipped_excluded = 0
touched_bodies = 0
mtns_dropped = 0
rivers_dropped = 0
t0 = time.time()
print(f"\n prune_atlas_features.py")
print(f" DB: {db_path}")
print(f" Max mtns: {args.max_mtns}")
print(f" Max rivers: {args.max_rivers}")
if args.dry_run:
print(f" Mode: DRY RUN")
print(f" {len(all_markers)} markers.json files to scan")
print()
for markers_path in all_markers:
total_bodies += 1
slug = _system_slug(markers_path)
if slug in EXCLUDED_SYSTEMS:
skipped_excluded += 1
if args.verbose:
print(f" SKIP (excluded system) {markers_path}")
continue
try:
markers = json.loads(markers_path.read_text())
except json.JSONDecodeError as e:
print(f" ERROR: invalid JSON in {markers_path}: {e}")
continue
body_id = _body_id(markers_path)
dropped_mtns = prune_mountains(markers, args.max_mtns)
dropped_rivers = prune_rivers(markers, args.max_rivers)
if dropped_mtns or dropped_rivers:
touched_bodies += 1
mtns_dropped += dropped_mtns
rivers_dropped += dropped_rivers
if args.verbose or dropped_mtns >= 20:
print(f" {body_id:14s} {slug:8s} "
f"{dropped_mtns} mtns, {dropped_rivers} rivers")
if not args.dry_run:
markers_path.write_text(
json.dumps(markers, indent=2) + "\n"
)
if body_id in valid_body_ids:
sync_markers_to_db(conn, body_id, markers)
elif args.verbose:
print(f" (no DB row for {body_id} — skipping sync)")
# Periodic log line so the user sees progress on a long run.
if total_bodies % 250 == 0:
rate = total_bodies / max(time.time() - t0, 1e-6)
print(f" scanned {total_bodies}/{len(all_markers)} bodies "
f"({rate:.0f}/s) touched {touched_bodies}")
if not args.dry_run:
conn.commit()
conn.close()
elapsed = time.time() - t0
print()
print(f" Done: {elapsed:.1f}s")
print(f" bodies scanned: {total_bodies}")
print(f" excluded (Sol etc.): {skipped_excluded}")
print(f" bodies pruned: {touched_bodies}")
print(f" mountain ranges dropped: {mtns_dropped}")
print(f" rivers dropped: {rivers_dropped}")
if args.dry_run:
print(f"\n Dry run — no files written, no DB changes.")
print()
if __name__ == "__main__":
main()
-523
View File
@@ -1,523 +0,0 @@
#!/usr/bin/env python3
"""QA report on atlas naming quality and distribution.
Runs checks against systems.db and markers.json files:
- Exact duplicates within systems
- Stem repetition (shared root words)
- Prompt fragment leaks
- Register bleed (wrong cultural register for corridor)
- Feature-type mismatches (street names as mountains, etc.)
- Short/long name outliers
- Body-name echo (planet name used as feature stem)
- Coverage gaps
- Distribution by corridor and register
Usage:
python3 tooling/planet-gen/qa_naming.py
python3 tooling/planet-gen/qa_naming.py --verbose
"""
import re
import sqlite3
import sys
from collections import Counter, defaultdict
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
sys.path.insert(0, str(TOOLING_DIR))
from naming_core import _words
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def load_all_names(conn):
"""Load all named features grouped by system and body."""
results = []
for table, ftype in [
("atlas_cities", "city"),
("atlas_rivers", "river"),
("atlas_mountain_ranges", "mountain"),
("atlas_oceans", "ocean"),
("atlas_pois", "poi"),
]:
rows = conn.execute(f"""
SELECT a.body_id, a.name, b.system_id,
COALESCE(s.geographic_sector, 'unknown') as corridor,
COALESCE(s.proper_name, s.system_id) as system_name,
COALESCE(b.proper_name, '') as body_name,
b.inhabited
FROM {table} a
JOIN bodies b ON a.body_id = b.body_id
JOIN star_systems s ON b.system_id = s.system_id
WHERE a.name IS NOT NULL AND a.name != ''
""").fetchall()
for body_id, name, system_id, corridor, sys_name, body_name, inhabited in rows:
results.append({
"body_id": body_id,
"name": name,
"system_id": system_id,
"corridor": corridor,
"system_name": sys_name,
"body_name": body_name,
"feature_type": ftype,
"inhabited": bool(inhabited),
})
return results
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
def check_prompt_fragments(names):
"""Find names that look like prompt leaks."""
fragments = [
"style:", "answer:", "insert your", "example", "placeholder",
"number only", "names:", "generate", "already used", "do not",
"must be", "distinct", "comma-separated", "best:", "option",
]
hits = []
for n in names:
low = n["name"].lower()
for frag in fragments:
if frag in low:
hits.append((n["name"], n["body_id"], n["system_name"], frag))
break
return hits
def check_exact_dupes_within_system(names):
"""Find exact duplicate names within the same system + feature type."""
by_sys_type = defaultdict(list)
for n in names:
key = (n["system_id"], n["feature_type"])
by_sys_type[key].append(n)
dupes = []
for key, group in by_sys_type.items():
seen = {}
for n in group:
low = n["name"].lower()
if low in seen:
dupes.append((n["name"], n["body_id"], seen[low], n["system_name"], n["feature_type"]))
else:
seen[low] = n["body_id"]
return dupes
def check_exact_dupes_within_body(names):
"""Find exact duplicate names within the same body across all types."""
by_body = defaultdict(list)
for n in names:
by_body[n["body_id"]].append(n)
dupes = []
for body_id, group in by_body.items():
seen = {}
for n in group:
low = n["name"].lower()
if low in seen:
dupes.append((n["name"], body_id, n["feature_type"], seen[low], n["system_name"]))
else:
seen[low] = n["feature_type"]
return dupes
def check_body_name_echo(names):
"""Find names where the body/system proper name dominates."""
hits = []
for n in names:
if not n["body_name"]:
continue
body_stem = n["body_name"].lower()
name_low = n["name"].lower()
# Check if body name appears as a word in the feature name
name_words = set(name_low.split())
body_words = set(body_stem.split())
if body_words & name_words:
hits.append((n["name"], n["body_name"], n["body_id"], n["system_name"]))
return hits
def check_register_bleed(names):
"""Find names that look like they're from the wrong cultural register.
Uses keyword heuristics — not perfect but catches obvious mismatches.
"""
# Register keywords that should NOT appear in certain corridors
bleed_patterns = {
"core": {
"wrong": ["kimchi", "samurai", "fjord", "veld", "kopje", "baobab"],
},
"east_reach": {
"wrong": ["bramble", "meadow", "thatch", "croft", "basilica", "forum", "senate"],
},
"west_reach": {
"wrong": ["sakura", "bamboo", "lotus", "jade", "dragon", "phoenix"],
},
"south_reach": {
"wrong": ["fjord", "viking", "norse", "highland", "glen"],
},
"north_reach": {
"wrong": ["sakura", "bamboo", "jade", "polder", "graben"],
},
}
# NZ/Australian names in non-core/north corridors
nz_keywords = ["pōhutukawa", "waitara", "wairarapa", "fiordland", "aotearoa",
"rangitoto", "wellington", "canterbury", "auckland", "otago",
"kauri", "pukekohe", "taranaki", "moana"]
hits = []
for n in names:
low = n["name"].lower()
corridor = n["corridor"]
# Check NZ bleed into non-Australian registers
if corridor not in ("core", "north_reach"):
for kw in nz_keywords:
if kw in low:
hits.append((n["name"], n["body_id"], corridor, n["system_name"],
f"NZ/AU keyword '{kw}' in {corridor}"))
break
# Check corridor-specific wrong keywords
if corridor in bleed_patterns:
for kw in bleed_patterns[corridor]["wrong"]:
if kw in low:
hits.append((n["name"], n["body_id"], corridor, n["system_name"],
f"keyword '{kw}' wrong for {corridor}"))
break
return hits
def check_feature_type_mismatch(names):
"""Find names that sound wrong for their feature type."""
# Street/road names shouldn't be mountains
street_words = {"street", "avenue", "boulevard", "drive", "road", "lane",
"way", "highway", "route", "thoroughfare"}
# Building names shouldn't be rivers/oceans
building_words = {"hall", "house", "building", "tower", "plaza", "square",
"station", "terminal", "center", "centre"}
hits = []
for n in names:
words = set(n["name"].lower().split())
if n["feature_type"] == "mountain" and words & street_words:
hits.append((n["name"], n["body_id"], n["feature_type"], n["system_name"],
f"street name as mountain"))
if n["feature_type"] in ("river", "ocean") and words & building_words:
# Allow "Hall" for classical register
if n["corridor"] != "core":
hits.append((n["name"], n["body_id"], n["feature_type"], n["system_name"],
f"building name as {n['feature_type']}"))
return hits
def check_stem_repetition(names):
"""Find bodies where too many features share the same first word."""
by_body = defaultdict(list)
for n in names:
by_body[n["body_id"]].append(n)
hits = []
for body_id, group in by_body.items():
# Count first significant word per feature type
by_type = defaultdict(list)
for n in group:
by_type[n["feature_type"]].append(n["name"])
for ftype, fnames in by_type.items():
if len(fnames) < 4:
continue
first_words = [_words(name)[0] if _words(name) else "" for name in fnames]
counts = Counter(first_words)
for word, count in counts.most_common(3):
if count >= 4 and word:
hits.append((body_id, ftype, word, count, len(fnames),
group[0]["system_name"]))
return hits
def check_short_long_names(names):
"""Find very short (1 word, ≤3 chars) or very long names."""
short = [(n["name"], n["body_id"], n["system_name"])
for n in names if len(n["name"]) <= 3]
long_ = [(n["name"], n["body_id"], n["system_name"])
for n in names if len(n["name"]) > 40]
return short, long_
def check_numbers_in_names(names):
"""Find names containing digits."""
return [(n["name"], n["body_id"], n["system_name"])
for n in names if re.search(r"\d", n["name"])]
def corridor_distribution(names):
"""Count names per corridor."""
counts = Counter(n["corridor"] for n in names)
return counts
def feature_type_distribution(names):
"""Count names per feature type."""
counts = Counter(n["feature_type"] for n in names)
return counts
def coverage_gaps(conn):
"""Find bodies with unnamed features."""
gaps = []
for table, ftype in [
("atlas_mountain_ranges", "mountain"),
("atlas_oceans", "ocean"),
("atlas_pois", "poi"),
]:
rows = conn.execute(f"""
SELECT a.body_id, COUNT(*) as total,
SUM(CASE WHEN a.name IS NULL OR a.name = '' THEN 1 ELSE 0 END) as blank,
COALESCE(s.proper_name, b.system_id) as sys_name,
b.inhabited
FROM {table} a
JOIN bodies b ON a.body_id = b.body_id
JOIN star_systems s ON b.system_id = s.system_id
GROUP BY a.body_id
HAVING blank > 0
ORDER BY b.inhabited DESC, blank DESC
""").fetchall()
for body_id, total, blank, sys_name, inhabited in rows:
gaps.append((body_id, ftype, blank, total, sys_name, bool(inhabited)))
return gaps
def most_common_names(names, top_n=20):
"""Find the most frequently used names across all systems."""
counts = Counter(n["name"].lower() for n in names)
return counts.most_common(top_n)
# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------
def main():
verbose = "--verbose" in sys.argv
conn = sqlite3.connect(str(DB_PATH), timeout=30.0)
print("Loading named features from DB...")
names = load_all_names(conn)
print(f" {len(names):,} named features loaded\n")
# === Distribution ===
print("=" * 70)
print("DISTRIBUTION")
print("=" * 70)
print("\nBy corridor:")
for corridor, count in sorted(corridor_distribution(names).items(), key=lambda x: -x[1]):
print(f" {corridor:20s} {count:>6,}")
print("\nBy feature type:")
for ftype, count in sorted(feature_type_distribution(names).items(), key=lambda x: -x[1]):
print(f" {ftype:20s} {count:>6,}")
# === Most common names ===
print(f"\n{'=' * 70}")
print("MOST COMMON NAMES (potential over-generation)")
print("=" * 70)
for name, count in most_common_names(names, 30):
if count >= 3:
print(f" {count:>4}x {name}")
# === Prompt fragments ===
print(f"\n{'=' * 70}")
print("PROMPT FRAGMENT LEAKS")
print("=" * 70)
fragments = check_prompt_fragments(names)
if fragments:
for name, body, sys_name, frag in fragments[:20]:
print(f" [{sys_name}/{body}] \"{name}\" (matched: {frag})")
if len(fragments) > 20:
print(f" ... and {len(fragments) - 20} more")
else:
print(" None found ✓")
print(f" Total: {len(fragments)}")
# === Exact dupes within system ===
print(f"\n{'=' * 70}")
print("EXACT DUPLICATES WITHIN SYSTEM (same name, same feature type)")
print("=" * 70)
sys_dupes = check_exact_dupes_within_system(names)
if sys_dupes:
for name, body1, body2, sys_name, ftype in sys_dupes[:20]:
print(f" [{sys_name}] \"{name}\" ({ftype}) on {body1} and {body2}")
if len(sys_dupes) > 20:
print(f" ... and {len(sys_dupes) - 20} more")
else:
print(" None found ✓")
print(f" Total: {len(sys_dupes)}")
# === Exact dupes within body ===
print(f"\n{'=' * 70}")
print("EXACT DUPLICATES WITHIN BODY (same name, different feature types)")
print("=" * 70)
body_dupes = check_exact_dupes_within_body(names)
if body_dupes:
for name, body, ftype1, ftype2, sys_name in body_dupes[:20]:
print(f" [{sys_name}/{body}] \"{name}\" as {ftype1} and {ftype2}")
if len(body_dupes) > 20:
print(f" ... and {len(body_dupes) - 20} more")
else:
print(" None found ✓")
print(f" Total: {len(body_dupes)}")
# === Stem repetition ===
print(f"\n{'=' * 70}")
print("STEM REPETITION (4+ features sharing first word on same body)")
print("=" * 70)
stems = check_stem_repetition(names)
if stems:
for body, ftype, word, count, total, sys_name in stems[:20]:
print(f" [{sys_name}/{body}] \"{word}\" appears {count}/{total} times in {ftype}s")
if len(stems) > 20:
print(f" ... and {len(stems) - 20} more")
else:
print(" None found ✓")
print(f" Total: {len(stems)}")
# === Body name echo ===
print(f"\n{'=' * 70}")
print("BODY NAME ECHO (planet name appears in feature name)")
print("=" * 70)
echoes = check_body_name_echo(names)
if echoes:
# Group by body
by_body = defaultdict(list)
for name, body_name, body_id, sys_name in echoes:
by_body[(body_id, body_name, sys_name)].append(name)
for (body_id, body_name, sys_name), echo_names in sorted(
by_body.items(), key=lambda x: -len(x[1])
)[:15]:
print(f" [{sys_name}/{body_id}] body=\"{body_name}\": {', '.join(echo_names[:5])}"
f"{'...' if len(echo_names) > 5 else ''} ({len(echo_names)} total)")
else:
print(" None found ✓")
print(f" Total: {len(echoes)} names across {len(set(e[2] for e in echoes))} bodies")
# === Register bleed ===
print(f"\n{'=' * 70}")
print("REGISTER BLEED (cultural mismatch for corridor)")
print("=" * 70)
bleeds = check_register_bleed(names)
if bleeds:
for name, body, corridor, sys_name, reason in bleeds[:30]:
print(f" [{sys_name}/{body}] \"{name}\"{reason}")
if len(bleeds) > 30:
print(f" ... and {len(bleeds) - 30} more")
else:
print(" None found ✓")
print(f" Total: {len(bleeds)}")
# === Feature type mismatch ===
print(f"\n{'=' * 70}")
print("FEATURE TYPE MISMATCH (street names as mountains, etc.)")
print("=" * 70)
mismatches = check_feature_type_mismatch(names)
if mismatches:
for name, body, ftype, sys_name, reason in mismatches[:20]:
print(f" [{sys_name}/{body}] \"{name}\"{reason}")
if len(mismatches) > 20:
print(f" ... and {len(mismatches) - 20} more")
else:
print(" None found ✓")
print(f" Total: {len(mismatches)}")
# === Short/long names ===
print(f"\n{'=' * 70}")
print("SHORT NAMES (≤3 chars)")
print("=" * 70)
short, long_ = check_short_long_names(names)
if short:
for name, body, sys_name in short[:15]:
print(f" [{sys_name}/{body}] \"{name}\"")
if len(short) > 15:
print(f" ... and {len(short) - 15} more")
else:
print(" None found ✓")
print(f" Total: {len(short)}")
print(f"\n{'=' * 70}")
print("LONG NAMES (>40 chars)")
print("=" * 70)
if long_:
for name, body, sys_name in long_[:15]:
print(f" [{sys_name}/{body}] \"{name}\"")
else:
print(" None found ✓")
print(f" Total: {len(long_)}")
# === Numbers in names ===
print(f"\n{'=' * 70}")
print("NUMBERS IN NAMES")
print("=" * 70)
numbered = check_numbers_in_names(names)
if numbered:
for name, body, sys_name in numbered[:15]:
print(f" [{sys_name}/{body}] \"{name}\"")
else:
print(" None found ✓")
print(f" Total: {len(numbered)}")
# === Coverage gaps ===
print(f"\n{'=' * 70}")
print("COVERAGE GAPS (bodies with unnamed features)")
print("=" * 70)
gaps = coverage_gaps(conn)
inhabited_gaps = [g for g in gaps if g[5]]
uninhabited_gaps = [g for g in gaps if not g[5]]
if inhabited_gaps:
print(f"\n INHABITED bodies with gaps ({len(inhabited_gaps)}):")
for body, ftype, blank, total, sys_name, _ in inhabited_gaps[:10]:
print(f" [{sys_name}/{body}] {blank}/{total} {ftype}s unnamed")
if uninhabited_gaps:
print(f"\n Uninhabited bodies with gaps ({len(uninhabited_gaps)}):")
gap_by_type = Counter(g[1] for g in uninhabited_gaps)
for ftype, count in gap_by_type.most_common():
total_blank = sum(g[2] for g in uninhabited_gaps if g[1] == ftype)
print(f" {ftype}: {count} bodies, {total_blank} unnamed features")
# === Summary ===
print(f"\n{'=' * 70}")
print("SUMMARY")
print("=" * 70)
total = len(names)
issues = (len(fragments) + len(sys_dupes) + len(body_dupes) +
len(stems) + len(bleeds) + len(mismatches) +
len(short) + len(long_) + len(numbered))
print(f" Total named features: {total:>8,}")
print(f" Total QA issues found: {issues:>8,}")
print(f" Issue rate: {issues/total*100:>7.2f}%")
print(f" Prompt fragment leaks: {len(fragments):>8,}")
print(f" Exact dupes (system): {len(sys_dupes):>8,}")
print(f" Exact dupes (body): {len(body_dupes):>8,}")
print(f" Stem repetition: {len(stems):>8,}")
print(f" Body name echo: {len(echoes):>8,}")
print(f" Register bleed: {len(bleeds):>8,}")
print(f" Feature type mismatch: {len(mismatches):>8,}")
print(f" Short names: {len(short):>8,}")
print(f" Long names: {len(long_):>8,}")
print(f" Numbers in names: {len(numbered):>8,}")
print(f" Coverage gaps (inhabited):{len(inhabited_gaps):>8,}")
print(f" Coverage gaps (uninh.): {len(uninhabited_gaps):>8,}")
conn.close()
if __name__ == "__main__":
main()
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Run the full Gemma 2 batch naming pipeline across every markers.json
# in wiki/star-systems. Uses the gfx1201 ROCm binary via distrobox.
#
# Safe to interrupt and resume: the pipeline preserves bodies that
# already have non-empty name fields, so re-running picks up where it
# left off. A crash loses at most the current body's in-progress work.
#
# Wall time on an RX 9070 is roughly 4-6 h for the ~26k features in
# 2394 bodies. Run with `nohup` and tail the log file:
#
# nohup tooling/planet-gen/run-atlas-naming.sh > /tmp/atlas.out 2>&1 &
# tail -f .tmp/atlas-naming-*.log
set -euo pipefail
# No arguments accepted — everything is hardcoded for the overnight
# run. Guard against typos/--help slipping through to gemma_naming.py
# and starting a real run when the caller expected a help screen.
if [[ $# -gt 0 ]]; then
echo "usage: $0" >&2
echo " (no arguments; edit this script to change binary/model/container)" >&2
exit 2
fi
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"
LOG_DIR="$REPO_ROOT/.tmp"
mkdir -p "$LOG_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOG="$LOG_DIR/atlas-naming-$STAMP.log"
BIN="$HOME/Projects/settled-reach/binaries/sr-voice-tooling"
MODEL="$HOME/Projects/settled-reach/models/gemma-4.gguf"
DISTROBOX_NAME="reach-build"
if [[ ! -x "$BIN" ]]; then
echo "error: sr-voice binary not found at $BIN" >&2
echo " build with --features rocm inside $DISTROBOX_NAME" >&2
exit 1
fi
if [[ ! -f "$MODEL" ]]; then
echo "error: Gemma 2 model not found at $MODEL" >&2
exit 1
fi
if ! distrobox list 2>/dev/null | grep -q "^[[:xdigit:]]\+ *| *$DISTROBOX_NAME "; then
echo "error: distrobox container '$DISTROBOX_NAME' not found" >&2
exit 1
fi
# Sanity-check the binary is compiled for the host GPU. The llama.cpp
# HIP kernels embed their target architecture as substrings like
# "amdgcn-amd-amdhsa--gfx1201". If gfx1201 is missing and e.g. only
# gfx906 is present, the build shipped kernels for a different arch
# and every inference will crash with "invalid device function".
#
# Uses `grep -a` to scan the binary directly so we don't depend on
# `strings` being on PATH (not present on a stock Bazzite host).
if ! grep -a -q gfx1201 "$BIN"; then
echo "error: $BIN does not contain gfx1201 kernels" >&2
echo " expected target for AMD Radeon RX 9070 (Navi 48)" >&2
echo " rebuild with CMAKE_HIP_ARCHITECTURES=gfx1201 inside $DISTROBOX_NAME" >&2
exit 1
fi
echo "atlas naming run starting"
echo " bin: $BIN"
echo " model: $MODEL"
echo " distrobox: $DISTROBOX_NAME"
echo " log: $LOG"
echo " bodies: ~2394 (resume-safe — already-named skipped)"
echo " estimate: ~4-6 h on an RX 9070"
echo
exec python3 tooling/planet-gen/gemma_naming.py \
--sr-voice "$BIN" \
--model "$MODEL" \
--distrobox "$DISTROBOX_NAME" \
--log "$LOG"
-209
View File
@@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""Test batch naming: exercises naming_core against real Gemma 4.
Usage:
python3 tooling/planet-gen/test_batch_naming.py
"""
import json
import os
import signal
import subprocess
import sys
import hashlib
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from gemma_naming import (
CORRIDOR_SUBSTYLES,
DEFAULT_SUBSTYLES,
_PROMPT_CONFIG,
)
from naming_core import (
build_batch_prompt,
parse_batch_response,
select_distinct,
)
BIN = Path.home() / "Projects/settled-reach/binaries/sr-voice-tooling"
MODEL = Path.home() / "Projects/settled-reach/models/gemma-4.gguf"
DISTROBOX = "reach-build"
CTX_SIZE = 1024
# Full system simulations — 5 bodies each, accumulating taken list
SYSTEM_SIMS = [
{
"name": "Ran", "corridor": "core",
"inflection": "English countryside, rural, agricultural settlers",
"hook": "RAN does not import food.",
"bodies": [
("GJ144b", "ambition"), ("GJ144c", "community"),
("GJ144d", "fear"), ("GJ144e", "hope"), ("GJ144e-1", "loss"),
],
},
{
"name": "Groombridge", "corridor": "core",
"inflection": "British colonial settlement era",
"hook": "GROOMBRIDGE is where the money lives.",
"bodies": [
("GJ380b", "wealth"), ("GJ380c", "pride"),
("GJ380d", "industry"), ("GJ380e", "ambition"), ("GJ380f", "fleeting"),
],
},
{
"name": "Cairnside", "corridor": "deep_frontier",
"inflection": "frontier descriptive, geographic features named by surveyors",
"hook": "CAIRNSIDE is a materials science program running for forty years.",
"bodies": [
("GJ892b", "defiance"), ("GJ892c", "isolation"),
("GJ892d", "fear"), ("GJ892e", "hope"), ("GJ892f", "loss"),
],
},
{
"name": "Ratnagiri", "corridor": "north_reach",
"inflection": "South African English settler",
"hook": "RATNAGIRI has a monopoly on its primary export that no one engineered.",
"bodies": [
("GJ575Ab", "pride"), ("GJ575Ac", "community"),
("GJ575Ad", "industry"), ("GJ575Ae", "devotion"), ("GJ575Af", "ambition"),
],
},
]
# Build test list
TESTS = []
for sim in SYSTEM_SIMS:
for body_id, mood in sim["bodies"]:
TESTS.append({
"label": f"{sim['name']}{body_id} (mood: {mood})",
"system": sim["name"], "body": body_id, "corridor": sim["corridor"],
"inflection": sim["inflection"],
"feature_type": "mountain_range", "count": 8,
"taken": f"__accumulate_{sim['name']}__",
"hook": sim["hook"], "mood": mood,
})
def main():
cmd = ["distrobox", "enter", DISTROBOX, "--",
str(BIN),
"--model", str(MODEL),
"--ctx-size", str(CTX_SIZE)]
print("starting sr-voice-tooling...", flush=True)
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1,
start_new_session=True,
)
accum: dict[str, list[str]] = {}
for test in TESTS:
taken = test["taken"]
if isinstance(taken, str) and taken.startswith("__accumulate_"):
key = taken
taken = list(accum.get(key, []))
# Primary batch
prompt = build_batch_prompt(
feature_type=test["feature_type"],
inflection=test["inflection"],
count=test["count"],
taken=taken,
prompt_config=_PROMPT_CONFIG,
system_name=test["system"],
body_name=test["body"],
system_hook=test["hook"],
mood=test.get("mood"),
ctx_size=CTX_SIZE,
)
seed = int(hashlib.sha256(
f"batch|{test['body']}|{test['feature_type']}".encode()
).hexdigest()[:8], 16)
req = json.dumps({"prompt": prompt, "seed": seed})
proc.stdin.write(req + "\n")
proc.stdin.flush()
t0 = time.time()
resp_line = proc.stdout.readline()
elapsed = time.time() - t0
try:
resp = json.loads(resp_line)
raw = resp.get("text", resp.get("error", ""))
except (json.JSONDecodeError, TypeError):
raw = f"ERR: {resp_line!r}"
candidates = parse_batch_response(raw)
selected = select_distinct(candidates, test["count"], taken)
# Refill from adjacent register if short
if len(selected) < test["count"]:
shortfall = test["count"] - len(selected)
refill_taken = taken + selected
corridor = test.get("corridor", "core")
substyles = CORRIDOR_SUBSTYLES.get(corridor, DEFAULT_SUBSTYLES)
primary_idx = next(
(i for i, s in enumerate(substyles)
if s["inflection"] == test["inflection"]),
0,
)
refill_idx = (primary_idx + 1) % len(substyles)
refill_inflection = substyles[refill_idx]["inflection"]
refill_prompt = build_batch_prompt(
feature_type=test["feature_type"],
inflection=refill_inflection,
count=shortfall * 3,
taken=refill_taken,
prompt_config=_PROMPT_CONFIG,
system_name=test["system"],
body_name=test["body"],
system_hook=test["hook"],
mood=test.get("mood"),
ctx_size=CTX_SIZE,
)
refill_seed = int(hashlib.sha256(
f"refill|{test['body']}|{test['feature_type']}".encode()
).hexdigest()[:8], 16)
proc.stdin.write(json.dumps({"prompt": refill_prompt, "seed": refill_seed}) + "\n")
proc.stdin.flush()
t1 = time.time()
refill_line = proc.stdout.readline()
refill_elapsed = time.time() - t1
try:
refill_resp = json.loads(refill_line)
refill_raw = refill_resp.get("text", "")
except (json.JSONDecodeError, TypeError):
refill_raw = ""
refill_candidates = parse_batch_response(refill_raw)
extra = select_distinct(refill_candidates, shortfall, refill_taken)
print(f" REFILL ({refill_inflection}): {len(refill_candidates)} cand → {len(extra)} new: {extra}")
selected.extend(extra)
print(f" {test['label']}")
print(f" {len(candidates)} cand → {len(selected)} selected ({elapsed:.1f}s) taken={len(taken)}")
print(f" {selected}")
if isinstance(test["taken"], str) and test["taken"].startswith("__accumulate_"):
key = test["taken"]
accum.setdefault(key, []).extend(selected)
print(f" [{test['system']}: {len(accum[key])} total]")
proc.stdin.close()
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except Exception:
pass
proc.wait()
print()
if __name__ == "__main__":
main()
@@ -1,163 +0,0 @@
#!/usr/bin/env python3
"""Quick test: send register-selection prompts to sr-voice for a few
systems and print what Gemma actually picks.
Usage:
python3 tooling/planet-gen/test_register_selection.py
"""
import json
import subprocess
import sys
import hashlib
import re
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from gemma_naming import (
load_wiki_context,
CORRIDOR_SUBSTYLES,
DEFAULT_SUBSTYLES,
palette_for,
_extract_cultural_lines,
)
BIN = Path.home() / "Projects/settled-reach/binaries/sr-voice-tooling"
MODEL = Path.home() / "Projects/settled-reach/models/gemma-4.gguf"
DISTROBOX = "reach-build"
CTX_SIZE = 1024
TEST_SYSTEMS = [
("GJ 411", "south_reach", "Lalande — Iberian/Portuguese"),
("GJ 884", "south_reach", "Matamba — Angolan/Lusophone"),
("GJ 506", "west_reach", "Dokkum — Dutch/Frisian"),
("GJ 581", "west_reach", "Rødvik — Nordic"),
("GJ 34B", "east_reach", "Yongjin — Korean"),
("GJ 205", "east_reach", "Kurashiki — Japanese"),
("GJ 71", "core", "Gateway — administrative hub"),
("GJ 144", "core", "Ran — agricultural"),
]
def build_prompt(system_id: str, corridor: str) -> tuple[str, list[dict]]:
"""Build the register selection prompt. Returns (prompt, substyles)."""
substyles = CORRIDOR_SUBSTYLES.get(corridor, DEFAULT_SUBSTYLES)
wiki_text, gttr_text = load_wiki_context(system_id)
options = []
for idx, style in enumerate(substyles, 1):
options.append(f"{idx}. {style['inflection']}")
option_block = "\n".join(options)
context_parts = []
if gttr_text:
for para in gttr_text.strip().split("\n\n"):
cleaned = para.replace("#", "").strip()
if cleaned.startswith("THE DRIFTER") or cleaned.startswith("DRIFTER"):
continue
if not cleaned or len(cleaned) < 20:
continue
context_parts.append(cleaned[:300])
break
if wiki_text:
cultural = _extract_cultural_lines(wiki_text)
if cultural:
context_parts.append(cultural)
context = "\n".join(context_parts)
preamble = (
"Match the star system to the best cultural naming register.\n\n"
"System: Neustadt — German-heritage industrial town, west corridor, orderly municipal governance.\n"
"1. German settlement 2. Dutch colonial 3. Nordic 4. Polish/Czech 5. Baltic/Finnish\n"
"Best: 1\n\n"
"System: Matsue — Japanese precision manufacturing hub, east corridor.\n"
"1. Korean 2. Japanese 3. Taiwanese/Hakka 4. Filipino 5. Mixed East Asian\n"
"Best: 2\n\n"
"System: "
)
tail = f"\n{option_block}\nBest (number only):"
max_prompt_chars = (CTX_SIZE - 16) * 4
budget = max_prompt_chars - len(preamble) - len(tail)
if budget < 100:
budget = 100
if len(context) > budget:
context = context[:budget]
prompt = f"{preamble}{context}{tail}"
return prompt, substyles
def main():
# Show prompt sizes first
print("Prompt token estimates (rough: chars/4):")
for system_id, corridor, note in TEST_SYSTEMS:
prompt, _ = build_prompt(system_id, corridor)
est_tokens = len(prompt) // 4
print(f" {system_id:<8s} ~{est_tokens:>4d} tokens ({len(prompt)} chars) {note}")
print()
cmd = ["distrobox", "enter", DISTROBOX, "--",
str(BIN),
"--model", str(MODEL),
"--ctx-size", str(CTX_SIZE)]
print(f"starting sr-voice (ctx_size={CTX_SIZE})...", flush=True)
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1,
)
results = []
for system_id, corridor, note in TEST_SYSTEMS:
prompt, substyles = build_prompt(system_id, corridor)
seed = int(hashlib.sha256(
f"register|{system_id}|0".encode()
).hexdigest()[:8], 16)
req = json.dumps({"prompt": prompt, "seed": seed})
proc.stdin.write(req + "\n")
proc.stdin.flush()
t0 = time.time()
resp_line = proc.stdout.readline()
elapsed = time.time() - t0
try:
resp = json.loads(resp_line)
raw = resp.get("text", resp.get("error", ""))
except (json.JSONDecodeError, TypeError):
raw = f"ERR:{resp_line!r}"
# Parse
digits = re.search(r"\d+", raw.strip() or "")
if digits:
try:
choice = int(digits.group())
if 1 <= choice <= len(substyles):
picked = f"#{choice} {substyles[choice - 1]['inflection']}"
else:
picked = f"OUT OF RANGE ({choice})"
except ValueError:
picked = "PARSE FAIL"
else:
picked = f"FAIL: {raw[:60]}"
hash_pal = palette_for(corridor, system_id)
results.append((system_id, note, raw.strip()[:12], picked,
hash_pal["inflection"], elapsed))
proc.stdin.close()
proc.wait()
print()
print(f"{'System':<8s} {'Raw':<12s} {'Gemma picked':<48s} {'Hash fallback':<45s} {'Time':>5s}")
print("-" * 130)
for system_id, note, raw, picked, hash_pick, elapsed in results:
print(f"{system_id:<8s} {raw:<12s} {picked:<48s} {hash_pick:<45s} {elapsed:4.1f}s")
print(f"{note}")
print()
if __name__ == "__main__":
main()
-1
View File
@@ -8,7 +8,6 @@ require a bump.
Imported by:
tooling/economy-db/import_economics.py
tooling/planet-gen/generate_atlas.py
"""
SCHEMA_VERSION = "1.0.0"