#!/usr/bin/env python3 """ gemma_naming.py — Batch-name every empty name field in the reach's 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 or null. Hand-authored names are never overwritten. 2. Build a short corridor-aware prompt per feature. 3. Stream the prompts into `sr-voice serve --stdio` (long-lived subprocess, restarted every --refresh requests to prevent KV-cache context bleed). 4. Post-process each response: strip quotes, trim whitespace, reject blocklisted Earth majors, retry with a bumped seed on collision or on blocklist hit (up to 3 attempts), fall back to a deterministic palette-driven name on persistent failure. 5. Dedup within (cultural_corridor, feature_type) so two bodies in the same corridor never ship the same river name; cross-corridor collisions are allowed (two "Aldren"s on opposite arcs is fine). 6. Write markers.json back (only if any field changed). 7. Sync every touched body's atlas_* rows in systems.db so `atlas_cities.name`, `atlas_rivers.name`, etc. pick up the new strings without needing a follow-up generate_atlas.py pass. Usage: tooling/planet-gen/gemma_naming.py # full batch, real model tooling/planet-gen/gemma_naming.py --body GJ380c # single body tooling/planet-gen/gemma_naming.py --limit 5 --verbose # smoke test tooling/planet-gen/gemma_naming.py --mock # mock-stdio.sh (no model) tooling/planet-gen/gemma_naming.py \\ --sr-voice ~/Projects/settled-reach/binaries/sr-voice-tooling \\ --model ~/Projects/settled-reach/models/gemma-4.gguf Exit codes: 0 pipeline completed (possibly with skipped bodies) 1 fatal error (subprocess crash, missing binary, missing schema) """ import argparse import datetime import hashlib import json import os import re import signal import subprocess import sys import time from pathlib import Path TOOLING_DIR = Path(__file__).resolve().parent REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve() # Reuse the atlas DB sync logic from generate_atlas.py so there is one # authoritative path for atlas_* row updates. sys.path.insert(0, str(TOOLING_DIR)) from generate_atlas import ( # noqa: E402 GRID_H, GRID_W, ensure_atlas_schema, sync_markers_to_db, ) 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" BLOCKLIST_PATH = TOOLING_DIR / "earth_blocklist.txt" # NOTE: The following are vestigial from the Gemma 2 single-name pipeline. # The live path uses _batch_fill() → name_features_batch() from naming_core. # TODO(#833): remove in a cleanup pass. Full dead-code island (~750 lines): # - _CAPTURE_FILE, --dump-prompts argparse (here + main()) # - _build_prompt() and its few-shot example pools (~lines 500-929) # - post_process(), is_placeholder(), _PLACEHOLDER_TOKENS, _LABEL_PREFIX, # _MD_BOLD, _MD_UNDER (~lines 1040-1100) # - is_blocked(), load_blocklist() (~lines 1105-1140) # - fallback_name(), _FALLBACK_STEMS, _FALLBACK_SUFFIXES (~lines 1145-1205) # - name_feature() with its retry loop and _is_duplicate (~lines 1530-1655) _CAPTURE_FILE = None # vestigial — see note above # Default binary + model paths. The sr-voice binary is platform-specific # (GPU backend baked in per-build) and lives OUTSIDE any git worktree so # it survives sprint-worktree cleanup: # # ~/Projects/settled-reach/binaries/sr-voice-rocm (AMD / ROCm) # ~/Projects/settled-reach/binaries/sr-voice-cuda (NVIDIA, future) # ~/Projects/settled-reach/binaries/sr-voice-vulkan (cross-vendor, future) # ~/Projects/settled-reach/binaries/sr-voice-cpu (fallback) # # See #850 for the multi-backend release-binary ticket. The container # used to build these is created via the distrobox recipe documented in # the sr-voice README. # # The Gemma 2 model weights live under main/server/models and are shared # 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") def _find_sr_voice() -> Path: """Resolve the default sr-voice binary path. Preference order: 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 = _find_default_model() MOCK_STDIO = REPO_ROOT / "server" / "sr-voice" / "mock-stdio.sh" # --------------------------------------------------------------------------- # Tee logger — stdout + log file in one call # --------------------------------------------------------------------------- class Logger: """Write lines to stdout AND an optional log file. Every message gets a prefix of the form `[HH:MM:SS +00h03m]`: - HH:MM:SS is wall-clock local time, - +NNhMMm is the elapsed time since the Logger was constructed. The elapsed offset tells the user at a glance how long the run has been going without scrolling back to the banner line. Flushes after every line so a kill -9 loses at most one entry. """ def __init__(self, log_path: Path | None): self.log_path = log_path self.started_at = time.monotonic() self.fh = None if log_path is not None: log_path.parent.mkdir(parents=True, exist_ok=True) # Truncate on open so each run starts fresh — the user can # rename an old log before kicking off the next run. self.fh = log_path.open("w", buffering=1) # line buffered def _elapsed(self) -> str: secs = int(time.monotonic() - self.started_at) return f"+{secs // 3600:02d}h{(secs % 3600) // 60:02d}m" def _prefix(self) -> str: clock = datetime.datetime.now().strftime("%H:%M:%S") return f"[{clock} {self._elapsed()}]" def __call__(self, msg: str = "") -> None: line = f"{self._prefix()} {msg}" if msg else "" print(line, flush=True) if self.fh is not None: self.fh.write(line + "\n") self.fh.flush() def raw(self, msg: str = "") -> None: """Print without the timestamp prefix (for banner lines).""" print(msg, flush=True) if self.fh is not None: self.fh.write(msg + "\n") self.fh.flush() def close(self) -> None: if self.fh is not None: self.fh.close() self.fh = None # --------------------------------------------------------------------------- # Corridor palettes (D-191 §4, glossary.md §Corridors, decisions/economics.md D-175) # --------------------------------------------------------------------------- # Each palette is the cultural inflection the prompt asks Gemma to # produce names in. Palette keys match the values of # `star_systems.geographic_sector` directly — that column is the real # source of corridor identity in systems.db (cultural_corridor is a # legacy field that was never populated beyond sol-gateway-axis). CORRIDOR_SUBSTYLES: dict[str, list[dict[str, str]]] = { # Each corridor has a list of sub-style inflections. The pipeline # picks one per body via hash(body_id) so neighbouring bodies on the # same planet get different registers, and Gemma's narrow per-register # vocabulary (~15 stems) stays fresh across hundreds of bodies. # # IMPORTANT: the inflection is a DOMINANT bias, not a hard lock. # A British surveyor on an east_reach moon still names a river after # their aunt in Dorset. Each sub-style explicitly names its register # AND invites diaspora variety. "core": [ {"inflection": "English countryside, rural, agricultural settlers", "examples": "Thornbury, Bramblewood, Millbrook, Ashford, Weston"}, {"inflection": "British colonial settlement era", "examples": "New Bristol, Port Augusta, Kingstown, Admiralty, Georgetown"}, {"inflection": "American frontier, practical, geographic", "examples": "Dusty Creek, Twin Oaks, Cedar Flat, Hawk's Hollow, Red Bluff"}, {"inflection": "American municipal, administrative, cosmopolitan", "examples": "Prospect Heights, Liberty, Union, Meridian, Commonwealth"}, {"inflection": "Classical references, institutional, civic", "examples": "Concordia, Aurelius, Prefecture, Senate Landing, Forum"}, {"inflection": "Australian and New Zealand settler", "examples": "Redfern, Glenelg, Wollongong, Kaikoura, Hawke's Bay"}, ], "north_reach": [ {"inflection": "English rural, village and parish names", "examples": "Wolcott, Mildern, Ashbourne, Briarfell, Tarndale"}, {"inflection": "Scottish Highland and Lowland place-names", "examples": "Glenmoray, Dunfermline, Kinross, Brae, Dalwhinnie"}, {"inflection": "Australian outback, station and property names", "examples": "Redfern, Birdsville, Tennant, Woomera, Coober"}, {"inflection": "Irish rural and coastal settlement", "examples": "Ballymore, Kilrush, Dunmore, Tralee, Skellig"}, {"inflection": "South African English settler", "examples": "Grahamstown, Oudtshoorn, Stellenbosch, Graaff, Beaufort"}, ], "south_reach": [ {"inflection": "Portuguese colonial era, Iberian", "examples": "Monteforte, Serra, Tavira, Oliveira, Porto Novo"}, {"inflection": "Brazilian interior, frontier settlement", "examples": "Ribeirão, Campo Largo, Várzea, Ilhabela, Pinheiro"}, {"inflection": "East African Swahili coastal", "examples": "Inhambane, Kilimi, Ngola, Manhica, Quelimane"}, {"inflection": "Cape Verdean and West African", "examples": "Cabo, Moçambo, Ribeira, Mindelo, Tarrafal"}, {"inflection": "Angolan and Mozambican settlement", "examples": "Huambo, Lobito, Nampula, Lichinga, Benguela"}, ], "east_reach": [ {"inflection": "Korean place-name tradition", "examples": "Hanyang, Seorak, Baektu, Saeyeon, Taegong"}, {"inflection": "Japanese rural and coastal settlement", "examples": "Takamine, Ginoza, Tsukuri, Aomori, Fukagawa"}, {"inflection": "Taiwanese and Hakka settler", "examples": "Jiufen, Beigang, Hsinchu, Meinong, Tainan"}, {"inflection": "Filipino settler community", "examples": "Batangas, Legazpi, Tuguegarao, Zambales, Tarlac"}, {"inflection": "Mixed East Asian diaspora, cosmopolitan", "examples": "Naruhan, Morimine, Kōzan, Midori, Kawasaki"}, ], "west_reach": [ {"inflection": "German settlement, orderly and compound names", "examples": "Altdorf, Drachenberg, Feldberg, Krakenberg, Lüneborg"}, {"inflection": "Dutch colonial, low-country", "examples": "Kloosterdam, Hoogland, Oudewater, Nieuwpoort, Voorhout"}, {"inflection": "Nordic and Scandinavian", "examples": "Sørholm, Torsfell, Bergfjord, Lindeborg, Nordhölm"}, {"inflection": "Polish and Czech settler", "examples": "Krakowice, Bystrica, Wieliczka, Tarnów, Ostrava"}, {"inflection": "Baltic and Finnish settler", "examples": "Järvenpää, Tallinna, Pärnu, Turku, Rakvere"}, ], "deep_frontier": [ {"inflection": "frontier founder-name era, surname-first, any Earth culture", "examples": "Okafor Reach, Stenner Cross, Weller Hold, Pruitt Basin"}, {"inflection": "frontier descriptive, geographic features named by surveyors", "examples": "Red Mesa, Dry Fork, Iron Flat, Long Ridge, Dust Basin"}, {"inflection": "frontier outpost, functional and military", "examples": "Forward Post, Relay Station, Survey Camp, Waypoint, Anchor"}, ], } # Legacy aliases CORRIDOR_SUBSTYLES["sol-gateway-axis"] = CORRIDOR_SUBSTYLES["core"] CORRIDOR_SUBSTYLES["inner_corridor"] = CORRIDOR_SUBSTYLES["core"] CORRIDOR_SUBSTYLES["inner_orbit"] = CORRIDOR_SUBSTYLES["core"] CORRIDOR_SUBSTYLES["frontier"] = CORRIDOR_SUBSTYLES["deep_frontier"] DEFAULT_SUBSTYLES = CORRIDOR_SUBSTYLES["core"] # Processing order for the main run — core first so those bodies win # the dedup race and the outer sectors fall into the palette fallback # path when names collide. SECTOR_PRIORITY: dict[str, int] = { "core": 0, "north_reach": 1, "south_reach": 2, "east_reach": 3, "west_reach": 4, "deep_frontier": 5, } def palette_for(corridor: str | None, system_id: str = "") -> dict[str, str]: """Pick a sub-style for this system within its corridor. 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("