gemma_naming.py now re-queries affected bodies when >40% suffix clustering is detected. naming_core.py build_batch_prompt accepts cultural_history param threading secondary corridor substyles into the few-shot prompt for richer cross-cultural name blending. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
431 lines
16 KiB
Python
431 lines
16 KiB
Python
"""naming_core.py — Shared algorithms for the atlas naming pipeline.
|
|
|
|
Contains: Levenshtein distance, distinctiveness ranking, batch prompt
|
|
building, mood pool, name validation, and response parsing. Used by
|
|
both gemma_naming.py (production pipeline) and test scripts.
|
|
|
|
Version history:
|
|
0.1 2026-04-16 Initial extraction from test_batch_naming.py
|
|
- levenshtein, word_avg_distance, select_distinct
|
|
- build_batch_prompt with mood injection
|
|
- parse_batch_response with dedup
|
|
- name_features_batch with adjacent-register refill
|
|
- is_valid_name prompt-fragment filter
|
|
0.2 2026-04-17 Post-QA hardening
|
|
- few-shot example blocklist (prevents prompt bleed)
|
|
- minimum name length raised to 3 chars
|
|
- bracket/number rejection in is_valid_name
|
|
- parse_batch_response filters few-shot examples
|
|
0.3 2026-04-21 Generator-patch follow-up (#853)
|
|
- compass-direction negative example in build_batch_prompt
|
|
- river flow/current filter in is_valid_name(feature_type)
|
|
- parse_batch_response / name_features_batch pass feature_type
|
|
"""
|
|
|
|
__version__ = "0.3"
|
|
|
|
import hashlib
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mood pool — randomized per-body emotional seed for vocabulary divergence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MOOD_POOL = [
|
|
"ambition", "family", "wealth", "community", "industry",
|
|
"pride", "fleeting", "hope", "fear", "isolation",
|
|
"devotion", "defiance", "loss",
|
|
]
|
|
|
|
|
|
def mood_for_body(body_id: str, world_seed: int = 42) -> str:
|
|
"""Deterministic mood selection per body."""
|
|
h = int(hashlib.sha256(f"mood|{body_id}|{world_seed}".encode()).hexdigest()[:8], 16)
|
|
return MOOD_POOL[h % len(MOOD_POOL)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Levenshtein distance and distinctiveness ranking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def levenshtein(a: str, b: str) -> int:
|
|
"""Standard Levenshtein edit distance."""
|
|
if len(a) < len(b):
|
|
return levenshtein(b, a)
|
|
if not b:
|
|
return len(a)
|
|
prev = list(range(len(b) + 1))
|
|
for i, ca in enumerate(a):
|
|
curr = [i + 1]
|
|
for j, cb in enumerate(b):
|
|
cost = 0 if ca == cb else 1
|
|
curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost))
|
|
prev = curr
|
|
return prev[-1]
|
|
|
|
|
|
def _words(name: str) -> list[str]:
|
|
"""Split a name into lowercase words for per-word comparison."""
|
|
return [w for w in name.lower().split() if w]
|
|
|
|
|
|
def word_avg_distance(a: str, b: str) -> float:
|
|
"""Average Levenshtein distance across word pairs.
|
|
|
|
Compares each word in the shorter name against the closest word in
|
|
the longer name, then averages. Shared structural words (Serra,
|
|
The, Mount) lower the score but don't block — the unique words
|
|
pull the average up.
|
|
|
|
Returns 0.0 for identical, higher = more distinct.
|
|
"""
|
|
wa, wb = _words(a), _words(b)
|
|
if not wa or not wb:
|
|
return float(levenshtein(a.lower(), b.lower()))
|
|
|
|
shorter, longer = (wa, wb) if len(wa) <= len(wb) else (wb, wa)
|
|
total = 0.0
|
|
for sw in shorter:
|
|
best = min(levenshtein(sw, lw) for lw in longer)
|
|
total += best
|
|
return total / len(shorter)
|
|
|
|
|
|
def min_avg_distance_to_set(name: str, existing: list[str]) -> float:
|
|
"""Minimum word-average distance from name to any name in the set."""
|
|
if not existing:
|
|
return 999.0
|
|
return min(word_avg_distance(name, e) for e in existing)
|
|
|
|
|
|
def select_distinct(
|
|
candidates: list[str],
|
|
count: int,
|
|
taken: list[str],
|
|
) -> list[str]:
|
|
"""Greedily select the N most distinct names from candidates.
|
|
|
|
No hard rejection — all candidates are eligible except exact
|
|
matches to taken names. Ranked by distinctiveness (word-average
|
|
Levenshtein distance) against both taken names and previously
|
|
selected names. The most distinct candidate is picked first, then
|
|
the next most distinct relative to the growing set, until the
|
|
quota is filled.
|
|
|
|
Shared words (Serra, The, Forum) lower a candidate's preference
|
|
but never block it. The greedy approach naturally spaces out
|
|
selections.
|
|
"""
|
|
# Hard-filter exact matches to taken (case-insensitive)
|
|
taken_lower = {t.lower() for t in taken}
|
|
pool = [c for c in candidates if c.lower() not in taken_lower]
|
|
|
|
selected: list[str] = []
|
|
reference: list[str] = list(taken)
|
|
|
|
for _ in range(min(count, len(pool))):
|
|
if not pool:
|
|
break
|
|
|
|
best_idx = 0
|
|
best_score = -1.0
|
|
for i, name in enumerate(pool):
|
|
score = min_avg_distance_to_set(name, reference) if reference else 999.0
|
|
if score > best_score:
|
|
best_score = score
|
|
best_idx = i
|
|
|
|
chosen = pool.pop(best_idx)
|
|
selected.append(chosen)
|
|
reference.append(chosen)
|
|
|
|
return selected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Name validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Few-shot examples used in batch prompts. These must be blocked from
|
|
# appearing as output — Gemma pattern-completes them verbatim, and
|
|
# without this blocklist they appear 50-100x across the reach.
|
|
FEWSHOT_BLOCKLIST = {
|
|
"glen moray", "dunvegan ridge", "torridon", "cairn brae",
|
|
"the kelpie's spine", "kloosterbeek", "nieuw rijn",
|
|
"hoogland run", "van diemen's creek",
|
|
}
|
|
|
|
|
|
def is_valid_name(name: str, feature_type: str = "") -> bool:
|
|
"""Filter out garbage: too short, too long, contains periods/brackets,
|
|
looks like a prompt fragment, matches a few-shot example, or contains
|
|
digits.
|
|
|
|
Pass feature_type="river" to also reject navigational vocabulary
|
|
(Flow, Current) that bleeds from ocean naming into river names (#853).
|
|
"""
|
|
if not name or len(name) < 3 or len(name) > 50:
|
|
return False
|
|
# Brackets, periods, digits — structural garbage
|
|
if "." in name or "(" in name or ")" in name or "[" in name or "]" in name:
|
|
return False
|
|
if any(c.isdigit() for c in name):
|
|
return False
|
|
low = name.lower()
|
|
# Prompt fragment echoes
|
|
reject_phrases = [
|
|
"names", "style:", "answer:", "must be", "avoid", "distinct",
|
|
"already used", "do not", "need", "generate", "list",
|
|
"number only", "comma-separated", "best:",
|
|
]
|
|
if any(phrase in low for phrase in reject_phrases):
|
|
return False
|
|
# Few-shot example bleed
|
|
if low in FEWSHOT_BLOCKLIST:
|
|
return False
|
|
# River-specific: reject navigational/oceanic vocabulary (#853 §6)
|
|
# "X Flow", "X Current" read oddly for rivers — these are ocean terms.
|
|
if feature_type == "river":
|
|
words = low.split()
|
|
if words and words[-1] in ("flow", "current"):
|
|
return False
|
|
return True
|
|
|
|
|
|
def parse_batch_response(raw: str, feature_type: str = "") -> list[str]:
|
|
"""Parse a batch naming response into a list of clean, unique name strings.
|
|
|
|
Takes the first line only (model often continues with explanations
|
|
or more styles), splits on commas, strips quotes/whitespace,
|
|
filters invalid names, and deduplicates (preserving order).
|
|
|
|
Pass feature_type to enable feature-specific filtering (e.g. river
|
|
flow/current rejection via is_valid_name).
|
|
"""
|
|
first_line = raw.strip().split("\n")[0] if raw.strip() else ""
|
|
candidates = [
|
|
n.strip().strip('"').strip("'").strip()
|
|
for n in first_line.split(",")
|
|
]
|
|
# Deduplicate preserving order (model often repeats names in batch)
|
|
seen: set[str] = set()
|
|
unique: list[str] = []
|
|
for n in candidates:
|
|
if is_valid_name(n, feature_type) and n.lower() not in seen:
|
|
unique.append(n)
|
|
seen.add(n.lower())
|
|
return unique
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Batch prompt building
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_batch_prompt(
|
|
feature_type: str,
|
|
inflection: str,
|
|
count: int,
|
|
taken: list[str],
|
|
prompt_config: dict,
|
|
system_name: str | None = None,
|
|
body_name: str | None = None,
|
|
system_hook: str | None = None,
|
|
mood: str | None = None,
|
|
cultural_history: str | None = None,
|
|
ctx_size: int = 1024,
|
|
) -> str:
|
|
"""Build a batch naming prompt asking for N names in one call.
|
|
|
|
Uses the same preamble structure as the single-name prompts but
|
|
with few-shot examples showing comma-separated lists. The model
|
|
pattern-completes the list.
|
|
|
|
`cultural_history` threads secondary cultural registers into the
|
|
prompt so names reflect the layered settlement history of a corridor
|
|
rather than only the primary inflection style (#886 §6).
|
|
|
|
The prompt is truncated to fit within ctx_size tokens (rough
|
|
estimate: 1 token ≈ 4 chars).
|
|
"""
|
|
cfg = prompt_config.get(feature_type)
|
|
if not cfg:
|
|
return ""
|
|
|
|
subject = cfg["subject"]
|
|
verb = "called" if subject.startswith("their ") else "named"
|
|
|
|
mood_clause = ""
|
|
if mood:
|
|
mood_clause = f" The settlers here had a sense of {mood}. "
|
|
|
|
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 landform, a family name, a practical "
|
|
f"description.{mood_clause}Avoid the obvious choice. Each name must "
|
|
f"be distinct — no two names may share a root word. "
|
|
f"Do NOT name features after compass directions "
|
|
f"(Eastern Range, Northern Heights, Western Pass — "
|
|
f"settlers name places after people and events, not bearings).\n"
|
|
f"Reply with ONLY a comma-separated list, no numbering, no markdown."
|
|
)
|
|
|
|
lines = [preamble, ""]
|
|
if system_name or body_name:
|
|
ident = []
|
|
if system_name:
|
|
ident.append(f"System: {system_name}")
|
|
if body_name:
|
|
ident.append(f"Planet: {body_name}")
|
|
lines.append(". ".join(ident) + ".")
|
|
if system_hook:
|
|
lines.append(f"About the system: {system_hook}")
|
|
if cultural_history:
|
|
lines.append(f"Settlement history: {cultural_history}")
|
|
if taken:
|
|
lines.append(f"Already used (do NOT repeat): {', '.join(taken)}")
|
|
if system_name or body_name or system_hook or cultural_history or taken:
|
|
lines.append("")
|
|
|
|
# Few-shot examples showing batch format
|
|
lines.append("Style: Scottish Highland. 5 names: Glen Moray, Dunvegan Ridge, Torridon, Cairn Brae, The Kelpie's Spine")
|
|
lines.append("Style: Dutch colonial. 4 names: Kloosterbeek, Nieuw Rijn, Hoogland Run, Van Diemen's Creek")
|
|
lines.append("")
|
|
|
|
ask_for = count * 2 # oversample, then rank by distinctiveness
|
|
tail = f"Style: {inflection}. {ask_for} names:"
|
|
|
|
# Truncate context to fit within ctx_size
|
|
fixed = "\n".join(lines)
|
|
max_chars = (ctx_size - 16) * 4 # 16 tokens headroom for output
|
|
budget = max_chars - len(fixed) - len(tail) - 2 # 2 for newlines
|
|
if budget < 0:
|
|
# Trim taken list first (preserving cultural_history context)
|
|
while taken and budget < 0:
|
|
taken = taken[:-1]
|
|
lines_rebuild = [preamble, ""]
|
|
if system_name or body_name:
|
|
ident = []
|
|
if system_name:
|
|
ident.append(f"System: {system_name}")
|
|
if body_name:
|
|
ident.append(f"Planet: {body_name}")
|
|
lines_rebuild.append(". ".join(ident) + ".")
|
|
if system_hook:
|
|
lines_rebuild.append(f"About the system: {system_hook}")
|
|
if cultural_history:
|
|
lines_rebuild.append(f"Settlement history: {cultural_history}")
|
|
if taken:
|
|
lines_rebuild.append(f"Already used (do NOT repeat): {', '.join(taken)}")
|
|
lines_rebuild.append("")
|
|
lines_rebuild.append("Style: Scottish Highland. 5 names: Glen Moray, Dunvegan Ridge, Torridon, Cairn Brae, The Kelpie's Spine")
|
|
lines_rebuild.append("Style: Dutch colonial. 4 names: Kloosterbeek, Nieuw Rijn, Hoogland Run, Van Diemen's Creek")
|
|
lines_rebuild.append("")
|
|
fixed = "\n".join(lines_rebuild)
|
|
budget = max_chars - len(fixed) - len(tail) - 2
|
|
|
|
return fixed + "\n" + tail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Batch naming with refill
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def name_features_batch(
|
|
voice, # VoiceSubprocess — not typed to avoid circular import
|
|
feature_type: str,
|
|
count: int,
|
|
inflection: str,
|
|
corridor: str,
|
|
corridor_substyles: list[dict[str, str]],
|
|
taken: list[str],
|
|
prompt_config: dict,
|
|
system_name: str | None,
|
|
body_name: str | None,
|
|
system_hook: str | None,
|
|
mood: str | None,
|
|
body_id: str,
|
|
world_seed: int,
|
|
cultural_history: str | None = None,
|
|
ctx_size: int = 1024,
|
|
) -> list[str]:
|
|
"""Generate `count` names for a feature type using batch prompting.
|
|
|
|
Flow:
|
|
1. Build a batch prompt asking for count*2 names.
|
|
2. Send to voice, parse response.
|
|
3. Rank by distinctiveness via Levenshtein, pick top `count`.
|
|
4. If short, refill from the next adjacent register in the corridor.
|
|
5. Return the final list of names.
|
|
|
|
`cultural_history` is forwarded to build_batch_prompt to enrich the
|
|
prompt with secondary cultural context (#886 §6).
|
|
"""
|
|
prompt = build_batch_prompt(
|
|
feature_type=feature_type,
|
|
inflection=inflection,
|
|
count=count,
|
|
taken=taken,
|
|
prompt_config=prompt_config,
|
|
system_name=system_name,
|
|
body_name=body_name,
|
|
system_hook=system_hook,
|
|
mood=mood,
|
|
cultural_history=cultural_history,
|
|
ctx_size=ctx_size,
|
|
)
|
|
|
|
seed = int(hashlib.sha256(
|
|
f"batch|{body_id}|{feature_type}|{world_seed}".encode()
|
|
).hexdigest()[:8], 16)
|
|
|
|
try:
|
|
raw = voice.request(prompt, seed)
|
|
except RuntimeError:
|
|
raw = ""
|
|
|
|
candidates = parse_batch_response(raw, feature_type)
|
|
selected = select_distinct(candidates, count, taken)
|
|
|
|
# Refill from adjacent register if we didn't fill the quota
|
|
if len(selected) < count and corridor_substyles:
|
|
shortfall = count - len(selected)
|
|
refill_taken = taken + selected
|
|
|
|
# Find the primary register's index and pick the next one
|
|
primary_idx = next(
|
|
(i for i, s in enumerate(corridor_substyles)
|
|
if s["inflection"] == inflection),
|
|
0,
|
|
)
|
|
refill_idx = (primary_idx + 1) % len(corridor_substyles)
|
|
refill_inflection = corridor_substyles[refill_idx]["inflection"]
|
|
|
|
refill_prompt = build_batch_prompt(
|
|
feature_type=feature_type,
|
|
inflection=refill_inflection,
|
|
count=shortfall * 3,
|
|
taken=refill_taken,
|
|
prompt_config=prompt_config,
|
|
system_name=system_name,
|
|
body_name=body_name,
|
|
system_hook=system_hook,
|
|
mood=mood,
|
|
cultural_history=cultural_history,
|
|
ctx_size=ctx_size,
|
|
)
|
|
|
|
refill_seed = int(hashlib.sha256(
|
|
f"refill|{body_id}|{feature_type}|{world_seed}".encode()
|
|
).hexdigest()[:8], 16)
|
|
|
|
try:
|
|
refill_raw = voice.request(refill_prompt, refill_seed)
|
|
except RuntimeError:
|
|
refill_raw = ""
|
|
|
|
refill_candidates = parse_batch_response(refill_raw, feature_type)
|
|
extra = select_distinct(refill_candidates, shortfall, refill_taken)
|
|
selected.extend(extra)
|
|
|
|
return selected
|