refactor(assets): atlas naming — dedup, compass filter, river vocab, infra names (#853)

Addresses five of seven subtasks from atlas-generator-refinement-notes:

- Cross-body dedup: corpus keyed by (corridor, feature_type) instead of
  (system_id, feature_type), seeded from existing atlas_* rows so re-runs
  don't collide with already-committed names. §1, §2.
- Empty-name fallback for mountain ranges when Gemma returns fewer names
  than needed ({body_proper} Range {i+1}). §2.
- Suffix monotony detection: flags bodies where >40% of mountain names
  share a trailing word (warning only — batch pipeline has no voice
  access for auto-fix). §3. Follow-up in #886.
- Compass-direction ban: build_batch_prompt explicitly forbids
  "Eastern/Northern/Western X" in few-shot instructions. §4.
- River vocabulary filter: is_valid_name rejects "X Flow" / "X Current"
  when feature_type="river" — these are ocean terms bleeding through. §6.
- Infrastructure naming: deterministic post-pass assigns "{CityA}–{CityB}
  {corridor_suffix}" to unnamed roads and railroads (Corridor/Road/Estrada/
  Strasse/Track by corridor). §7.

Cultural-history prompt threading (§5) remains as existing corridor_substyles
refill mechanism; explicit cultural-history blurb deferred to #886.

naming_core bumped to v0.3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 08:54:37 +02:00
co-authored by Claude Opus 4.6
parent 9f755f2a62
commit 531bb1df5c
2 changed files with 160 additions and 13 deletions
+131 -4
View File
@@ -1855,7 +1855,9 @@ def process_body(
for feat in features:
if not _is_blank(feat.get("name")):
ft = feature_type_fn(feat)
corpus.setdefault((system_id, ft), set()).add(feat["name"])
# Corpus key: (corridor, feature_type) for cross-system dedup
# within the same cultural corridor (#853 §1, §2).
corpus.setdefault((corridor, ft), set()).add(feat["name"])
counts["preserved"] += 1
else:
blank.append(feat)
@@ -1872,8 +1874,9 @@ def process_body(
for ft, feats in by_type.items():
need = len(feats)
# Build taken list from corpus (cross-body dedup)
taken = list(corpus.get((system_id, ft), set()))
# Build taken list from corpus (cross-body corridor-scoped dedup).
# Two bodies in the same corridor never get the same city/mountain name.
taken = list(corpus.get((corridor, ft), set()))
# Also include body_used to avoid cross-type collisions on same body
taken_full = taken + list(body_used)
@@ -1896,14 +1899,26 @@ def process_body(
)
# Assign names to features in order
body_proper = ctx.get("body_proper_name") or body_id
for i, feat in enumerate(feats):
if i < len(names):
feat["name"] = names[i]
counts[count_key] += 1
generated[count_key].append(names[i])
corpus.setdefault((system_id, ft), set()).add(names[i])
corpus.setdefault((corridor, ft), set()).add(names[i])
body_used.add(names[i])
changed = True
elif section_key == "mountain_ranges":
# Empty-name fallback for mountains (#853 §2):
# If Gemma returned fewer names than needed, use a
# deterministic fallback rather than leave the field blank.
fallback = f"{body_proper} Range {i + 1}"
feat["name"] = fallback
counts[count_key] += 1
generated[count_key].append(fallback)
corpus.setdefault((corridor, ft), set()).add(fallback)
body_used.add(fallback)
changed = True
_batch_fill("cities", _feature_type_for_city, "cities")
_batch_fill("rivers", lambda f: "river", "rivers")
@@ -1911,6 +1926,94 @@ 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):
# 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.
mountain_names = [
f.get("name", "") for f in (markers.get("mountain_ranges") or [])
if f.get("name")
]
if len(mountain_names) >= 3:
suffix_counts: dict[str, int] = {}
for mn in mountain_names:
words = mn.split()
if words:
suffix_counts[words[-1].lower()] = suffix_counts.get(words[-1].lower(), 0) + 1
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"
)
# Infrastructure naming (#853 §7):
# Assign deterministic city-pair names to unnamed roads and railroads.
# Convention: "{CityA}{CityB} {corridor_suffix}"
# (e.g. "AldrenForgehaven Corridor" for core,
# "MatambaDakar Estrada" for south_reach)
# This is a deterministic post-pass — no LLM needed.
_ROAD_SUFFIX: dict[str, str] = {
"core": "Corridor", "sol-gateway-axis": "Corridor",
"inner_corridor": "Corridor", "inner_orbit": "Corridor",
"north_reach": "Road", "south_reach": "Estrada",
"east_reach": "Road", "west_reach": "Strasse",
"deep_frontier": "Track", "frontier": "Track",
}
_RAIL_SUFFIX: dict[str, str] = {
"core": "Express", "sol-gateway-axis": "Express",
"inner_corridor": "Express", "inner_orbit": "Express",
"north_reach": "Line", "south_reach": "Linha",
"east_reach": "Line", "west_reach": "Bahn",
"deep_frontier": "Run", "frontier": "Run",
}
road_sfx = _ROAD_SUFFIX.get(corridor, "Road")
rail_sfx = _RAIL_SUFFIX.get(corridor, "Line")
cities_list = markers.get("cities") or []
def _nearest_city_name(path: list, cities: list[dict]) -> str:
"""Return the proper_name of the city nearest to a path endpoint."""
if not cities or not path:
return ""
endpoint = path[0] # first path point
if not isinstance(endpoint, (list, tuple)) or len(endpoint) < 2:
return ""
er, ec = endpoint[0], endpoint[1]
best_name = ""
best_dist = float("inf")
for city in cities:
center = city.get("center")
if not center or len(center) < 2:
continue
cr, cc = center[0], center[1]
dist = abs(er - cr) + abs(ec - cc)
if dist < best_dist and city.get("name"):
best_dist = dist
best_name = city["name"]
return best_name
for section_key, suffix in (("roads", road_sfx), ("railroads", rail_sfx)):
infra_list = markers.get(section_key) or []
for infra in infra_list:
if infra.get("name"):
continue # already named
path = infra.get("path") or []
if len(path) < 2:
continue
city_a = _nearest_city_name(path[:1], cities_list)
city_b = _nearest_city_name(path[-1:], cities_list)
if city_a and city_b and city_a != city_b:
infra["name"] = f"{city_a}{city_b} {suffix}"
elif city_a:
infra["name"] = f"{city_a} {suffix}"
else:
continue
changed = True
if changed:
markers_path.write_text(json.dumps(markers, indent=2) + "\n")
# Refresh atlas_* rows for this body so DB queries pick up the
@@ -2148,6 +2251,30 @@ def main():
corpus: dict[tuple, set[str]] = {}
# Seed corpus from existing atlas_* names so re-runs don't collide with
# names that were already committed to the DB on a previous pass (#853 §1).
# Key: (cultural_corridor, feature_type) — corridor-scoped dedup.
_ATLAS_SEED_QUERIES: list[tuple[str, str]] = [
("atlas_cities", "city"),
("atlas_rivers", "river"),
("atlas_mountain_ranges", "mountain_range"),
("atlas_oceans", "ocean"),
("atlas_pois", "poi_transit"),
]
try:
for table, ft in _ATLAS_SEED_QUERIES:
rows = conn.execute(
f"SELECT c.name, b.cultural_corridor "
f"FROM {table} c "
f"JOIN bodies b ON c.body_id = b.body_id "
f"WHERE c.name IS NOT NULL AND c.name != ''"
).fetchall()
for name, corridor_val in rows:
key = (corridor_val or "core", ft)
corpus.setdefault(key, set()).add(name)
except Exception as e:
log(f" warning: corpus seeding from DB failed ({e}) — cross-run dedup disabled")
# 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] = {
+29 -9
View File
@@ -16,9 +16,13 @@ Version history:
- 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.2"
__version__ = "0.3"
import hashlib
@@ -151,10 +155,14 @@ FEWSHOT_BLOCKLIST = {
}
def is_valid_name(name: str) -> bool:
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."""
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
@@ -174,15 +182,24 @@ def is_valid_name(name: str) -> bool:
# 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) -> list[str]:
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 = [
@@ -193,7 +210,7 @@ def parse_batch_response(raw: str) -> list[str]:
seen: set[str] = set()
unique: list[str] = []
for n in candidates:
if is_valid_name(n) and n.lower() not in seen:
if is_valid_name(n, feature_type) and n.lower() not in seen:
unique.append(n)
seen.add(n.lower())
return unique
@@ -238,9 +255,12 @@ def build_batch_prompt(
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"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.\n"
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."
)
@@ -349,7 +369,7 @@ def name_features_batch(
except RuntimeError:
raw = ""
candidates = parse_batch_response(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
@@ -388,7 +408,7 @@ def name_features_batch(
except RuntimeError:
refill_raw = ""
refill_candidates = parse_batch_response(refill_raw)
refill_candidates = parse_batch_response(refill_raw, feature_type)
extra = select_distinct(refill_candidates, shortfall, refill_taken)
selected.extend(extra)