fix(atlas): suffix monotony auto-fix + cultural-history prompting (#886)

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>
This commit is contained in:
2026-05-02 18:40:21 +02:00
co-authored by Claude Opus 4.6
parent 91066e4be6
commit d57f0566d2
2 changed files with 92 additions and 10 deletions
+75 -8
View File
@@ -1842,6 +1842,19 @@ def process_body(
substyles = CORRIDOR_SUBSTYLES.get(corridor, DEFAULT_SUBSTYLES)
mood = mood_for_body(body_id, world_seed)
# Build cultural-history context for the prompt (#886 §6):
# Collect the inflection descriptions of all *secondary* registers in
# this corridor so the model sees the full settlement layering — e.g.
# "Scottish Highland" as primary, but also the Irish and Australian
# substyles that represent earlier or interleaved waves of settlers.
# Limited to 3 secondary styles to keep the prompt concise.
_secondary_inflections = [
s["inflection"] for s in substyles if s["inflection"] != inflection
][:3]
cultural_history: str | None = (
"; ".join(_secondary_inflections) if _secondary_inflections else None
)
# Helper: batch-name blank features in a marker section
def _batch_fill(
section_key: str,
@@ -1895,6 +1908,7 @@ def process_body(
mood=mood,
body_id=body_id,
world_seed=world_seed,
cultural_history=cultural_history,
ctx_size=voice.ctx_size,
)
@@ -1926,11 +1940,11 @@ def process_body(
_batch_fill("mountain_ranges", lambda f: "mountain_range", "mountain_ranges")
_batch_fill("pois", _feature_type_for_poi, "pois")
# Mountain suffix monotony check (#853 §3):
# Mountain suffix monotony check + auto-fix (#853 §3, #886 §3):
# If >40% of mountain names on a single body share a trailing word,
# flag it. We don't re-query in the batch pipeline (no voice access here)
# but record a warning so the batch runner can surface bodies that need
# a targeted re-run.
# re-query with the offending names added to `taken` so the model is
# forced to diversify. One retry per body; if the retry still clusters
# (rare), record a warning for post-run inspection.
mountain_names = [
f.get("name", "") for f in (markers.get("mountain_ranges") or [])
if f.get("name")
@@ -1944,11 +1958,64 @@ def process_body(
dominant = max(suffix_counts, key=lambda k: suffix_counts[k])
dominant_frac = suffix_counts[dominant] / len(mountain_names)
if dominant_frac > 0.40:
counts["suffix_monotony_warning"] = (
f"mountain suffix '{dominant}' on "
f"{suffix_counts[dominant]}/{len(mountain_names)} "
f"features ({dominant_frac:.0%}) — re-run targeting this body"
# Targeted retry: identify features with the dominant suffix,
# re-request names for them with the monotonous names as `taken`.
offending_features = [
f for f in (markers.get("mountain_ranges") or [])
if f.get("name") and f["name"].split()[-1].lower() == dominant
]
retry_taken = (
list(body_used)
+ list(corpus.get((corridor, "mountain_range"), set()))
)
retry_names = name_features_batch(
voice=voice,
feature_type="mountain_range",
count=len(offending_features),
inflection=inflection,
corridor=corridor,
corridor_substyles=substyles,
taken=retry_taken,
prompt_config=_PROMPT_CONFIG,
system_name=ctx.get("system_proper_name"),
body_name=ctx.get("body_proper_name"),
system_hook=system_hook,
mood=mood,
body_id=body_id,
world_seed=world_seed + 1, # bump seed to force different output
cultural_history=cultural_history,
ctx_size=voice.ctx_size,
)
for i, feat in enumerate(offending_features):
if i < len(retry_names):
old_name = feat["name"]
feat["name"] = retry_names[i]
body_used.discard(old_name)
body_used.add(retry_names[i])
corpus.setdefault((corridor, "mountain_range"), set()).discard(old_name)
corpus.setdefault((corridor, "mountain_range"), set()).add(retry_names[i])
changed = True
# Re-check after retry; record warning if still clustered
mountain_names_after = [
f.get("name", "") for f in (markers.get("mountain_ranges") or [])
if f.get("name")
]
suffix_counts_after: dict[str, int] = {}
for mn in mountain_names_after:
words = mn.split()
if words:
suffix_counts_after[words[-1].lower()] = (
suffix_counts_after.get(words[-1].lower(), 0) + 1
)
if suffix_counts_after:
dominant_after = max(suffix_counts_after, key=lambda k: suffix_counts_after[k])
dominant_frac_after = suffix_counts_after[dominant_after] / len(mountain_names_after)
if dominant_frac_after > 0.40:
counts["suffix_monotony_warning"] = (
f"mountain suffix '{dominant_after}' still on "
f"{suffix_counts_after[dominant_after]}/{len(mountain_names_after)} "
f"features ({dominant_frac_after:.0%}) after retry"
)
# Infrastructure naming (#853 §7):
# Assign deterministic city-pair names to unnamed roads and railroads.
+17 -2
View File
@@ -230,6 +230,7 @@ def build_batch_prompt(
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.
@@ -238,6 +239,10 @@ def build_batch_prompt(
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).
"""
@@ -274,9 +279,11 @@ def build_batch_prompt(
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 taken:
if system_name or body_name or system_hook or cultural_history or taken:
lines.append("")
# Few-shot examples showing batch format
@@ -292,7 +299,7 @@ def build_batch_prompt(
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 the taken list to fit
# Trim taken list first (preserving cultural_history context)
while taken and budget < 0:
taken = taken[:-1]
lines_rebuild = [preamble, ""]
@@ -305,6 +312,8 @@ def build_batch_prompt(
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("")
@@ -336,6 +345,7 @@ def name_features_batch(
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.
@@ -346,6 +356,9 @@ def name_features_batch(
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,
@@ -357,6 +370,7 @@ def name_features_batch(
body_name=body_name,
system_hook=system_hook,
mood=mood,
cultural_history=cultural_history,
ctx_size=ctx_size,
)
@@ -396,6 +410,7 @@ def name_features_batch(
body_name=body_name,
system_hook=system_hook,
mood=mood,
cultural_history=cultural_history,
ctx_size=ctx_size,
)