Full architecture doc covering the Gemma 4 batch naming pipeline: pipeline stages, cultural registers, body ordering, known limitations, QA process, and extension guide. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
21 KiB
Atlas Naming Pipeline
Reference document for the feature-naming pipeline that populates
markers.json files and the atlas_* tables in systems.db.
Related: ticket #833, D-191 §4.
1. Overview
The atlas naming pipeline generates human-readable names for every unnamed geographic feature across ~2,394 planetary bodies in the Settled Reach. Features covered: cities (capital and secondary), rivers, oceans/seas/lakes, mountain ranges, and points of interest (transit, institutional, cultural).
Inputs:
wiki/star-systems/*/bodies/*/markers.json— one file per body, containing arrays of feature records with optionalnamefieldsserver/data/systems.db—star_systems,bodies,atlas_*tableswiki/star-systems/*/index.mdandgttr.md— cultural context for register selectiontooling/planet-gen/earth_blocklist.txt— exact-match rejection list for well-known Earth place names
Outputs:
markers.jsonfiles updated in-place with the newnamefieldsatlas_cities,atlas_rivers,atlas_mountain_ranges,atlas_oceans,atlas_poisrows insystems.dbsynced immediately after each body
Hand-authored names in markers.json are never overwritten. Re-running the
pipeline is safe: any body whose features all have non-empty name fields is
skipped entirely.
Wall time on an RX 9070 (ROCm) is roughly 4-6 hours for a full run.
2. Architecture
The inference binary: sr-voice-tooling
The pipeline does NOT use the game's Gemma 2 runtime (sr-voice). It uses a
separate binary, sr-voice-tooling, built against Gemma 4 (GGUF) and compiled
for the ROCm / HIP stack. The binary lives outside all git worktrees at:
~/Projects/settled-reach/binaries/sr-voice-tooling
~/Projects/settled-reach/models/gemma-4.gguf
The split exists because the game's runtime (Gemma 2, 2B parameters, 1024-token context) is optimised for low-latency in-game NPC dialogue, while the tooling binary trades latency for richer outputs and a larger context window.
stdio JSONL protocol
VoiceSubprocess in gemma_naming.py manages the binary as a long-lived
subprocess. Communication is newline-delimited JSON on stdin/stdout:
Request (one JSON object per line):
{"prompt": "...", "seed": 12345678}
Response:
{"text": "..."}
or on error:
{"error": "..."}
The subprocess is restarted every --refresh requests (default: 1000) to
prevent KV-cache context bleed accumulating across bodies. Seeds are derived
deterministically from (body_id, feature_type, world_seed) so the same
prompt always produces the same output — re-running for a partially-completed
body reproduces the same names, which helps diagnose quality issues without
introducing fresh variation.
Running inside distrobox
The ROCm HIP kernels embedded in sr-voice-tooling are compiled for gfx1201
(AMD Navi 48 / RX 9070). Because libhipblas.so.2 and related ROCm libraries
are only installed in the reach-build distrobox container, run-atlas-naming.sh
and the test scripts invoke the binary via distrobox enter reach-build --.
The shell wrapper verifies the gfx1201 target is present before starting.
3. Pipeline Stages
For each body, process_body() executes the following stages in order.
Stage 1: Register selection (per system, wiki-grounded)
At the start of each new system, select_register() asks Gemma to choose
the best cultural register from the corridor's CORRIDOR_SUBSTYLES list,
based on the system's wiki/star-systems/{slug}/index.md and gttr.md.
load_wiki_context() reads both files. _extract_cultural_lines() scans
index.md for lines containing cultural keywords (heritage, founding, settler,
diaspora, specific language names, etc.) and returns up to 8 of them. The first
substantive paragraph from gttr.md (skipping the title line) is used as the
context hook.
The prompt is few-shot: two worked examples from different corridors, then the
target system's context followed by the numbered option list. Gemma replies
with a single digit. The pipeline retries up to max_attempts=3 times with
rotated seeds. On failure it falls back to palette_for(), a hash-based
deterministic selection: hash(system_id) % len(substyles).
The selected register applies to all bodies in that system. All bodies in the same system therefore share a consistent cultural voice.
Stage 2: Mood injection (per body)
mood_for_body(body_id, world_seed) picks one entry from MOOD_POOL via
SHA-256(f"mood|{body_id}|{world_seed}"). The 13-word pool is:
ambition, family, wealth, community, industry, pride, fleeting,
hope, fear, isolation, devotion, defiance, loss
The chosen mood is injected into each batch prompt as a clause:
"The settlers here had a sense of {mood}." This is the primary mechanism for
vocabulary divergence across bodies in the same system — same cultural register,
different emotional colouring. The mood is deterministic per body so re-runs
produce identical results.
Stage 3: Batch naming (2x oversampled)
name_features_batch() (in naming_core.py) groups blank features by type and
requests count * 2 names in a single prompt call. The oversample factor means
the ranking step (Stage 4) has candidates to work with even if the model repeats
some names.
build_batch_prompt() assembles the prompt with:
- A preamble emphasising mundane, practical settler names over epic or classical ones
- Optional system name, body name, and
system_hook(the gttr.md characterisation) - The mood clause (if a mood was injected)
- The "already used" list (trimmed to fit within
ctx_sizetokens) - Two fixed few-shot examples showing comma-separated batch format (Scottish Highland and Dutch colonial)
- The tail:
"Style: {inflection}. {count*2} names:"
The model pattern-completes a comma-separated list. parse_batch_response()
takes the first line only (the model often continues with explanation), splits
on commas, strips quotes, filters via is_valid_name(), and deduplicates
preserving order.
For single-name prompts (the legacy _build_prompt() / name_feature() path),
the prompt uses a rotating pool of few-shot style+answer pairs. The pool is
picked deterministically by hash(body_id | local_id | attempt) so retries
rotate to a different example set rather than re-querying with the same prompt
and a different seed.
Stage 4: Levenshtein distinctiveness ranking
select_distinct(candidates, count, taken) in naming_core.py greedily
selects the N most distinct names from the oversampled candidate list.
The distinctiveness metric is word_avg_distance(): for each word in the
shorter name, find the closest word (by Levenshtein edit distance) in the
longer name, then average across words. This means shared structural words
(Serra, The, Glen, Ridge) lower a candidate's preference score but do not
block it outright — the unique words in the name pull the average up.
Selection is greedy: pick the candidate with the highest minimum distance to
all already-selected names plus all taken names, add it to the selected set,
repeat. No hard threshold — all candidates except exact string matches to
taken are eligible.
Stage 5: Cross-body dedup
The corpus dict in main() maps (system_id, feature_type) to the set of
all names already assigned within that system. Before each batch call, taken
is built from corpus[(system_id, ft)] plus body_used (all names already
assigned to the current body across all feature types). This enforces two
constraints:
- No body has the same name for two different features (e.g. a river and a mountain range).
- No two bodies in the same system share the same name for the same feature type (e.g. two rivers in GJ 144 named "Cooper's Creek").
Cross-corridor collisions (the same name appearing in two different systems from different corridors) are allowed and expected.
taken is pruned when it exceeds the context budget: the most recently added
entries are dropped first. This means a very large system (many bodies, many
features) may eventually lose dedup coverage for its earliest names at the
tail of the run.
Stage 6: Adjacent-register refill
If the batch call + ranking still yields fewer than count names (after
dedup against taken), name_features_batch() fires a refill call using the
next sub-style in the corridor's CORRIDOR_SUBSTYLES list, wrapping around.
The refill asks for shortfall * 3 candidates. Refill results go through the
same select_distinct() ranking against taken + selected. This ensures
the feature list is always fully populated even when the primary register's
vocabulary is exhausted.
4. Cultural Registers
CORRIDOR_SUBSTYLES
CORRIDOR_SUBSTYLES in gemma_naming.py is a dict[str, list[dict[str, str]]]
mapping corridor name to an ordered list of sub-style dicts. Each sub-style has
two keys: inflection (the style label injected into the prompt) and examples
(a comma-separated string of illustrative names, shown during select_register()
option display but NOT injected into batch prompts — only the inflection label
reaches the model during naming).
Active corridors and their sub-styles:
core (6 sub-styles)
- English countryside, rural, agricultural settlers
- British colonial settlement era
- American frontier, practical, geographic
- American municipal, administrative, cosmopolitan
- Classical references, institutional, civic
- Australian and New Zealand settler
north_reach (5 sub-styles)
- English rural, village and parish names
- Scottish Highland and Lowland place-names
- Australian outback, station and property names
- Irish rural and coastal settlement
- South African English settler
south_reach (5 sub-styles)
- Portuguese colonial era, Iberian
- Brazilian interior, frontier settlement
- East African Swahili coastal
- Cape Verdean and West African
- Angolan and Mozambican settlement
east_reach (5 sub-styles)
- Korean place-name tradition
- Japanese rural and coastal settlement
- Taiwanese and Hakka settler
- Filipino settler community
- Mixed East Asian diaspora, cosmopolitan
west_reach (5 sub-styles)
- German settlement, orderly and compound names
- Dutch colonial, low-country
- Nordic and Scandinavian
- Polish and Czech settler
- Baltic and Finnish settler
deep_frontier (3 sub-styles)
- frontier founder-name era, surname-first, any Earth culture
- frontier descriptive, geographic features named by surveyors
- frontier outpost, functional and military
Legacy aliases (sol-gateway-axis, inner_corridor, inner_orbit) map to
core. frontier maps to deep_frontier. The pipeline reads the corridor
from COALESCE(cultural_corridor, geographic_sector, 'core') in
star_systems — geographic_sector is the live field; cultural_corridor
was a legacy column never populated beyond the sol-gateway-axis.
Sub-style rotation
All bodies within a system use the same register (chosen by select_register()
or palette_for()). Across systems within a corridor the hash-based fallback
rotates through the sub-style list: hash(system_id) % len(substyles). This
ensures neighbouring systems don't all sound identical even when select_register()
fails.
Inflection as dominant bias, not hard lock
The CORRIDOR_SUBSTYLES comment is explicit: the inflection is a dominant bias,
not a hard cultural lock. A British surveyor on an east_reach moon still names
a river after their aunt in Dorset. The few-shot examples in the batch prompt
show cross-cultural names to reinforce this — the examples are Scottish Highland
and Dutch colonial regardless of the target corridor.
SECTOR_PRIORITY
SECTOR_PRIORITY sets the processing order: core=0, north_reach=1,
south_reach=2, east_reach=3, west_reach=4, deep_frontier=5. Core
bodies are named first so they win the dedup race; frontier bodies fall into
the adjacent-register refill path when names collide.
5. Body Processing Order
discover_bodies() discovers all markers.json files via
WIKI_SYSTEMS.glob("*/bodies/*/markers.json") and sorts them by a 5-tuple
sort key loaded from load_body_hop_order():
(hop_distance, system_id, is_inhabited_desc, population_rank, body_id)
- Hop distance from Gateway (GJ 71), ascending. Core systems process first.
- System grouping: all bodies in a system are processed consecutively
(same
system_idin the sort key). - Inhabited first: within a system, inhabited bodies sort before uninhabited ones.
- Population rank: within inhabited bodies, higher-population bodies sort earlier (so the main inhabited world of a system wins the dedup race against minor colonies).
- Bodies whose
body_idis absent fromhop_order(markers.json orphans not in the DB) sort to the end with a sentinel hop of 99.
6. Key Files and Their Roles
| File | Role |
|---|---|
tooling/planet-gen/gemma_naming.py |
Main pipeline: VoiceSubprocess, CORRIDOR_SUBSTYLES, palette_for(), select_register(), _build_prompt() (single-name path), _PROMPT_CONFIG, process_body(), main(). |
tooling/planet-gen/naming_core.py |
Shared algorithms: levenshtein(), word_avg_distance(), select_distinct(), build_batch_prompt(), parse_batch_response(), name_features_batch(), mood_for_body(), MOOD_POOL, is_valid_name(). |
tooling/planet-gen/run-atlas-naming.sh |
Shell wrapper for overnight runs. Validates the binary (gfx1201 kernel check), model, and distrobox container before exec-ing into gemma_naming.py. Hardcodes paths — no arguments accepted. |
tooling/planet-gen/qa_naming.py |
QA report: reads systems.db and markers.json files, runs the full check suite, prints a summary with issue rates. |
tooling/planet-gen/test_batch_naming.py |
Integration test harness. Runs 4 simulated systems (20 bodies) against real Gemma 4 via sr-voice-tooling, accumulates the taken list across bodies, prints candidates/selected/refill for visual inspection. |
tooling/planet-gen/test_register_selection.py |
Tests select_register() against 8 real systems spanning all corridors, prints what Gemma picks vs. the hash-based fallback. |
tooling/planet-gen/earth_blocklist.txt |
Lowercase exact-match list of Earth major place names. is_blocked() also strips a leading "The " before comparing. |
~/Projects/settled-reach/binaries/sr-voice-tooling |
The Gemma 4 inference binary. Lives outside the repo. Built with CMAKE_HIP_ARCHITECTURES=gfx1201 inside the reach-build distrobox. |
~/Projects/settled-reach/models/gemma-4.gguf |
The model weights. Shared across worktrees. |
server/data/systems.db |
SQLite database. star_systems.geographic_sector is the corridor source of truth. atlas_* tables are synced per body immediately after markers.json is written. |
generate_atlas.py provides ensure_atlas_schema() and sync_markers_to_db(),
both imported by gemma_naming.py. This makes generate_atlas.py the single
authoritative path for atlas_* row writes.
7. Known Limitations
Classical register sameyness
The "Classical references, institutional, civic" sub-style in the core
corridor (Concordia, Aurelius, Prefecture, Forum) has a narrow vocabulary.
Gemma 4 has roughly 15 usable stems per register. On a system with many
bodies, classical register bodies at the tail of the run exhaust the fresh
stem pool and the adjacent-register refill kicks in. The refill names are
structurally correct but may not feel classical. This is by design: the
frontier fallback is correct behaviour, not a failure mode.
Few-shot example bleed
The two fixed batch few-shot examples (Scottish Highland, Dutch colonial) are
cross-corridor by design, but on short prompts (low ctx_size) they can
dominate the model's completion distribution. The result is names that have
a vaguely British or Dutch character even for east_reach or south_reach
bodies. Increasing ctx_size reduces this; at 1024 tokens it is detectable
but not severe.
Narrow register exhaustion on large systems
The taken list is trimmed from the tail when it exceeds the token budget.
For systems with 30+ bodies (e.g. very large multi-moon systems), early
names may drop off the taken list before all bodies are processed, allowing
duplicates to slip through. qa_naming.py's check_exact_dupes_within_system()
catches these; manual re-runs with --body are the fix.
Stem repetition ("Ridge Ridge Ridge")
check_stem_repetition() detects this: 4 or more features on the same body
sharing the same first word. It is caused by the model having a strong
associative path from the inflection to one compound-word opener (e.g.
"Ridge" for frontier descriptive). The Levenshtein ranking partially mitigates
it — two names with identical first words have a low word_avg_distance and
the second one loses the greedy selection — but it does not eliminate it when
the entire candidate pool shares the stem.
Single-name vs. batch path
gemma_naming.py contains two prompt-building paths: the legacy
_build_prompt() / name_feature() single-name path and the newer
build_batch_prompt() / name_features_batch() batch path. The batch path
is what process_body() calls via _batch_fill(). The single-name path
and its _PROMPT_CONFIG pools are still present and used by _build_prompt(),
which is called by name_feature() — that function may be invoked on retry
paths or by older call sites. The two paths use different prompt structures
and produce different output distributions.
8. QA Process
Running
python3 tooling/planet-gen/qa_naming.py
python3 tooling/planet-gen/qa_naming.py --verbose
Requires server/data/systems.db to be present with populated atlas_* tables.
What the checks look for
| Check | Function | Trigger |
|---|---|---|
| Prompt fragment leaks | check_prompt_fragments() |
Name contains substrings like "style:", "generate", "already used", "must be", etc. |
| Exact dupes within system | check_exact_dupes_within_system() |
Same name, same feature type, two different bodies in the same system. |
| Exact dupes within body | check_exact_dupes_within_body() |
Same name, different feature types, same body (e.g. a river and a mountain range both called "Thornbury"). |
| Stem repetition | check_stem_repetition() |
4 or more features of the same type on a body share the same first word. |
| Body name echo | check_body_name_echo() |
A word from the body's proper name appears as a word in a feature name. |
| Register bleed | check_register_bleed() |
Keyword heuristics flag obviously wrong cultural associations (e.g. "fjord" in south_reach, "sakura" in west_reach). |
| Feature type mismatch | check_feature_type_mismatch() |
Street-vocabulary words on a mountain; building-vocabulary words on a river or ocean. |
| Short names | check_short_long_names() |
Name is 3 characters or fewer. |
| Long names | check_short_long_names() |
Name is more than 40 characters. |
| Numbers in names | check_numbers_in_names() |
Name contains a digit. |
| Coverage gaps | coverage_gaps() |
Bodies with one or more unnamed features remaining in atlas_* tables. Inhabited bodies listed separately. |
The summary block at the end prints a total issue rate (issues / total_named * 100). A clean run at the end of a full batch should sit below 1%. Prompt
fragment leaks above zero indicate a context-budget or parsing failure and
should be investigated before ship.
9. How to Extend
Adding a new cultural register
- Add a new dict entry to the relevant list in
CORRIDOR_SUBSTYLES:{"inflection": "...", "examples": "..."} - The pipeline picks it up automatically.
palette_for()useslen(substyles)for the modulo, so the new sub-style becomes available on the next hash rotation.select_register()presents it as a numbered option to Gemma. - Run
test_register_selection.pyto verify Gemma picks the new register for a system whose wiki content matches it.
Tuning temperature and sampling
Temperature is a parameter of the sr-voice-tooling binary, not of the
Python scripts. Pass flags to the binary via the --sr-voice argument or
edit run-atlas-naming.sh. The scripts have no Python-side temperature knob.
Adding new moods
Extend MOOD_POOL in naming_core.py. The deterministic hash spreads the
new mood across bodies automatically on the next full run. Existing bodies
whose markers.json already has names will be skipped, so only un-named
bodies pick up the new mood entries.
Running for a single system or body
Single body:
python3 tooling/planet-gen/gemma_naming.py --body GJ380c
Single body with verbose output showing per-feature retry detail:
python3 tooling/planet-gen/gemma_naming.py --body GJ380c --verbose
Smoke test (first 5 bodies in hop order):
python3 tooling/planet-gen/gemma_naming.py --limit 5 --verbose
Without a real model (mock stdio for pipeline testing):
python3 tooling/planet-gen/gemma_naming.py --mock --limit 5
Dump prompts to JSONL without calling the model (for A/B comparison against a different backend):
python3 tooling/planet-gen/gemma_naming.py \
--dump-prompts /tmp/prompts.jsonl \
--limit 10
All invocations use --refresh 1000 by default (subprocess restart every
1000 requests). Lower it to --refresh 100 if you observe quality degradation
across a long single-system run.