diff --git a/tooling/planet-gen/earth_blocklist.txt b/tooling/planet-gen/earth_blocklist.txt index e44a1f6ec..a1943dec0 100644 --- a/tooling/planet-gen/earth_blocklist.txt +++ b/tooling/planet-gen/earth_blocklist.txt @@ -214,3 +214,54 @@ Amazon Basin # Greek/Roman mythology that reads too literally as Earth classical Olympus Mons Mount Olympus + +# European rivers the model keeps reaching for +Rhine +Weser +Elbe +Oder +Vistula +Loire +Rhône +Douro +Tagus +Ebro +Po +Arno +Tiber +Sava +Drava +Vlatava +Vltava +Dnieper +Don +Volga +Dniester + +# Nordic / Eastern European cities +Reykjavik +Oslo +Bergen +Tromsø +Gothenburg +Gdansk +Krakow +Warsaw +Prague +Brno +Bratislava +Budapest +Debrecen +Bucharest +Sofia +Belgrade +Zagreb +Ljubljana +Tallinn +Riga +Vilnius +Kiev +Kyiv +Minsk +Odessa +Lviv diff --git a/tooling/planet-gen/gemma_naming.py b/tooling/planet-gen/gemma_naming.py index 0b2f12923..3ead3921d 100755 --- a/tooling/planet-gen/gemma_naming.py +++ b/tooling/planet-gen/gemma_naming.py @@ -79,16 +79,19 @@ MOCK_STDIO = REPO_ROOT / "server" / "sr-voice" / "mock-stdio.sh" # --------------------------------------------------------------------------- class Logger: - """Write lines to stdout AND an optional append-mode log file. + """Write lines to stdout AND an optional log file. - Every message gets a millisecond timestamp prefix so the log is - interleavable with tail -f and the user can follow progress across - two parallel shards by `tail -f .tmp/gemma_naming.shard*.log`. - Flushes after every line so a kill -9 loses at most one entry. + 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) @@ -96,11 +99,16 @@ class Logger: # rename an old log before kicking off the next run. self.fh = log_path.open("w", buffering=1) # line buffered - def _ts(self) -> str: - return datetime.datetime.now().strftime("%H:%M:%S") + 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._ts()}] {msg}" if msg else "" + line = f"{self._prefix()} {msg}" if msg else "" print(line, flush=True) if self.fh is not None: self.fh.write(line + "\n") @@ -130,8 +138,13 @@ class Logger: # legacy field that was never populated beyond sol-gateway-axis). CORRIDOR_PALETTES: dict[str, dict[str, str]] = { "core": { - "inflection": "institutional Latin / pan-Anglo / Gateway-era", - "examples": "Meridian, Concord, Cardinal, Prefecture, Lumen, Foro, Axis, Senatus", + # NOTE the style label is deliberately plain: earlier versions used + # "institutional Latin / pan-Anglo / Gateway-era" which biased + # Gemma 2 2B toward Latinate coinages like "Aureus" / "Aetheria". + # "Administrative English" gets prosaic output that matches the + # settler-named frontier feel the core corridor actually has. + "inflection": "administrative English / Gateway-era", + "examples": "Meridian, Concord, East Ridge, Landing, Old Gate, Foreman's Run", }, "north_reach": { "inflection": "British / Australian / Irish", @@ -156,12 +169,12 @@ CORRIDOR_PALETTES: dict[str, dict[str, str]] = { # Legacy keys retained for backward compatibility with the # cultural_corridor column on the one system that uses it. "sol-gateway-axis": { - "inflection": "institutional Latin / pan-Anglo / Gateway-era", - "examples": "Meridian, Concord, Cardinal, Prefecture, Lumen, Foro, Axis, Senatus", + "inflection": "administrative English / Gateway-era", + "examples": "Meridian, Concord, East Ridge, Landing, Old Gate, Foreman's Run", }, "inner_corridor": { - "inflection": "institutional Latin / pan-Anglo", - "examples": "Meridian, Concord, Prefecture, Cardinal, Lumen, Foro", + "inflection": "administrative English", + "examples": "Meridian, Concord, East Ridge, Landing, Old Gate", }, } @@ -187,142 +200,513 @@ def palette_for(corridor: str | None) -> dict[str, str]: # --------------------------------------------------------------------------- -# Feature prompt templates — few-shot format +# Feature prompt templates — few-shot format with rotating example pools # --------------------------------------------------------------------------- # Gemma 2 2B is small and noisy on free-form instruction prompts — it # loves to echo the prompt back as "[River Name]" / "NAME: ..." / -# "**River: X**" etc. The fix is few-shot: show 3 concrete -# `Input → Output` examples so the model completes a pattern instead +# "**River: X**" etc. The fix is few-shot: show concrete +# `Style → Answer` examples so the model completes a pattern instead # of generating to an open-ended instruction. # -# Important rules these templates enforce: -# - The examples ALWAYS show a bare name (no labels, no markdown, -# no brackets, no quotes) so the completion mimics that shape. -# - The examples are DIFFERENT corridors from the one being named, -# to prevent Gemma from just echoing one of the examples. -# - The final line ends with `Answer:` (not `NAME:`) — less likely -# to collide with a real name in post-processing. +# Two lessons from earlier iterations: +# 1. The examples are the ONLY thing the model actually learns from. +# If they're all epic/classical (Wolcott Beck, Nakamura Stream), +# the model completes in epic/classical register for every body. +# Grounded outputs (Cooper's Creek, West Ridge, Mill Run) require +# grounded examples. +# 2. A single static example set produces uniform output: same prompt +# + similar seeds → similar completions. Rotating through a pool +# of example sets per call injects variation and nudges the +# sampler into different regions of the output distribution. # -# All templates share the same few-shot prefix defined below; only -# the question and example axis differ per feature type. +# Each feature type has a POOL of example sets. `_build_prompt()` picks +# one set deterministically per (body_id, local_id) so the same feature +# always gets the same prompt but neighbouring features get different +# prompts. The pools emphasise grounded / first-person / prosaic names +# with the occasional classical one — matching how real settlers on +# frontier worlds actually named places. +# +# Preamble wording matters too: "Settlers name …" reminds the model that +# these are human-chosen names, not fantasy coinages. The negative +# constraint "Most names are mundane" reinforces the grounded bias. -_FEW_SHOT_RIVER = ( - "You name rivers on alien planets. Reply with ONLY the name, 1-3 words, " - "no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Wolcott Beck\n" - "Style: Korean/Japanese. Answer: Nakamura Stream\n" - "Style: Portuguese/Swahili. Answer: Ribeiro do Sal\n" - "\n" - "Style: {inflection}. Answer:" -) -_FEW_SHOT_OCEAN = ( - "You name oceans on alien planets. Reply with ONLY the name, 1-3 words, " - "no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Tarnsea\n" - "Style: Korean/Japanese. Answer: Aomine Deep\n" - "Style: German/Dutch/Nordic. Answer: Nordhav\n" - "\n" - "Style: {inflection}. Answer:" -) -_FEW_SHOT_SEA = ( - "You name seas on alien planets. Reply with ONLY the name, 1-3 words, " - "no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Harven Sea\n" - "Style: Portuguese/Swahili. Answer: Mar de Quelim\n" - "Style: institutional Latin. Answer: Mare Ardens\n" - "\n" - "Style: {inflection}. Answer:" -) -_FEW_SHOT_LAKE = ( - "You name lakes on alien planets. Reply with ONLY the name, 1-3 words, " - "no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Kelstern Mere\n" - "Style: Korean/Japanese. Answer: Shiromizu\n" - "Style: German/Dutch/Nordic. Answer: Eikmeer\n" - "\n" - "Style: {inflection}. Answer:" -) -_FEW_SHOT_MOUNTAIN = ( - "You name mountain ranges on alien planets. Reply with ONLY the name, " - "1-3 words, no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Drayton Spine\n" - "Style: Korean/Japanese. Answer: Takamine Ridge\n" - "Style: German/Dutch/Nordic. Answer: Drachenberg\n" - "\n" - "Style: {inflection}. Answer:" -) -_FEW_SHOT_CITY_CAPITAL = ( - "You name capital cities on alien planets. Reply with ONLY the name, " - "1-2 words, no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Holmwood\n" - "Style: Korean/Japanese. Answer: Seungmun\n" - "Style: Portuguese/Swahili. Answer: Porto Keli\n" - "\n" - "Style: {inflection}. Planet: {planet_class}. Answer:" -) -_FEW_SHOT_CITY_SECONDARY = ( - "You name secondary cities on alien planets. Reply with ONLY the name, " - "1-2 words, no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Carberry\n" - "Style: Korean/Japanese. Answer: Yurigawa\n" - "Style: German/Dutch/Nordic. Answer: Wolfsturm\n" - "\n" - "Style: {inflection}. Planet: {planet_class}. Answer:" -) -_FEW_SHOT_POI_TRANSIT = ( - "You name gate terminals on alien planets. Reply with ONLY the name, " - "2-3 words ending in 'Gate Terminal', 'Transit', 'Exchange', or " - "'Concourse'. No brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Holmwood Gate Terminal\n" - "Style: Korean/Japanese. Answer: Seungmun Transit\n" - "Style: institutional Latin. Answer: Meridian Concourse\n" - "\n" - "Style: {inflection}. Answer:" -) -_FEW_SHOT_POI_INSTITUTIONAL = ( - "You name institutional landmarks on alien planets. Reply with ONLY " - "the name, 2-4 words, no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Holmwood Assembly Hall\n" - "Style: Korean/Japanese. Answer: Seungmun Archive\n" - "Style: institutional Latin. Answer: Meridian Registry\n" - "\n" - "Style: {inflection}. Answer:" -) -_FEW_SHOT_POI_CULTURAL = ( - "You name cultural landmarks on alien planets. Reply with ONLY the " - "name, 2-4 words, no brackets, no quotes, no markdown, no label.\n" - "\n" - "Style: British/Australian. Answer: Holmwood Commons\n" - "Style: Korean/Japanese. Answer: Yurigawa Grounds\n" - "Style: Portuguese/Swahili. Answer: Praça do Vento\n" - "\n" - "Style: {inflection}. Answer:" -) +# Each pool entry is a list of `(style_label, example_name)` pairs. The +# style labels are cross-corridor — they teach Gemma the pattern, not +# a specific corridor's vocabulary. The target corridor's inflection +# gets substituted at the END of the prompt. -FEATURE_PROMPTS: dict[str, str] = { - "river": _FEW_SHOT_RIVER, - "ocean": _FEW_SHOT_OCEAN, - "sea": _FEW_SHOT_SEA, - "lake": _FEW_SHOT_LAKE, - "mountain_range": _FEW_SHOT_MOUNTAIN, - "city_capital": _FEW_SHOT_CITY_CAPITAL, - "city_secondary": _FEW_SHOT_CITY_SECONDARY, - "poi_transit": _FEW_SHOT_POI_TRANSIT, - "poi_institutional": _FEW_SHOT_POI_INSTITUTIONAL, - "poi_cultural": _FEW_SHOT_POI_CULTURAL, +_RIVER_POOLS: list[list[tuple[str, str]]] = [ + # Pool 0 — possessive, surnames dominant with a first-name mixed in + [ + ("British/Australian", "Cooper's Creek"), + ("Irish", "Maura's Run"), # first name + ("Dutch", "Van Dael's Beek"), + ("Italian", "Fiume Bruno"), + ("Japanese", "Tanaka Stream"), + ("Polish", "Kowalski Potok"), + ], + # Pool 1 — compass / descriptive, mixed cultures + [ + ("Australian", "West Brook"), + ("Nordic", "Nordälven"), + ("French", "Ruisseau du Nord"), + ("Japanese", "Kita-gawa"), + ("Swahili", "Mto wa Kaskazini"), + ("Hungarian", "Északi Patak"), + ], + # Pool 2 — colour / feature observation + [ + ("Irish", "Blackwater"), + ("German", "Braunbach"), + ("Spanish", "Río Verde"), + ("Russian", "Chornaya Rechka"), + ("Korean", "Ha-gang"), + ("Portuguese", "Ribeira Negra"), + ], + # Pool 3 — short single-word / old-world prosaic + [ + ("British", "Mill Run"), + ("Dutch", "Oude Wetering"), + ("Nordic", "Stenbäck"), + ("Japanese", "Sakura-gawa"), + ("Portuguese", "Ribeiro Seco"), + ("Czech", "Starý Potok"), + ], + # Pool 4 — founder surname + feature + [ + ("British/Australian", "Garner Creek"), + ("Dutch", "Meijer Beek"), + ("Nordic", "Sveinsström"), + ("Korean", "Choi Stream"), + ("Italian", "Fiume Marconi"), + ("Greek", "Petrakis Rema"), + ], + # Pool 6 — founder FIRST name possessive (Clifford's Bay shape) + # Added so first-name-possessive naming joins the rotation alongside + # the surname pools without replacing any of them. + [ + ("British", "Clifford's Bay"), + ("Irish", "Maura's Run"), + ("Japanese", "Yuki's Pool"), + ("Italian", "Rio di Marco"), + ("Portuguese", "Rio de Ana"), + ("French", "Rivière d'Elena"), + ], + # Pool 5 — classical / institutional / Latinate (occasional ~17%) + [ + ("British/Australian", "Aqueduct Run"), + ("Italian", "Acqua Vetusta"), + ("Spanish", "Río Antiguo"), + ("French", "Vieille Rivière"), + ("German", "Altwasser"), + ], +] + +_MOUNTAIN_POOLS: list[list[tuple[str, str]]] = [ + # Pool 0 — compass / direct observation (the "Western Ridge" shape) + [ + ("British/Australian", "Western Ridge"), + ("Dutch", "Noordrug"), + ("Nordic", "Sørkammen"), + ("Japanese", "Minami-yama"), + ("Portuguese", "Serra do Sul"), + ("Hungarian", "Északi Hát"), + ], + # Pool 1 — surname + feature, cosmopolitan + [ + ("British/Australian", "Drayton Hills"), + ("Italian", "Monti Rovere"), + ("Dutch", "Van Dijk Heuvels"), + ("Korean", "Park Sanmaek"), + ("Polish", "Góry Brzeskie"), + ("French", "Crête Valmont"), + ], + # Pool 2 — colour / shape descriptor + [ + ("British", "The Long Spine"), + ("Japanese", "Shiro-yama"), + ("Russian", "Bely Khrebet"), + ("Portuguese", "Serra Branca"), + ("German", "Blauberg"), + ("Spanish", "Sierra Roja"), + ], + # Pool 3 — short single-word + [ + ("British", "Fell Back"), + ("Japanese", "Takamine"), + ("Dutch", "Klipfjord"), + ("Nordic", "Torsfell"), + ("Portuguese", "Cabeço"), + ("German", "Eichfels"), + ], + # Pool 4 — something-the-settlers-said (The-word / definite-article) + [ + ("British", "The Backbone"), + ("Spanish", "El Espinazo"), + ("Italian", "La Schiena"), + ("Russian", "Khrebet"), + ("Portuguese", "O Dorso"), + ("French", "L'Épine"), + ], + # Pool 5 — classical / institutional / Latinate (occasional ~17%) + [ + ("British/Australian", "Cassian Range"), + ("Italian", "Monti Augusti"), + ("Portuguese", "Monte Augusto"), + ("Latin", "Mons Cassianus"), + ("French", "Massif Aurélien"), + ], + # Pool 6 — founder FIRST name + feature + [ + ("British", "Clifford's Ridge"), + ("Irish", "Maeve's Back"), + ("Japanese", "Keiko's Peak"), + ("Spanish", "Sierra de Elena"), + ("French", "Crête de Pierre"), + ("Russian", "Anushka Khrebet"), + ], +] + +_LAKE_POOLS: list[list[tuple[str, str]]] = [ + [ + ("British", "Cold Tarn"), + ("Dutch", "Winterplas"), + ("Nordic", "Kalltjärn"), + ("Japanese", "Shizuko"), + ("Portuguese", "Lagoa Funda"), + ("Finnish", "Kylmäjärvi"), + ], + [ + ("British/Australian", "Mildern Mere"), + ("Italian", "Lago d'Argento"), + ("Japanese", "Aoike"), + ("Polish", "Jezioro Srebrne"), + ("German", "Bergsee"), + ("French", "Lac Clair"), + ], + [ + ("British", "Three Oaks Pool"), + ("Dutch", "Driehoekplas"), + ("Japanese", "Midori-ike"), + ("Hungarian", "Három Tölgy Tava"), + ("Portuguese", "Poça Grande"), + ("Spanish", "Laguna Grande"), + ], +] + +_OCEAN_POOLS: list[list[tuple[str, str]]] = [ + [ + ("British", "Tarnsea"), + ("Japanese", "Aomi"), + ("Nordic", "Nordhav"), + ("Portuguese", "Mar do Sul"), + ("Dutch", "Zuidzee"), + ("Italian", "Mare Meridio"), + ], + [ + ("British", "The Long Main"), + ("Japanese", "Kuro-umi"), + ("Nordic", "Stormsø"), + ("Portuguese", "Mar Profundo"), + ("Russian", "Bolshoye More"), + ("French", "Grand Large"), + ], +] + +_SEA_POOLS: list[list[tuple[str, str]]] = [ + [ + ("British", "Harven Sea"), + ("Japanese", "Minami-kai"), + ("Nordic", "Sønderhav"), + ("Portuguese", "Mar de Quelim"), + ("Italian", "Mare Toscano"), + ("Dutch", "Zeebocht"), + ], + [ + ("British", "Cold Gulf"), + ("Japanese", "Nagi-kai"), + ("Nordic", "Iskullfjord"), + ("Portuguese", "Golfo das Ilhas"), + ("Polish", "Zatoka Zimna"), + ("German", "Tiefbucht"), + ], +] + +_CITY_CAPITAL_POOLS: list[list[tuple[str, str]]] = [ + # Pool 0 — founder / homestead / surname-town + [ + ("British/Australian", "Holmwood"), + ("Dutch", "Van Damhoeve"), + ("Japanese", "Yuna"), + ("Italian", "Borgo Marconi"), + ("Polish", "Kowalowo"), + ("Portuguese", "Vila Moreira"), + ], + # Pool 1 — compass + old-country place name + [ + ("British", "Westfield"), + ("Nordic", "Sørholm"), + ("Japanese", "Kita-sato"), + ("French", "Saint-Nord"), + ("Hungarian", "Kelethegy"), + ("German", "Südkamp"), + ], + # Pool 2 — short rooted stem (farm / town / kiln etc) + [ + ("British", "Kiln"), + ("Dutch", "Stenen"), + ("Japanese", "Sora"), + ("Portuguese", "Paço"), + ("Italian", "Forno"), + ("Czech", "Starovice"), + ], + # Pool 3 — explicitly mundane / functional + [ + ("British", "Landing"), + ("Nordic", "Brygga"), + ("Japanese", "Habu"), + ("Portuguese", "Cabo"), + ("Dutch", "Haven"), + ("French", "Débarquement"), + ], + # Pool 4 — classical / institutional / Latinate (occasional ~20%) + [ + ("British/Australian", "Meridian"), + ("Italian", "Augusta"), + ("Portuguese", "Porto Imperial"), + ("Latin", "Solarium"), + ("French", "Saint-Aurélien"), + ], + # Pool 5 — founder FIRST name settlement + [ + ("British", "Clifford's Landing"), + ("Irish", "Maura's Cross"), + ("Japanese", "Yuki-mura"), + ("Italian", "Villa di Marco"), + ("Portuguese", "Vila Helena"), + ("French", "Chez Pierre"), + ], +] + +_CITY_SECONDARY_POOLS: list[list[tuple[str, str]]] = [ + [ + ("British/Australian", "Carberry"), + ("Korean/Japanese", "Yurigawa"), + ("Dutch", "Kleindorp"), + ("Italian", "Piccola Villa"), + ("Polish", "Nowawieś"), + ("Portuguese", "Ribeirão"), + ], + [ + ("British/Australian", "Garner's Cross"), + ("French", "Sainte-Marie"), + ("Japanese", "Tanaka-no-mura"), + ("Nordic", "Sveinsby"), + ("Hungarian", "Kiskút"), + ("Portuguese", "Vila Nova"), + ], + [ + ("British", "Kelstern"), + ("Japanese", "Shirakawa"), + ("Dutch", "Hoogland"), + ("Czech", "Starovice"), + ("Spanish", "Alta Vista"), + ("German", "Talhöhe"), + ], + [ + ("British", "Mill End"), + ("Japanese", "Shimo-machi"), + ("Nordic", "Nedreby"), + ("French", "Les Moulins"), + ("Portuguese", "Marginal"), + ("Italian", "Fondobasso"), + ], + # Classical / Latinate (occasional) + [ + ("British", "Prospect"), + ("Japanese", "Seishin"), + ("Italian", "Porta Aurea"), + ("Portuguese", "Pórtico"), + ("French", "Consulat"), + ], + # Founder FIRST-name settlements + [ + ("British", "Clifford's Ferry"), + ("Irish", "Maeve's Quay"), + ("Japanese", "Yuki-no-mura"), + ("Italian", "Casa Elena"), + ("Portuguese", "Vila de Ana"), + ("French", "Saint-Martin"), + ], +] + +_POI_TRANSIT_POOLS: list[list[tuple[str, str]]] = [ + [ + ("British", "Holmwood Gate Terminal"), + ("Japanese", "Yurigawa Transit"), + ("Dutch", "Noordpoort Gate Terminal"), + ("Portuguese", "Porto Exchange"), + ("French", "Gare du Nord Concourse"), + ], + [ + ("British", "West Gate Terminal"), + ("Nordic", "Brygga Transit"), + ("Italian", "Porta Vecchia"), + ("Japanese", "Kita-sato Transit"), + ("German", "Steinhof Gate Terminal"), + ], +] + +_POI_INSTITUTIONAL_POOLS: list[list[tuple[str, str]]] = [ + [ + ("British", "Holmwood Assembly Hall"), + ("Japanese", "Seungmun Archive"), + ("Italian", "Palazzo Civico"), + ("Portuguese", "Câmara Municipal"), + ("German", "Altes Rathaus"), + ], + [ + ("British", "Founders' Registry"), + ("French", "Registre Général"), + ("Japanese", "Kō Records Office"), + ("Polish", "Archiwum Miejskie"), + ("Dutch", "Burgerhuis"), + ], +] + +_POI_CULTURAL_POOLS: list[list[tuple[str, str]]] = [ + [ + ("British", "The Commons"), + ("Japanese", "Yurigawa Grounds"), + ("Italian", "Piazza Nuova"), + ("Portuguese", "Praça do Vento"), + ("German", "Marktplatz"), + ], + [ + ("British", "The Meeting House"), + ("French", "Place des Fondateurs"), + ("Japanese", "Sakura Grounds"), + ("Polish", "Rynek Stary"), + ("Nordic", "Gamle Torget"), + ], +] + +# Map feature type → pools + preamble + length hint + extra context hook. +_PROMPT_CONFIG: dict[str, dict] = { + "river": { + "pools": _RIVER_POOLS, + "subject": "rivers", + "length_hint": "1-3 words", + }, + "ocean": { + "pools": _OCEAN_POOLS, + "subject": "oceans", + "length_hint": "1-3 words", + }, + "sea": { + "pools": _SEA_POOLS, + "subject": "seas", + "length_hint": "1-3 words", + }, + "lake": { + "pools": _LAKE_POOLS, + "subject": "lakes", + "length_hint": "1-3 words", + }, + "mountain_range": { + "pools": _MOUNTAIN_POOLS, + "subject": "mountain ranges", + "length_hint": "1-3 words", + }, + "city_capital": { + "pools": _CITY_CAPITAL_POOLS, + "subject": "their capital town", + "length_hint": "1-2 words", + "include_planet": True, + }, + "city_secondary": { + "pools": _CITY_SECONDARY_POOLS, + "subject": "their secondary towns", + "length_hint": "1-2 words", + "include_planet": True, + }, + "poi_transit": { + "pools": _POI_TRANSIT_POOLS, + "subject": "gate terminals / transit hubs", + "length_hint": "2-3 words ending in 'Gate Terminal', 'Transit', " + "'Exchange', or 'Concourse'", + }, + "poi_institutional": { + "pools": _POI_INSTITUTIONAL_POOLS, + "subject": "institutional landmarks", + "length_hint": "2-4 words", + }, + "poi_cultural": { + "pools": _POI_CULTURAL_POOLS, + "subject": "cultural landmarks", + "length_hint": "2-4 words", + }, } +def _build_prompt( + feature_type: str, + inflection: str, + planet_class: str, + body_id: str, + local_id: str, + attempt: int, +) -> str: + """Assemble a few-shot prompt for the given feature type. + + The example pool rotates per call via a deterministic hash of + (body_id, local_id, attempt) — this puts a finger on the sampling + scales so neighbouring features on the same body don't all draw + from an identical prompt and collapse to identical outputs. + """ + cfg = _PROMPT_CONFIG.get(feature_type) + if cfg is None: + return "" + pools: list[list[tuple[str, str]]] = cfg["pools"] + + # Deterministic pool pick: same feature always hits the same pool on + # attempt 0; retries rotate forward so a rejected name gets a + # different example set, not just a different seed. + salt = int( + hashlib.sha256(f"{body_id}|{local_id}|{attempt}".encode()).hexdigest()[:8], + 16, + ) + pool = pools[salt % len(pools)] + + subject = cfg["subject"] + # Use "named X" for collective/plural subjects, "called X" for + # singular possessive ones ("their capital town"). Heuristic: if the + # subject starts with "their", use "called"; otherwise "named". + verb = "called" if subject.startswith("their ") else "named" + 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. Classical or epic names are rare. " + f"Reply with ONLY the name, {cfg['length_hint']}, no brackets, " + f"no quotes, no markdown, no label." + ) + + lines = [preamble, ""] + for style, example in pool: + lines.append(f"Style: {style}. Answer: {example}") + lines.append("") + + tail = f"Style: {inflection}." + if cfg.get("include_planet"): + tail += f" Planet: {planet_class}." + tail += " Answer:" + lines.append(tail) + + return "\n".join(lines) + + # --------------------------------------------------------------------------- # Blocklist # --------------------------------------------------------------------------- @@ -758,16 +1142,12 @@ def name_feature( mountain range on the same world, which reads as ridiculous even when the types differ. """ - template = FEATURE_PROMPTS.get(feature_type) corridor = ctx.get("cultural_corridor") or "core" - if template is None: + 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) - prompt = template.format( - inflection=palette["inflection"], - planet_class=ctx.get("planet_class") or "habitable", - ) + planet_class = ctx.get("planet_class") or "habitable" dedup_key = (corridor, feature_type) used = corpus.setdefault(dedup_key, set()) @@ -797,6 +1177,17 @@ def name_feature( for attempt in range(max_attempts): seed = _seed_for(world_seed, body_id, local_id, attempt) + # Rotate the example pool per attempt so retries get a different + # prompt, not just a different seed — big variety payoff for a + # small model like Gemma 2 2B. + prompt = _build_prompt( + feature_type, + inflection=palette["inflection"], + planet_class=planet_class, + body_id=body_id, + local_id=local_id, + attempt=attempt, + ) try: raw = voice.request(prompt, seed) except RuntimeError as e: @@ -854,7 +1245,8 @@ def load_body_context(body_id: str, system_id: str, conn: sqlite3.Connection) -> """ SELECT b.planet_class, b.settlement_pattern, COALESCE(b.cultural_corridor, s.cultural_corridor, s.geographic_sector), - b.population, b.economic_role + b.population, b.economic_role, + b.proper_name, s.proper_name FROM bodies b JOIN star_systems s ON b.system_id = s.system_id WHERE b.body_id = ? @@ -866,8 +1258,10 @@ def load_body_context(body_id: str, system_id: str, conn: sqlite3.Connection) -> "planet_class": None, "settlement_pattern": None, "cultural_corridor": None, "population": 0, "economic_role": None, "pop_band": "unknown", + "body_proper_name": None, "system_proper_name": None, } - planet_class, settlement_pattern, corridor, population, economic_role = row + (planet_class, settlement_pattern, corridor, population, economic_role, + body_proper_name, system_proper_name) = row return { "planet_class": planet_class, "settlement_pattern": settlement_pattern, @@ -875,6 +1269,8 @@ def load_body_context(body_id: str, system_id: str, conn: sqlite3.Connection) -> "population": population or 0, "economic_role": economic_role, "pop_band": body_population_band(population or 0), + "body_proper_name": body_proper_name, + "system_proper_name": system_proper_name, } @@ -1283,6 +1679,22 @@ def main(): corpus: dict[tuple[str, str], set[str]] = {} stem_counts: dict[str, int] = {} + # Cache of system_id → proper_name so we can emit a header line the + # first time we hit each system without re-querying per body. + system_name_cache: dict[str, str] = { + row[0]: row[1] or "" + for row in conn.execute( + "SELECT system_id, proper_name FROM star_systems" + ).fetchall() + } + body_name_cache: dict[str, str] = { + row[0]: row[1] or "" + for row in conn.execute( + "SELECT body_id, proper_name FROM bodies" + ).fetchall() + } + last_system_id: str | None = None + totals = { "cities": 0, "rivers": 0, "oceans": 0, "mountain_ranges": 0, "pois": 0, "preserved": 0, "errors": 0, @@ -1300,6 +1712,21 @@ def main(): ) as voice: for i, markers_path in enumerate(markers_paths): body_id, system_id = _body_id_from_path(markers_path) + + # System header: print when we enter a new system, so the + # user can see which part of the reach we're in. Includes + # the proper_name if the system has one (e.g. "Tau Ceti", + # "p Eridani", "Groombridge"). + 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] + 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})") + seen_systems.add(system_id) t0 = time.time() counts = process_body( @@ -1323,9 +1750,16 @@ def main(): hop = hop_order.get(body_id, (99, ""))[0] progress = f"{body_progress} {sys_progress} hop={hop}" + # Append the body's proper name if it has one ("Threshold", + # "Arden", "Earth") so the log reads like a tour through + # the reach rather than a wall of body_id slugs. + body_proper = body_name_cache.get(body_id, "") + body_label = body_id if not body_proper else f"{body_id:14s} ({body_proper})" + body_label = body_label if body_proper else f"{body_id:14s}" + if "error" in counts: totals["errors"] += 1 - log(f" [{progress}] {body_id:14s} ERROR: {counts['error']}") + log(f" [{progress}] {body_label} ERROR: {counts['error']}") continue generated: dict[str, list[str]] = counts.pop("generated", {}) or { @@ -1342,7 +1776,7 @@ def main(): for k in ("cities", "rivers", "oceans", "mountain_ranges", "pois", "preserved"): totals[k] += counts[k] log( - f" [{progress}] {body_id:14s} +{touched} names " + f" [{progress}] {body_label} +{touched} names " f"({elapsed:.1f}s) — " f"cities={counts['cities']} rivers={counts['rivers']} " f"oceans={counts['oceans']} mtns={counts['mountain_ranges']} " @@ -1365,11 +1799,10 @@ def main(): log(f" {section_label:7s} {preview}") else: totals["preserved"] += counts["preserved"] - if args.verbose: - log( - f" [{progress}] {body_id:14s} " - f"no blanks ({counts['preserved']} preserved)" - ) + log( + f" [{progress}] {body_label} " + f"(skip — {counts['preserved']} names already set)" + ) # Periodic cumulative snapshot so the log has regular # checkpoint lines the user can scroll to.