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:
@@ -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. "Aldren–Forgehaven Corridor" for core,
|
||||
# "Matamba–Dakar 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] = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user