feat(tooling): Gemma 4 batch naming pipeline with wiki-grounded register selection (#833)

Replace the one-at-a-time Gemma 2 naming pipeline with a batch-oriented
Gemma 4 E2B pipeline. Key changes:

- naming_core.py: shared library with Levenshtein distinctiveness ranking,
  batch prompt building, mood injection pool, name validation, and
  adjacent-register refill logic
- Wiki-grounded register selection: per-system LLM call picks the cultural
  register based on wiki/GTTR content instead of hash randomizer
- Batch naming: requests N*2 names per call, ranks by word-average
  Levenshtein distance, fills quota from most-distinct candidates
- Mood pool: 13 emotional seeds randomized per-body for vocabulary
  divergence (ambition, fear, isolation, defiance, etc.)
- Adjacent-register refill: when primary register exhausts, automatically
  switches to next corridor substyle
- Inhabited-first body ordering: habitable worlds get first pick of
  register vocabulary, barren moons get leftovers
- Process group cleanup: SIGTERM/SIGKILL the full distrobox chain on
  subprocess refresh to prevent GPU zombie processes
- qa_naming.py: QA report, fix_fewshot_bleed.py: post-hoc fix script
- test_batch_naming.py, test_register_selection.py: test harnesses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 16:09:23 +02:00
co-authored by Claude Opus 4.6
parent 474b31b214
commit 9ad9b88d7c
7 changed files with 1908 additions and 125 deletions
+246
View File
@@ -0,0 +1,246 @@
#!/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")
# Assign replacements deterministically — hash body_id + feature_id
# to pick from the pool, ensuring each body gets different names
used_per_body = defaultdict(set)
replacement_idx = 0
changed_files = set()
total_replaced = 0
for markers_path, body_id, section, feat in replacements_needed:
old_name = feat["name"]
# Find next available name not yet used on this body
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 is None:
print(f" SKIP {body_id}/{section}: no unique replacement for \"{old_name}\"")
continue
feat["name"] = assigned
used_per_body[body_id].add(assigned.lower())
global_names.add(assigned.lower())
changed_files.add(markers_path)
total_replaced += 1
# Write changed files
for markers_path in changed_files:
body_id = markers_path.parent.name
m = json.loads(markers_path.read_text())
# Re-apply changes (re-read since we modified feat objects in memory)
# Actually the feat dicts are still referenced — just rewrite
# But we need to reload and re-match since we didn't track which file
# has which changes...
# Simpler approach: reload, replace, write
# Reset and do it properly
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()
+370 -123
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
gemma_naming.py — Batch-name every empty name field in the reach's
markers.json files using the Gemma 2 voice pipeline (#833, D-191 §4).
markers.json files using the Gemma 4 E2B tooling pipeline (#833, D-191 §4).
Pipeline per body:
1. Load markers.json; identify feature records whose `name` is empty
@@ -40,7 +40,9 @@ import argparse
import datetime
import hashlib
import json
import os
import re
import signal
import subprocess
import sys
import time
@@ -60,6 +62,10 @@ from generate_atlas import ( # noqa: E402
)
import sqlite3 # noqa: E402
from naming_core import ( # noqa: E402
name_features_batch,
mood_for_body,
)
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
@@ -91,6 +97,7 @@ _CAPTURE_FILE = None # set in main() when --dump-prompts is used
# across worktrees (too large to duplicate).
HOME_PROJECTS = Path.home() / "Projects" / "settled-reach"
BINARIES_DIR = HOME_PROJECTS / "binaries"
MODELS_DIR = HOME_PROJECTS / "models"
MAIN_WORKDIR = Path("/var/mnt/data/projects/settled-reach/main")
@@ -98,19 +105,31 @@ def _find_sr_voice() -> Path:
"""Resolve the default sr-voice binary path.
Preference order:
1. $HOME/Projects/settled-reach/binaries/sr-voice-rocm — persistent
across worktree lifetimes, the canonical dev location.
2. main workdir's target/release/sr-voice — legacy, for
backward-compatibility with older layouts.
1. $HOME/Projects/settled-reach/binaries/sr-voice-tooling — Gemma 4
tooling binary, preferred for content generation.
2. $HOME/Projects/settled-reach/binaries/sr-voice-rocm — Gemma 2
ROCm binary, fallback.
3. main workdir's target/release/sr-voice — legacy.
"""
tooling_bin = BINARIES_DIR / "sr-voice-tooling"
if tooling_bin.exists():
return tooling_bin
rocm_bin = BINARIES_DIR / "sr-voice-rocm"
if rocm_bin.exists():
return rocm_bin
return MAIN_WORKDIR / "server" / "sr-voice" / "target" / "release" / "sr-voice"
def _find_default_model() -> Path:
"""Resolve the default model path. Prefers Gemma 4 over Gemma 2."""
gemma4 = MODELS_DIR / "gemma-4.gguf"
if gemma4.exists():
return gemma4
return MAIN_WORKDIR / "server" / "models" / "gemma2.gguf"
DEFAULT_SR_VOICE = _find_sr_voice()
DEFAULT_MODEL = MAIN_WORKDIR / "server" / "models" / "gemma2.gguf"
DEFAULT_MODEL = _find_default_model()
MOCK_STDIO = REPO_ROOT / "server" / "sr-voice" / "mock-stdio.sh"
@@ -285,12 +304,181 @@ def palette_for(corridor: str | None, system_id: str = "") -> dict[str, str]:
All bodies in the same system get the same sub-style (consistent
cultural register per star system). Different systems rotate through
the sub-style list via hash(system_id).
This is the FALLBACK path — the preferred path is select_register()
which asks Gemma to pick the register based on wiki/GTTR content.
"""
substyles = CORRIDOR_SUBSTYLES.get(corridor or "core", DEFAULT_SUBSTYLES)
idx = int(hashlib.sha256(system_id.encode()).hexdigest()[:8], 16) % len(substyles)
return substyles[idx]
def _system_slug(system_id: str) -> str:
"""Convert system_id ('GJ 411') to wiki directory slug ('GJ-411')."""
if system_id.startswith("GJ "):
return "GJ-" + system_id[3:]
return system_id
def load_wiki_context(system_id: str) -> tuple[str | None, str | None]:
"""Read index.md and gttr.md for a system from wiki/star-systems/.
Returns (index_text, gttr_text). Either or both may be None if the
file doesn't exist.
"""
slug = _system_slug(system_id)
sys_dir = WIKI_SYSTEMS / slug
index_path = sys_dir / "index.md"
gttr_path = sys_dir / "gttr.md"
index_text = index_path.read_text() if index_path.exists() else None
gttr_text = gttr_path.read_text() if gttr_path.exists() else None
return index_text, gttr_text
def _extract_cultural_lines(wiki_text: str, max_lines: int = 8) -> str:
"""Pull the most culturally relevant lines from a wiki index.md.
Scans for lines mentioning heritage, founding identity, language,
cultural texture, or corridor affiliation. Falls back to the first
prose paragraphs if no keyword hits. Keeps the excerpt short enough
for Gemma 2 2B's 1024-token context.
"""
keywords = (
"cultural", "heritage", "founding", "settler", "surname",
"language", "tradition", "diaspora", "population carried",
"portuguese", "iberian", "japanese", "korean", "chinese",
"filipino", "german", "dutch", "nordic", "scandinavian",
"polish", "czech", "finnish", "baltic", "swahili", "african",
"angolan", "cape verde", "irish", "scottish", "australian",
"british", "brazilian", "mozambic", "norwegian", "frisian",
"afrikaans", "lusophone", "corridor",
)
hits: list[str] = []
prose: list[str] = []
for line in wiki_text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or stripped.startswith("|") or stripped.startswith("---") or stripped.startswith("<!--"):
continue
low = stripped.lower()
if any(kw in low for kw in keywords):
hits.append(stripped[:200])
elif len(prose) < max_lines:
prose.append(stripped[:200])
selected = hits[:max_lines] if hits else prose[:max_lines]
return "\n".join(selected)
def select_register(
voice: "VoiceSubprocess",
corridor: str,
system_id: str,
wiki_text: str | None,
gttr_text: str | None,
log: "Logger",
max_attempts: int = 3,
) -> dict[str, str] | None:
"""Ask Gemma to pick the best cultural register for this system.
Presents the corridor's sub-style options numbered 1..N alongside
a compact cultural excerpt (gttr_hook + key wiki lines). Gemma
replies with just the number. Returns the selected sub-style dict,
or None if all attempts fail (caller falls back to hash-based
palette_for).
"""
substyles = CORRIDOR_SUBSTYLES.get(corridor, DEFAULT_SUBSTYLES)
if not wiki_text and not gttr_text:
return None
# Build numbered option list — inflection only, no examples, to
# save tokens. Gemma needs to match cultural identity, not mimic
# example names.
options: list[str] = []
for idx, style in enumerate(substyles, 1):
options.append(f"{idx}. {style['inflection']}")
option_block = "\n".join(options)
# Build a compact context block that fits in ~400 tokens.
# The gttr_hook is a 30-45 word summary; the wiki excerpt adds
# the strongest cultural-identity lines.
context_parts: list[str] = []
if gttr_text:
# Use the first substantive GTTR paragraph — skip the title
# line (# THE DRIFTER'S GUIDE ...) and any blank lines.
for para in gttr_text.strip().split("\n\n"):
cleaned = para.replace("#", "").strip()
# Skip title lines and section headers
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)
if not context_parts:
return None
context = "\n".join(context_parts)
# Few-shot format: Gemma 2 2B is much better at pattern completion
# than instruction following. Show 2 worked examples, then the
# target system. Keep examples short and from different corridors
# than the target so they don't bias the answer.
# Fixed preamble + tail that frame the completion pattern.
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):"
# Reserve tokens for preamble, tail, and a few output tokens.
# Rough estimate: 1 token ≈ 4 chars for English prose.
max_prompt_chars = (voice.ctx_size - 16) * 4 # 16 tokens headroom for output
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}"
for attempt in range(max_attempts):
seed = int(
hashlib.sha256(
f"register|{system_id}|{attempt}".encode()
).hexdigest()[:8],
16,
)
try:
raw = voice.request(prompt, seed)
except RuntimeError:
continue
# Extract the first number from the response. Gemma may reply
# "1", "1.", "Option 1", "Answer: 1", etc.
digits = re.search(r"\d+", raw.strip() or "")
if not digits:
continue
try:
choice = int(digits.group())
except ValueError:
continue
if 1 <= choice <= len(substyles):
return substyles[choice - 1]
return None
# ---------------------------------------------------------------------------
# Feature prompt templates — few-shot format with rotating example pools
# ---------------------------------------------------------------------------
@@ -812,6 +1000,8 @@ def _build_prompt(
f"or after places back home. Most names are mundane, short, and "
f"direct — a surname, a compass direction, a feature, a practical "
f"description. Classical or epic names are rare. "
f"Avoid the obvious choice. Do not repeat the system or planet name. "
f"Each name should be unique and surprising within its register. "
f"Reply with ONLY the name, {cfg['length_hint']}, no brackets, "
f"no quotes, no markdown, no label."
)
@@ -1156,6 +1346,7 @@ class VoiceSubprocess:
refresh_every: int,
verbose: bool,
distrobox: str | None = None,
ctx_size: int = 1024,
):
self.sr_voice_bin = sr_voice_bin
self.model_path = model_path
@@ -1163,6 +1354,7 @@ class VoiceSubprocess:
self.refresh_every = max(1, refresh_every)
self.verbose = verbose
self.distrobox = distrobox
self.ctx_size = ctx_size
self.proc: subprocess.Popen | None = None
self.request_count = 0
self._start()
@@ -1170,7 +1362,15 @@ class VoiceSubprocess:
def _build_cmd(self) -> list[str]:
if self.mock:
return [str(MOCK_STDIO), "serve", "--stdio"]
inner_cmd = [str(self.sr_voice_bin), "serve", "--stdio"]
# sr-voice-tooling (Gemma 4) takes --model directly;
# sr-voice (Gemma 2) needs `serve --stdio` subcommand.
bin_name = self.sr_voice_bin.name if hasattr(self.sr_voice_bin, 'name') else str(self.sr_voice_bin).rsplit("/", 1)[-1]
if "tooling" in bin_name:
inner_cmd = [str(self.sr_voice_bin),
"--ctx-size", str(self.ctx_size)]
else:
inner_cmd = [str(self.sr_voice_bin), "serve", "--stdio",
"--ctx-size", str(self.ctx_size)]
if self.model_path is not None:
inner_cmd += ["--model", str(self.model_path)]
# If a distrobox container was requested, invoke the binary
@@ -1195,6 +1395,7 @@ class VoiceSubprocess:
stderr=subprocess.DEVNULL if not self.verbose else None,
text=True,
bufsize=1, # line-buffered
start_new_session=True, # own process group so _stop can kill the whole chain
)
self.request_count = 0
# Mock prints a stderr banner synchronously; real sr-voice prints
@@ -1255,12 +1456,32 @@ class VoiceSubprocess:
self.proc.stdin.close()
except Exception:
pass
# Kill the entire process group (distrobox → podman → sr-voice)
# rather than just the top-level shell. Without this, the inner
# sr-voice binary survives terminate() and holds the GPU.
pgid = None
try:
self.proc.terminate()
pgid = os.getpgid(self.proc.pid)
except (ProcessLookupError, OSError):
pass
try:
if pgid:
os.killpg(pgid, signal.SIGTERM)
else:
self.proc.terminate()
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
try:
if pgid:
os.killpg(pgid, signal.SIGKILL)
else:
self.proc.kill()
except (ProcessLookupError, OSError):
pass
try:
self.proc.wait(timeout=3)
except Exception:
pass
except Exception:
pass
self.proc = None
@@ -1319,6 +1540,7 @@ def name_feature(
log: "Logger",
verbose: bool,
max_attempts: int = 5,
palette_override: dict[str, str] | None = None,
) -> str:
"""Request a name from Gemma, enforce blocklist + per-system dedup +
per-body cross-type dedup, skip on persistent failure.
@@ -1329,12 +1551,16 @@ def name_feature(
- `body_used` — per-body set across ALL feature types. Prevents
the same name from appearing as a river AND an ocean AND a
mountain range on the same world.
When `palette_override` is set, it replaces the hash-based palette
selection — used when select_register() picked a wiki-grounded
cultural register for this system.
"""
corridor = ctx.get("cultural_corridor") or "core"
if feature_type not in _PROMPT_CONFIG:
return fallback_name(corridor, feature_type, _seed_for(world_seed, body_id, local_id, 0))
palette = palette_for(corridor, system_id)
palette = palette_override or palette_for(corridor, system_id)
planet_class = ctx.get("planet_class") or "habitable"
dedup_key = (system_id, feature_type)
@@ -1489,23 +1715,38 @@ def load_system_gttr_hooks(conn: sqlite3.Connection) -> dict[str, str]:
return {row[0]: row[1] for row in rows}
def load_body_hop_order(conn: sqlite3.Connection) -> dict[str, tuple[int, str]]:
"""Return a `{body_id: (hop_distance_from_gateway, body_id)}` map used
as a stable sort key so the pipeline walks the reach from core
outward: Gateway (hop 0) first, then hop 1, hop 2, ... all the way
to the deep frontier. Ordering core-first gives those bodies first
crack at every unique Gemma output and lets outer sectors fall
into the palette fallback when they lose the dedup race.
def load_body_hop_order(conn: sqlite3.Connection) -> dict[str, tuple[int, str, int, int, str]]:
"""Return a `{body_id: (hop, system_id, uninhabited, neg_pop, body_id)}`
sort-key map. The pipeline walks the reach from core outward, keeps
all bodies in the same system together, and within each system
processes inhabited bodies first (sorted by population descending).
This gives the most important worlds — the ones players will
actually visit — first pick of the cultural register's vocabulary.
Barren moons get whatever's left or the adjacent-register refill,
which is fine for star-map dressing.
Sort key components:
- hop: system distance from Gateway (0 = core, higher = frontier)
- system_id: groups all bodies in same system together
- uninhabited: 0 for inhabited, 1 for uninhabited (inhabited first)
- neg_pop: negative population (higher pop sorts first)
- body_id: tiebreaker for determinism
"""
rows = conn.execute(
"""
SELECT b.body_id,
COALESCE(sg.hop_distance_from_gateway, 99) AS hop
b.system_id,
COALESCE(sg.hop_distance_from_gateway, 99) AS hop,
b.inhabited,
COALESCE(b.population, 0) AS pop
FROM bodies b
LEFT JOIN system_gates sg ON b.system_id = sg.system_id
"""
).fetchall()
return {body_id: (hop, body_id) for body_id, hop in rows}
return {
body_id: (hop, system_id, 0 if inhabited else 1, -(pop or 0), body_id)
for body_id, system_id, hop, inhabited, pop in rows
}
def _is_blank(value) -> bool:
@@ -1549,6 +1790,7 @@ def process_body(
hop: int,
log: "Logger",
verbose: bool,
palette_override: dict[str, str] | None = None,
) -> dict:
"""Fill every empty name field in this body's markers.json. Returns
a summary counts dict plus a `generated` dict mapping section name
@@ -1588,106 +1830,80 @@ def process_body(
if name and isinstance(name, str) and name.strip():
body_used.add(name.strip())
# Cities
for city in markers.get("cities") or []:
if not _is_blank(city.get("name")):
corpus.setdefault((system_id, _feature_type_for_city(city)), set()).add(
city["name"]
)
counts["preserved"] += 1
continue
feature_type = _feature_type_for_city(city)
name = name_feature(
voice, feature_type, ctx, blocklist, corpus, body_used,
system_hook, system_id,
world_seed, body_id, city.get("id") or "city_?",
hop, log, verbose,
)
if name is not None:
city["name"] = name
counts["cities"] += 1
generated["cities"].append(name)
changed = True
# Batch naming: group blank features by type, request N names at
# once, rank by Levenshtein distinctiveness, assign.
inflection = (palette_override or palette_for(corridor, system_id))["inflection"]
substyles = CORRIDOR_SUBSTYLES.get(corridor, DEFAULT_SUBSTYLES)
mood = mood_for_body(body_id, world_seed)
# Rivers
for river in markers.get("rivers") or []:
if not _is_blank(river.get("name")):
corpus.setdefault((system_id, "river"), set()).add(river["name"])
counts["preserved"] += 1
continue
name = name_feature(
voice, "river", ctx, blocklist, corpus, body_used,
system_hook, system_id,
world_seed, body_id, river.get("id") or "river_?",
hop, log, verbose,
)
if name is not None:
river["name"] = name
counts["rivers"] += 1
generated["rivers"].append(name)
changed = True
# Helper: batch-name blank features in a marker section
def _batch_fill(
section_key: str,
feature_type_fn, # callable(feat) -> str
count_key: str,
):
nonlocal changed
features = markers.get(section_key) or []
# Separate preserved vs blank
blank = []
for feat in features:
if not _is_blank(feat.get("name")):
ft = feature_type_fn(feat)
corpus.setdefault((system_id, ft), set()).add(feat["name"])
counts["preserved"] += 1
else:
blank.append(feat)
# Oceans / seas / lakes
for water in markers.get("oceans") or []:
if not _is_blank(water.get("name")):
corpus.setdefault((system_id, _feature_type_for_ocean(water)), set()).add(
water["name"]
)
counts["preserved"] += 1
continue
feature_type = _feature_type_for_ocean(water)
name = name_feature(
voice, feature_type, ctx, blocklist, corpus, body_used,
system_hook, system_id,
world_seed, body_id, water.get("id") or "water_?",
hop, log, verbose,
)
if name is not None:
water["name"] = name
counts["oceans"] += 1
generated["oceans"].append(name)
changed = True
if not blank:
return
# Mountain ranges
for rng_feat in markers.get("mountain_ranges") or []:
if not _is_blank(rng_feat.get("name")):
corpus.setdefault((system_id, "mountain_range"), set()).add(
rng_feat["name"]
)
counts["preserved"] += 1
continue
name = name_feature(
voice, "mountain_range", ctx, blocklist, corpus, body_used,
system_hook, system_id,
world_seed, body_id, rng_feat.get("id") or "range_?",
hop, log, verbose,
)
if name is not None:
rng_feat["name"] = name
counts["mountain_ranges"] += 1
generated["mountain_ranges"].append(name)
changed = True
# Group blanks by feature_type (cities have capital/secondary,
# pois have transit/institutional/cultural, oceans have ocean/sea/lake)
by_type: dict[str, list[dict]] = {}
for feat in blank:
ft = feature_type_fn(feat)
by_type.setdefault(ft, []).append(feat)
# POIs
for poi in markers.get("pois") or []:
if not _is_blank(poi.get("name")):
corpus.setdefault((system_id, _feature_type_for_poi(poi)), set()).add(
poi["name"]
for ft, feats in by_type.items():
need = len(feats)
# Build taken list from corpus (cross-body dedup)
taken = list(corpus.get((system_id, ft), set()))
# Also include body_used to avoid cross-type collisions on same body
taken_full = taken + list(body_used)
names = name_features_batch(
voice=voice,
feature_type=ft,
count=need,
inflection=inflection,
corridor=corridor,
corridor_substyles=substyles,
taken=taken_full,
prompt_config=_PROMPT_CONFIG,
system_name=ctx.get("system_proper_name"),
body_name=ctx.get("body_proper_name"),
system_hook=system_hook,
mood=mood,
body_id=body_id,
world_seed=world_seed,
ctx_size=voice.ctx_size,
)
counts["preserved"] += 1
continue
feature_type = _feature_type_for_poi(poi)
name = name_feature(
voice, feature_type, ctx, blocklist, corpus, body_used,
system_hook, system_id,
world_seed, body_id, poi.get("id") or "poi_?",
hop, log, verbose,
)
if name is not None:
poi["name"] = name
counts["pois"] += 1
generated["pois"].append(name)
changed = True
# Assign names to features in order
for i, feat in enumerate(feats):
if i < len(names):
feat["name"] = names[i]
counts[count_key] += 1
generated[count_key].append(names[i])
corpus.setdefault((system_id, ft), set()).add(names[i])
body_used.add(names[i])
changed = True
_batch_fill("cities", _feature_type_for_city, "cities")
_batch_fill("rivers", lambda f: "river", "rivers")
_batch_fill("oceans", _feature_type_for_ocean, "oceans")
_batch_fill("mountain_ranges", lambda f: "mountain_range", "mountain_ranges")
_batch_fill("pois", _feature_type_for_poi, "pois")
if changed:
markers_path.write_text(json.dumps(markers, indent=2) + "\n")
@@ -1696,8 +1912,14 @@ def process_body(
# immediately so a mid-run crash / kill loses at most one body
# of DB state — the markers.json files are already persisted
# above, atomically, via Path.write_text.
sync_markers_to_db(conn, body_id, markers)
conn.commit()
# Skip DB sync for orphan bodies (markers.json exists but no
# row in the bodies table — FK constraint would fail).
try:
sync_markers_to_db(conn, body_id, markers)
conn.commit()
except Exception as e:
conn.rollback()
log(f" DB sync skipped for {body_id}: {e}")
counts["generated"] = generated
return counts
@@ -1734,9 +1956,9 @@ def discover_bodies(
p for p in all_markers if _body_id_from_path(p)[0] == body_filter
]
def sort_key(path: Path) -> tuple[int, str]:
body_id = _body_id_from_path(path)[0]
return hop_order.get(body_id, (99, body_id))
def sort_key(path: Path) -> tuple[int, str, int, int, str]:
body_id, system_id = _body_id_from_path(path)
return hop_order.get(body_id, (99, system_id, 1, 0, body_id))
all_markers.sort(key=sort_key)
@@ -1874,6 +2096,15 @@ def main():
hop_order = load_body_hop_order(conn)
system_gttr_hooks = load_system_gttr_hooks(conn)
# Preload corridor for each system so select_register can scope to
# the right sub-style list without re-querying per body.
system_corridors: dict[str, str] = {
row[0]: row[1] or "core"
for row in conn.execute(
"SELECT system_id, COALESCE(cultural_corridor, geographic_sector, 'core') "
"FROM star_systems"
).fetchall()
}
markers_paths = discover_bodies(args.body, args.limit, hop_order)
if not markers_paths:
log(f"error: no markers.json found (body={args.body})")
@@ -1926,6 +2157,7 @@ def main():
).fetchall()
}
last_system_id: str | None = None
current_palette: dict[str, str] | None = None
totals = {
"cities": 0, "rivers": 0, "oceans": 0, "mountain_ranges": 0, "pois": 0,
@@ -1953,16 +2185,30 @@ def main():
if system_id != last_system_id:
last_system_id = system_id
sys_proper = system_name_cache.get(system_id, "")
sys_hop = hop_order.get(body_id, (99, ""))[0]
sys_hop = hop_order.get(body_id, (99, "", 1, 0, ""))[0]
label = f"{system_id}"
if sys_proper:
label = f"{system_id}{sys_proper}"
log(f" ── SYSTEM {len(seen_systems)+1}/{total_systems} "
f"{label} (hop {sys_hop})")
# Ask Gemma to pick the cultural register based on
# wiki content instead of the hash-based randomizer.
corridor = system_corridors.get(system_id, "core")
wiki_index, wiki_gttr = load_wiki_context(system_id)
current_palette = select_register(
voice, corridor, system_id,
wiki_index, wiki_gttr, log,
)
if current_palette:
log(f" register: {current_palette['inflection']}")
else:
current_palette = palette_for(corridor, system_id)
log(f" register: {current_palette['inflection']} (hash fallback)")
seen_systems.add(system_id)
t0 = time.time()
hop = hop_order.get(body_id, (99, ""))[0]
hop = hop_order.get(body_id, (99, "", 1, 0, ""))[0]
counts = process_body(
body_id=body_id,
system_id=system_id,
@@ -1976,12 +2222,13 @@ def main():
hop=hop,
log=log,
verbose=args.verbose,
palette_override=current_palette,
)
elapsed = time.time() - t0
body_progress = f"body {i+1}/{len(markers_paths)}"
sys_progress = f"sys {len(seen_systems)}/{total_systems}"
hop = hop_order.get(body_id, (99, ""))[0]
hop = hop_order.get(body_id, (99, "", 1, 0, ""))[0]
progress = f"{body_progress} {sys_progress} hop={hop}"
# Append the body's proper name if it has one ("Threshold",
+395
View File
@@ -0,0 +1,395 @@
"""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
"""
__version__ = "0.2"
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) -> bool:
"""Filter out garbage: too short, too long, contains periods/brackets,
looks like a prompt fragment, matches a few-shot example, or contains
digits."""
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
return True
def parse_batch_response(raw: 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).
"""
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) 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,
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.
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 compass direction, a feature, a practical "
f"description.{mood_clause}Avoid the obvious choice. Each name must "
f"be distinct — no two names may share a root word.\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 taken:
lines.append(f"Already used (do NOT repeat): {', '.join(taken)}")
if system_name or body_name or system_hook 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 the taken list to fit
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 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,
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.
"""
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,
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)
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,
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)
extra = select_distinct(refill_candidates, shortfall, refill_taken)
selected.extend(extra)
return selected
+523
View File
@@ -0,0 +1,523 @@
#!/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()
+2 -2
View File
@@ -31,8 +31,8 @@ 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-rocm"
MODEL="/var/mnt/data/projects/settled-reach/main/server/models/gemma2.gguf"
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
+209
View File
@@ -0,0 +1,209 @@
#!/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()
@@ -0,0 +1,163 @@
#!/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()