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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user