feat(tooling): prune oversized mountain/river counts + richer fallback palette (#833)
Two fixes from observing the first Gemma 2 batch run on Sirius:
1) Prune oversized feature counts. The upstream terrain pipeline emits
every distinct mountain cluster as a separate `mountain_range` and
every flowing path as a separate `river`. At the atlas generator's
512×256 grid this produced bodies with 40-80 named ranges and
10-15 rivers — noise, not information. A single planet with 48
ridges isn't richer, it's unparseable.
`tooling/planet-gen/prune_atlas_features.py` walks every
`markers.json` under `wiki/star-systems/`, ranks each feature type
by a size proxy, and keeps only the top N:
- mountain_ranges: sorted by `area_cells`, top 8 per body
- rivers: sorted by path length, top 6 per body
- oceans / cities / pois: untouched (already small, or
hand-authored by generate_atlas.py)
Sol (GJ-0) is hardcoded-excluded from pruning so the hand-authored
Earth / Mars / moon content stays untouched.
Each pruned body gets its atlas_* rows re-synced via
`sync_markers_to_db` so the DB mirror stays consistent. Bodies
whose wiki folder has no matching row in `bodies` (14 pre-existing
orphans like GJ1156h-1, GJ34Ah-2, …) are pruned in-file but skip
the DB sync to avoid FK violations on atlas_body_grids.
First run results:
bodies scanned: 2394
bodies pruned: 1513
mountain ranges dropped: 11640
rivers dropped: 1382
Safe to re-run — idempotent when a body is already within the caps.
2) Grounded cosmopolitan fallback palette. When Gemma's 3 retries
all fail (dedup, blocklist, stem-cap, placeholder), the code falls
to `_FALLBACK_STEMS[corridor]`. The old table had 10 stems per
corridor, all Latin-institutional (Meridian, Concord, Prefecture,
Cardinal, Lumen, Foro, Tabula, Vox, Axis, Senatus), which produced
the same-y `Axis Spine / Axis Ridge / Axis Heights / Axis Scarp`
clusters the user flagged on Sirius — exactly the old epic-Latin
register the few-shot pools were rewritten to avoid.
Fallbacks now draw from a 30-45 stem grounded cosmopolitan list
per corridor matching the few-shot pool intent:
- core: 45 stems (Ashfield, Bellview, Cedarbrook,
Fairmont, Ironwood, Kirkwood, Linden, Meridian,
Northfield, Riverside, Westbrook, …)
- north_reach: 40 stems (Ashford, Bellfield, Clifford, Drayton,
Elmhurst, Garner, Holmwood, Kelsworth, …)
- west_reach: 35 stems (Altdorf, Bergfjord, Eikhof, Hoogland,
Järvenpää, Kloosterdam, Nieuwpoort, Sørholm,
Svarteberg, Torsfell, Voorhout, Weserhof, Östby, …)
- east_reach: 35 stems (Aomori, Baektu, Chōshi, Fukagawa,
Hanyang, Izumi, Takamine, Yurigawa, …)
- south_reach: 36 stems (Alves, Brandão, Évora, Gomes, Ribeiro,
Serra, Várzea, Hlanganani, Kilimi, …)
- deep_frontier: 30 stems (Okafor, Stenner, Weller, Kellogg,
Stonebrook, Dustgate, Blackwater, …)
Per-feature suffix lists also expanded (e.g. river suffixes now
include Brook, Stream, Flow, Creek on top of the original Run /
Water / Beck / Rill / Course). Net effect: 300-450 unique fallback
combinations per (corridor, feature_type), up from 50, in the same
grounded register the few-shot pools teach.
Also preserves aliases `inner_corridor`, `inner_orbit`, and
`sol-gateway-axis` as legacy-compatible keys pointing at the
administrative-English palette.
Combined effect on the next run:
- ~45% fewer features to name (pruned 13k/52k)
- ~9× more fallback variety per corridor when fallback does trigger
- Same grounding overhaul from the previous commit, now reaching
into the safety-net path
This commit is contained in:
@@ -881,35 +881,120 @@ def post_process(raw: str) -> str:
|
||||
# Fallback palette generator (deterministic, no LLM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Fallback stems — 40+ per corridor drawn from grounded, cosmopolitan
|
||||
# place-name roots. Each corridor now spans multiple cultural registers
|
||||
# so fallback names inherit the same diversity the few-shot pools teach.
|
||||
# The old 10-stem Latin-institutional lists produced very same-y
|
||||
# "Concord Ridge / Axis Spine / Senatus Heights" clusters when dense
|
||||
# core bodies exhausted the LLM's unique output budget.
|
||||
#
|
||||
# Stems are deliberately a mix of real surnames, real place-name
|
||||
# fragments, and administrative English — because settlers named
|
||||
# places that way.
|
||||
_FALLBACK_STEMS: dict[str, list[str]] = {
|
||||
"north_reach": ["Wolcott", "Mildern", "Ashbourne", "Briarfell", "Tarndale",
|
||||
"Kelston", "Threlwood", "Harford", "Rowanmoor", "Pennhowe"],
|
||||
"south_reach": ["Vargas", "Inhaca", "Monteforte", "Ribeiro", "Kilimi",
|
||||
"Serravale", "Cabo", "Ilhabela", "Moçambo", "Ngola"],
|
||||
"east_reach": ["Hanyang", "Takamine", "Seorak", "Ginoza", "Baektu",
|
||||
"Tsukuri", "Naruhan", "Saeyeon", "Morimine", "Taegong"],
|
||||
"west_reach": ["Vanebach", "Kloosterdam", "Bergfjord", "Hellekade",
|
||||
"Straend", "Eikhof", "Viskrans", "Nordhölm", "Lindeborg",
|
||||
"Drachenberg"],
|
||||
"inner_corridor": ["Meridian", "Concord", "Prefecture", "Cardinal",
|
||||
"Lumen", "Foro", "Tabula", "Vox", "Axis", "Senatus"],
|
||||
"inner_orbit": ["Meridian", "Concord", "Prefecture", "Cardinal",
|
||||
"Lumen", "Foro", "Tabula", "Vox", "Axis", "Senatus"],
|
||||
"frontier": ["Okafor", "Stenner", "Weller", "Pruitt", "Hale",
|
||||
"Kettle", "Bowman", "Alder", "Risher", "Vickery"],
|
||||
"north_reach": [
|
||||
# British / Australian / Irish place-name stems
|
||||
"Ashford", "Bellfield", "Briarcroft", "Clifford", "Drayton",
|
||||
"Elmhurst", "Fairfield", "Garner", "Holmwood", "Irvington",
|
||||
"Kelsworth", "Larkfield", "Mildern", "Netherbrook", "Oakdale",
|
||||
"Pendleton", "Redfern", "Standen", "Tarndale", "Upwold",
|
||||
"Waltham", "Wolcott", "Ashbourne", "Briarfell", "Harford",
|
||||
"Kelston", "Pennhowe", "Rowanmoor", "Threlwood", "Carberry",
|
||||
"Glendale", "Hollowmere", "Ingledene", "Kirkstone", "Maybourne",
|
||||
"Newbridge", "Pinegrove", "Ridgemont", "Silverpine", "Tallgrass",
|
||||
],
|
||||
"south_reach": [
|
||||
# Portuguese / Brazilian / Swahili / Cape Verdean stems
|
||||
"Alves", "Brandão", "Cabral", "Dantas", "Évora", "Figueira",
|
||||
"Gomes", "Hlanganani", "Inhambane", "Jabali", "Kilimi", "Lourenço",
|
||||
"Manhica", "Natal", "Oliveira", "Pinheiro", "Quelimane", "Ribeiro",
|
||||
"Serra", "Tavira", "Vargas", "Monteforte", "Serravale", "Cabo",
|
||||
"Ilhabela", "Moçambo", "Ngola", "Porto", "Ribeira", "Costa",
|
||||
"Marinha", "Várzea", "Campo", "Outeiro", "Pedregal", "Telhado",
|
||||
],
|
||||
"east_reach": [
|
||||
# Korean / Japanese / Taiwanese stems
|
||||
"Aomori", "Baektu", "Chōshi", "Daisen", "Etajima", "Fukagawa",
|
||||
"Gogen", "Hanyang", "Izumi", "Junan", "Kita", "Mori", "Nagare",
|
||||
"Okami", "Saeyeon", "Takamine", "Unari", "Yurigawa", "Ginoza",
|
||||
"Naruhan", "Morimine", "Taegong", "Tsukuri", "Seorak", "Shiroyama",
|
||||
"Kōzan", "Hirano", "Nakayama", "Midori", "Kawasaki", "Tsukushi",
|
||||
"Higashi", "Nishimura", "Miyazaki", "Tachibana",
|
||||
],
|
||||
"west_reach": [
|
||||
# German / Dutch / Nordic / Polish / Czech stems
|
||||
"Altdorf", "Bergfjord", "Drachenberg", "Eikhof", "Feldberg",
|
||||
"Hellekade", "Hoogland", "Kloosterdam", "Lindeborg", "Nordhölm",
|
||||
"Oudewater", "Sørholm", "Straend", "Torsfell", "Vanebach",
|
||||
"Viskrans", "Aalburg", "Blankhof", "Dalhem", "Grunendal",
|
||||
"Halvorsen", "Järvenpää", "Krakenberg", "Lüneborg", "Meinhof",
|
||||
"Nieuwpoort", "Osterdal", "Reinwoud", "Svarteberg", "Tjornholm",
|
||||
"Urlinden", "Voorhout", "Weserhof", "Östby", "Brabanthof",
|
||||
],
|
||||
"core": [
|
||||
# Administrative English / Gateway-era, with a side of classical
|
||||
# for the occasional institutional feel
|
||||
"Ashfield", "Bellview", "Cedarbrook", "Concord", "Crestline",
|
||||
"Easthill", "Fairmont", "Foxgrove", "Gateway", "Glenbrook",
|
||||
"Haversham", "Holloway", "Ironwood", "Kirkwood", "Lakeside",
|
||||
"Libertyhill", "Linden", "Marchwood", "Meridian", "Midvale",
|
||||
"Newbridge", "Northfield", "Oakdale", "Pinehurst", "Riverside",
|
||||
"Rosemont", "Southgate", "Standen", "Stonewall", "Sunnyvale",
|
||||
"Tallwood", "Union", "Uplands", "Valewood", "Westbrook",
|
||||
"Willowgate", "Windmere", "Claremont", "Fernwood", "Hartley",
|
||||
"Cardinal", "Prefecture", "Senate", "Assembly", "Federal",
|
||||
],
|
||||
# Aliases for legacy geographic_sector names
|
||||
"inner_corridor": [
|
||||
"Ashfield", "Bellview", "Cedarbrook", "Concord", "Crestline",
|
||||
"Easthill", "Fairmont", "Foxgrove", "Gateway", "Glenbrook",
|
||||
"Meridian", "Midvale", "Northfield", "Oakdale", "Pinehurst",
|
||||
"Riverside", "Rosemont", "Southgate", "Uplands", "Westbrook",
|
||||
],
|
||||
"inner_orbit": [
|
||||
"Ashfield", "Bellview", "Cedarbrook", "Concord", "Crestline",
|
||||
"Easthill", "Fairmont", "Foxgrove", "Gateway", "Glenbrook",
|
||||
"Meridian", "Midvale", "Northfield", "Oakdale", "Pinehurst",
|
||||
"Riverside", "Rosemont", "Southgate", "Uplands", "Westbrook",
|
||||
],
|
||||
"sol-gateway-axis": [
|
||||
"Ashfield", "Bellview", "Cedarbrook", "Concord", "Crestline",
|
||||
"Easthill", "Fairmont", "Foxgrove", "Gateway", "Glenbrook",
|
||||
"Meridian", "Midvale", "Northfield", "Oakdale", "Pinehurst",
|
||||
"Riverside", "Rosemont", "Southgate", "Uplands", "Westbrook",
|
||||
],
|
||||
"deep_frontier": [
|
||||
"Okafor", "Stenner", "Weller", "Pruitt", "Hale", "Kettle",
|
||||
"Bowman", "Alder", "Risher", "Vickery", "Morgan", "Zhou",
|
||||
"Novak", "Lindström", "Barbosa", "Farrow", "Tillman", "Kellogg",
|
||||
"Grayson", "Whitby", "Pickering", "Cortland", "Deacon", "Huckle",
|
||||
"Stonebrook", "Clayridge", "Ironvale", "Redrock", "Blackwater",
|
||||
"Dustgate",
|
||||
],
|
||||
"frontier": [
|
||||
"Okafor", "Stenner", "Weller", "Pruitt", "Hale", "Kettle",
|
||||
"Bowman", "Alder", "Risher", "Vickery",
|
||||
],
|
||||
}
|
||||
|
||||
_FALLBACK_SUFFIXES: dict[str, list[str]] = {
|
||||
"river": ["Run", "Water", "Beck", "Rill", "Course"],
|
||||
"ocean": ["Sea", "Expanse", "Deep", "Reach"],
|
||||
"sea": ["Sea", "Gulf", "Basin"],
|
||||
"lake": ["Lake", "Mere", "Tarn", "Pool"],
|
||||
"mountain_range": ["Range", "Ridge", "Spine", "Heights", "Scarp"],
|
||||
"city_capital": ["Hold", "Prime", "Seat", "Court"],
|
||||
"city_secondary": ["Cross", "Reach", "Hollow", "Fields", "Stand"],
|
||||
"poi_transit": ["Gate Terminal", "Transit", "Concourse", "Exchange"],
|
||||
"poi_institutional": ["Archive", "Hall", "Assembly", "Registry"],
|
||||
"poi_cultural": ["Commons", "Grounds", "Circle", "Square"],
|
||||
"river": ["Run", "Water", "Beck", "Rill", "Course", "Brook",
|
||||
"Stream", "Flow", "Creek"],
|
||||
"ocean": ["Sea", "Expanse", "Deep", "Reach", "Main", "Vast"],
|
||||
"sea": ["Sea", "Gulf", "Basin", "Bay", "Firth"],
|
||||
"lake": ["Lake", "Mere", "Tarn", "Pool", "Loch", "Pond"],
|
||||
"mountain_range": ["Range", "Ridge", "Spine", "Heights", "Scarp",
|
||||
"Hills", "Peaks", "Back", "Crest", "Fell"],
|
||||
"city_capital": ["Hold", "Prime", "Seat", "Court", "Landing",
|
||||
"Cross", "Hub"],
|
||||
"city_secondary": ["Cross", "Reach", "Hollow", "Fields", "Stand",
|
||||
"Ferry", "Mill", "End", "Gate"],
|
||||
"poi_transit": ["Gate Terminal", "Transit", "Concourse", "Exchange",
|
||||
"Junction"],
|
||||
"poi_institutional": ["Archive", "Hall", "Assembly", "Registry",
|
||||
"Council", "Office"],
|
||||
"poi_cultural": ["Commons", "Grounds", "Circle", "Square", "Park",
|
||||
"Plaza"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
prune_atlas_features.py — Cap per-body feature counts so markers.json
|
||||
files stay readable at atlas scale.
|
||||
|
||||
Background: the upstream terrain pipeline detects every distinct mountain
|
||||
cluster as a separate `mountain_range` entry, which produces bodies with
|
||||
40-80 ranges at 512×256 grid resolution. Similarly for rivers. At atlas
|
||||
zoom those are noise, not information — a planet doesn't need 48 named
|
||||
ridges for the player to recognise the continent shape.
|
||||
|
||||
This pass ranks each feature type by a size proxy and keeps the top N:
|
||||
- mountain_ranges: sorted by `area_cells` descending, top 8
|
||||
- rivers: sorted by `len(path)` descending, top 6
|
||||
- oceans/seas/lakes: untouched (already small per body)
|
||||
- cities/pois: untouched (generated by generate_atlas.py, not here)
|
||||
|
||||
Excluded: anything under `wiki/star-systems/GJ-0/` (Sol). Sol bodies
|
||||
will be hand-authored and must not be touched by automated pruning.
|
||||
|
||||
Each pruned body gets its atlas_* rows re-synced via sync_markers_to_db
|
||||
so the DB mirror stays consistent with the on-disk JSON.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/prune_atlas_features.py
|
||||
tooling/planet-gen/prune_atlas_features.py --max-mtns 8 --max-rivers 6
|
||||
tooling/planet-gen/prune_atlas_features.py --dry-run
|
||||
tooling/planet-gen/prune_atlas_features.py --body GJ144d
|
||||
|
||||
Safe to re-run — idempotent when a body is already within the caps.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
||||
|
||||
# Path fragments that are never pruned. Sol is hand-authored.
|
||||
EXCLUDED_SYSTEMS = {"GJ-0"}
|
||||
|
||||
sys.path.insert(0, str(TOOLING_DIR))
|
||||
from generate_atlas import ensure_atlas_schema, sync_markers_to_db # noqa: E402
|
||||
|
||||
|
||||
def _system_slug(markers_path: Path) -> str:
|
||||
# wiki/star-systems/GJ-244A/bodies/GJ244Ab/markers.json → GJ-244A
|
||||
return markers_path.parent.parent.parent.name
|
||||
|
||||
|
||||
def _body_id(markers_path: Path) -> str:
|
||||
return markers_path.parent.name
|
||||
|
||||
|
||||
def prune_mountains(markers: dict, cap: int) -> int:
|
||||
mtns = markers.get("mountain_ranges") or []
|
||||
if len(mtns) <= cap:
|
||||
return 0
|
||||
ranked = sorted(
|
||||
mtns,
|
||||
key=lambda m: int(m.get("area_cells") or 0),
|
||||
reverse=True,
|
||||
)
|
||||
markers["mountain_ranges"] = ranked[:cap]
|
||||
return len(mtns) - cap
|
||||
|
||||
|
||||
def prune_rivers(markers: dict, cap: int) -> int:
|
||||
rivers = markers.get("rivers") or []
|
||||
if len(rivers) <= cap:
|
||||
return 0
|
||||
ranked = sorted(
|
||||
rivers,
|
||||
key=lambda r: len(r.get("path") or []),
|
||||
reverse=True,
|
||||
)
|
||||
markers["rivers"] = ranked[:cap]
|
||||
return len(rivers) - cap
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Prune oversized mountain_ranges / rivers in every "
|
||||
"markers.json (except Sol)"
|
||||
)
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
parser.add_argument("--body", help="Process only this body_id")
|
||||
parser.add_argument("--max-mtns", type=int, default=8,
|
||||
help="Max mountain_ranges per body (default: 8)")
|
||||
parser.add_argument("--max-rivers", type=int, default=6,
|
||||
help="Max rivers per body (default: 6)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Report counts without writing")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print every body's before/after counts")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
conn = sqlite3.connect(str(db_path), timeout=30.0)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=15000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_atlas_schema(conn)
|
||||
|
||||
# Preload the set of valid body_ids so we can skip orphan wiki
|
||||
# folders that have no matching row in the bodies table. Otherwise
|
||||
# sync_markers_to_db hits a FK violation on atlas_body_grids insert.
|
||||
valid_body_ids = {
|
||||
r[0] for r in conn.execute("SELECT body_id FROM bodies").fetchall()
|
||||
}
|
||||
|
||||
all_markers = sorted(WIKI_SYSTEMS.glob("*/bodies/*/markers.json"))
|
||||
if args.body:
|
||||
all_markers = [p for p in all_markers if _body_id(p) == args.body]
|
||||
|
||||
total_bodies = 0
|
||||
skipped_excluded = 0
|
||||
touched_bodies = 0
|
||||
mtns_dropped = 0
|
||||
rivers_dropped = 0
|
||||
t0 = time.time()
|
||||
|
||||
print(f"\n prune_atlas_features.py")
|
||||
print(f" DB: {db_path}")
|
||||
print(f" Max mtns: {args.max_mtns}")
|
||||
print(f" Max rivers: {args.max_rivers}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN")
|
||||
print(f" {len(all_markers)} markers.json files to scan")
|
||||
print()
|
||||
|
||||
for markers_path in all_markers:
|
||||
total_bodies += 1
|
||||
slug = _system_slug(markers_path)
|
||||
if slug in EXCLUDED_SYSTEMS:
|
||||
skipped_excluded += 1
|
||||
if args.verbose:
|
||||
print(f" SKIP (excluded system) {markers_path}")
|
||||
continue
|
||||
|
||||
try:
|
||||
markers = json.loads(markers_path.read_text())
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ERROR: invalid JSON in {markers_path}: {e}")
|
||||
continue
|
||||
|
||||
body_id = _body_id(markers_path)
|
||||
dropped_mtns = prune_mountains(markers, args.max_mtns)
|
||||
dropped_rivers = prune_rivers(markers, args.max_rivers)
|
||||
|
||||
if dropped_mtns or dropped_rivers:
|
||||
touched_bodies += 1
|
||||
mtns_dropped += dropped_mtns
|
||||
rivers_dropped += dropped_rivers
|
||||
if args.verbose or dropped_mtns >= 20:
|
||||
print(f" {body_id:14s} {slug:8s} "
|
||||
f"−{dropped_mtns} mtns, −{dropped_rivers} rivers")
|
||||
if not args.dry_run:
|
||||
markers_path.write_text(
|
||||
json.dumps(markers, indent=2) + "\n"
|
||||
)
|
||||
if body_id in valid_body_ids:
|
||||
sync_markers_to_db(conn, body_id, markers)
|
||||
elif args.verbose:
|
||||
print(f" (no DB row for {body_id} — skipping sync)")
|
||||
|
||||
# Periodic log line so the user sees progress on a long run.
|
||||
if total_bodies % 250 == 0:
|
||||
rate = total_bodies / max(time.time() - t0, 1e-6)
|
||||
print(f" scanned {total_bodies}/{len(all_markers)} bodies "
|
||||
f"({rate:.0f}/s) touched {touched_bodies}")
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print()
|
||||
print(f" Done: {elapsed:.1f}s")
|
||||
print(f" bodies scanned: {total_bodies}")
|
||||
print(f" excluded (Sol etc.): {skipped_excluded}")
|
||||
print(f" bodies pruned: {touched_bodies}")
|
||||
print(f" mountain ranges dropped: {mtns_dropped}")
|
||||
print(f" rivers dropped: {rivers_dropped}")
|
||||
if args.dry_run:
|
||||
print(f"\n Dry run — no files written, no DB changes.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user