Files
settled-reach/docs/architecture/atlas-naming-pipeline.md
T
jpmschweitzerandClaude Opus 4.6 508d6a7ca9 docs(architecture): atlas naming pipeline reference (#833)
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>
2026-04-17 16:09:57 +02:00

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 optional name fields
  • server/data/systems.dbstar_systems, bodies, atlas_* tables
  • wiki/star-systems/*/index.md and gttr.md — cultural context for register selection
  • tooling/planet-gen/earth_blocklist.txt — exact-match rejection list for well-known Earth place names

Outputs:

  • markers.json files updated in-place with the new name fields
  • atlas_cities, atlas_rivers, atlas_mountain_ranges, atlas_oceans, atlas_pois rows in systems.db synced 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_size tokens)
  • 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_systemsgeographic_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_id in 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_id is absent from hop_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

  1. Add a new dict entry to the relevant list in CORRIDOR_SUBSTYLES:
    {"inflection": "...", "examples": "..."}
    
  2. The pipeline picks it up automatically. palette_for() uses len(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.
  3. Run test_register_selection.py to 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.