diff --git a/tooling/planet-gen/gemma_naming.py b/tooling/planet-gen/gemma_naming.py index 494d59369..d7b8d35cc 100755 --- a/tooling/planet-gen/gemma_naming.py +++ b/tooling/planet-gen/gemma_naming.py @@ -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] = { diff --git a/tooling/planet-gen/naming_core.py b/tooling/planet-gen/naming_core.py index 67a7c1e73..f7837f587 100644 --- a/tooling/planet-gen/naming_core.py +++ b/tooling/planet-gen/naming_core.py @@ -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)