feat(tooling): grounding overhaul + richer naming palettes (#833)

Substantial quality pass on gemma_naming.py driven by user review of
the first real-mode smoke test output. The earlier run produced names
that read too sci-fi / epic-fantasy / same-y: Aureus, Aetheria,
Stellaris, Nexus, Elysium. Root cause analysis + fixes:

1. Runtime timestamps. The log prefix is now
   `[HH:MM:SS +00h03m]` — clock time plus elapsed-since-start. Gives
   the user an at-a-glance sense of how long the run has been going
   without scrolling back to the banner.

2. System / body headers. When the loop enters a new system it prints
   `── SYSTEM K/N  GJ 71 — Tau Ceti  (hop 0)`. Each body line now
   shows `GJ71c (Threshold)` if the body has a proper_name in
   systems.db, so the log reads like a tour of the reach rather than
   a wall of body_id slugs. Preserved (already-named) bodies now log
   a compact "(skip — N names already set)" line so progress is
   visible even when no inference happened.

3. Prompt grounding overhaul. The old few-shot examples were all
   classical/epic (Wolcott Beck, Nakamura Stream, Ribeiro do Sal,
   Drayton Spine) which biased Gemma 2 2B toward Latin/Greek
   coinages. New preambles use the shape:
       "Settlers named X after themselves, after what they saw, or
        after places back home. Most names are mundane, short, and
        direct — a surname, a compass direction, a feature, a
        practical description. Classical or epic names are rare."
   Combined with grounded example pools, Gemma now produces names
   like "Cooper's Creek", "Western Ridge", "The Highroad",
   "Blackwood Creek", "Dustbowl".

4. Core corridor relabel. The "core" palette inflection was
   "institutional Latin / pan-Anglo / Gateway-era", which pattern-
   matched in Gemma's training data to "make up Latin-sounding
   words" (→ Ardenia, Aurelia, Stellaris). Now it's
   "administrative English / Gateway-era" and the outputs are
   prosaic — Port Dundas, East Ridge, Meridian, Landing.

5. Rotating few-shot example pools. Each feature type now has 5-7
   pools of 5-6 examples each. `_build_prompt()` picks a pool
   deterministically per (body_id, local_id, attempt) so:
   - Same feature always gets the same prompt (determinism preserved).
   - Neighbouring features on the same body get different prompts
     (output variance — the sampler doesn't collapse to a single
     mode when you ask for 16 mountain names in a row).
   - Retries rotate to a new pool, not just a bumped seed, giving
     dedup failures a clean second attempt.

6. Cosmopolitan cultural variety in the examples. Earlier pools only
   showed British/Australian, Korean/Japanese, Portuguese/Swahili,
   German/Dutch/Nordic axes — the four reach corridors. Gemma learned
   "names come in four flavours". New pools span Dutch, Nordic,
   Italian, French, Polish, Hungarian, Czech, Spanish, Russian,
   Finnish, Greek, Irish, Japanese, and British — teaching the model
   that names can be any real Earth cultural register, not just the
   corridor label. The result: actual Dutch names (Egelantier,
   Hochland, Van Damhoeve), actual Nordic (Lundstad, Brygga),
   actual Italian (Borgo Marconi, Piazza Nuova), etc.

7. First-name possessive pools. Per user feedback, settler naming
   includes both surnames ("Cooper's Creek") and first names
   ("Clifford's Bay", "Maura's Run", "Yuki's Pool"). Each feature
   type now has a dedicated first-name-possessive pool in addition
   to the existing surname pool — the two rotate alongside so both
   patterns show up without either dominating.

8. One "classical/Latinate" pool per feature type (≈17% of calls
   given 5-7 pools per type). Keeps occasional Latin flavour without
   making it dominant — the user explicitly noted that replacing
   one pattern with another "is never a clean fix for a randomizer."

9. Earth-name blocklist expansion. The Gemma 2 model reached for
   real European names ("Weser", "Rhine", "Reykjavik") in the first
   real run. Added 21 European rivers (Rhine, Weser, Elbe, Oder,
   Vistula, Loire, Rhône, Douro, Tagus, Ebro, Po, Arno, Tiber, …)
   and 25 Nordic/Eastern European cities (Reykjavik, Oslo, Gdansk,
   Krakow, Prague, Warsaw, Budapest, Belgrade, …). Case-insensitive
   "The <name>" stripping still applies so "The Great Divide" also
   matches "Great Divide".

Combined smoke test after these changes (10 real-mode prompts across
core + west_reach):
  - core:       Port Dundas, The Backbone, Dustbowl, Blackwood Creek
  - west_reach: Egelantier, Hochland, Der Rücken, Lundstad, Klipfjord
  - no placeholder residue, no markdown, no 5+ word outputs.

--shard is gone (dead code since GPU contention killed parallelism).
Resume semantics are still free: re-run the same command and
already-named bodies skip via the preserved path.
This commit is contained in:
2026-04-15 11:35:07 +02:00
parent 6decf56b2d
commit cbeadc3184
2 changed files with 637 additions and 153 deletions
+51
View File
@@ -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
+586 -153
View File
@@ -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.