refactor(tooling): drop stem cap + scope dedup to per-system (#833)

The stem cap (--stem-cap 20) was rejecting valid names because common
feature-type vocabulary tokens like "ridge", "hill", "range" hit the
cap after ~200 bodies and blocked all subsequent names containing
them. With sub-style rotation already providing variety, the cap was
doing more harm than good. Removed entirely.

Cross-body dedup narrowed from (hop, corridor, feature_type) to
(system_id, feature_type). Two rivers in the same system can't share
a name; two rivers in different systems can. This matches how
settlers actually name things — they don't coordinate with other
star systems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 12:30:13 +02:00
co-authored by Claude Opus 4.6
parent cb178a8c57
commit 05a1366544
+18 -91
View File
@@ -1303,38 +1303,6 @@ def body_population_band(population: int) -> str:
return "megaworld (1B+)"
_STEM_WORD_RE = re.compile(r"[A-Za-z][A-Za-z'\-]{2,}")
# Tokens so generic they should never count toward stem-dominance — they
# just describe the feature type and don't carry cultural identity.
_IGNORED_STEMS = {
"the", "of", "a", "an", "and", "or",
"river", "sea", "lake", "ocean", "bay", "gulf", "range", "peak",
"peaks", "ridge", "spine", "scarp", "heights", "hollow", "run",
"beck", "water", "course", "flow", "basin", "reach", "hold",
"cross", "prime", "city", "town", "capital", "gate", "terminal",
"transit", "exchange", "concourse", "assembly", "archive",
"commons", "grounds", "square", "circle", "mountain", "mountains",
"stream", "brook", "spring", "tarn", "mere", "pool", "deep",
"expanse", "crest", "summit", "fells", "series", "sea", "maris",
"mare", "aquae", "fluvius", "terrae",
}
def _extract_stems(name: str) -> list[str]:
"""Return the lowercase 'interesting' stems of a name — cultural
root tokens only, with 'the', 'of', 'river', 'peaks' etc. dropped.
Used to enforce per-stem dominance caps across the full run so no
single root (e.g. 'Arcturus') can appear in hundreds of names
across 3240 bodies.
"""
return [
t.lower() for t in _STEM_WORD_RE.findall(name)
if t.lower() not in _IGNORED_STEMS
]
def name_feature(
voice: VoiceSubprocess,
feature_type: str,
@@ -1342,8 +1310,6 @@ def name_feature(
blocklist: set[str],
corpus: dict[tuple, set[str]],
body_used: set[str],
stem_counts: dict[str, int],
stem_cap: int,
system_hook: str | None,
system_id: str,
world_seed: int,
@@ -1354,15 +1320,12 @@ def name_feature(
verbose: bool,
max_attempts: int = 3,
) -> str:
"""Request a name from Gemma, enforce blocklist + hop/corridor dedup +
per-body cross-type dedup, fall back to the palette generator on
persistent failure.
"""Request a name from Gemma, enforce blocklist + per-system dedup +
per-body cross-type dedup, skip on persistent failure.
Dedup scopes:
- `corpus[(hop, corridor, feature_type)]` — cross-body dedup within
the same gate-hop distance and corridor. Systems at the same hop
in the same corridor are near neighbors and shouldn't share
feature names. Systems at different hops can.
- `corpus[(system_id, feature_type)]` — no two features of the
same type in the same system share a name.
- `body_used` — per-body set across ALL feature types. Prevents
the same name from appearing as a river AND an ocean AND a
mountain range on the same world.
@@ -1374,7 +1337,7 @@ def name_feature(
palette = palette_for(corridor, system_id)
planet_class = ctx.get("planet_class") or "habitable"
dedup_key = (hop, corridor, feature_type)
dedup_key = (system_id, feature_type)
used = corpus.setdefault(dedup_key, set())
# Capture mode: build the attempt-0 prompt, log it, return a unique
@@ -1417,24 +1380,12 @@ def name_feature(
or lc in (n.lower() for n in body_used)
)
def _exceeds_stem_cap(candidate: str) -> str | None:
"""Return the first stem in `candidate` that would exceed the
cap after this accept, or None if all stems are under the cap."""
if stem_cap <= 0:
return None
for stem in _extract_stems(candidate):
if stem_counts.get(stem, 0) >= stem_cap:
return stem
return None
def _commit_name(final: str) -> None:
used.add(final)
body_used.add(final)
for stem in _extract_stems(final):
stem_counts[stem] = stem_counts.get(stem, 0) + 1
rejections: dict[str, int] = {"empty": 0, "placeholder": 0, "blocklist": 0,
"dedup": 0, "stem_cap": 0, "error": 0}
"dedup": 0, "error": 0}
for attempt in range(max_attempts):
seed = _seed_for(world_seed, body_id, local_id, attempt)
@@ -1473,11 +1424,6 @@ def name_feature(
rejections["dedup"] += 1
log(f" dedup '{cleaned}' {body_id}/{local_id}")
continue
over_stem = _exceeds_stem_cap(cleaned)
if over_stem is not None:
rejections["stem_cap"] += 1
log(f" stem_cap '{cleaned}' ('{over_stem}'{stem_cap}) {body_id}/{local_id}")
continue
_commit_name(cleaned)
return cleaned
@@ -1602,8 +1548,6 @@ def process_body(
conn: sqlite3.Connection,
blocklist: set[str],
corpus: dict[tuple, set[str]],
stem_counts: dict[str, int],
stem_cap: int,
system_hook: str | None,
world_seed: int,
hop: int,
@@ -1641,22 +1585,17 @@ def process_body(
# Per-body dedup set — no name may appear twice on the same body,
# even across feature types. Seeded with every hand-authored name
# already present so templates (Lendel, Edict, Estrade, …) keep their
# canonical identifiers and new features don't collide with them.
# Hand-authored names also count against the stem cap so those
# anchors take priority over generator output.
body_used: set[str] = set()
for key in ("cities", "rivers", "oceans", "mountain_ranges", "pois"):
for feat in markers.get(key) or []:
name = feat.get("name")
if name and isinstance(name, str) and name.strip():
body_used.add(name.strip())
for stem in _extract_stems(name):
stem_counts[stem] = stem_counts.get(stem, 0) + 1
# Cities
for city in markers.get("cities") or []:
if not _is_blank(city.get("name")):
corpus.setdefault((hop, corridor, _feature_type_for_city(city)), set()).add(
corpus.setdefault((system_id, _feature_type_for_city(city)), set()).add(
city["name"]
)
counts["preserved"] += 1
@@ -1664,7 +1603,7 @@ def process_body(
feature_type = _feature_type_for_city(city)
name = name_feature(
voice, feature_type, ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook, system_id,
system_hook, system_id,
world_seed, body_id, city.get("id") or "city_?",
hop, log, verbose,
)
@@ -1677,12 +1616,12 @@ def process_body(
# Rivers
for river in markers.get("rivers") or []:
if not _is_blank(river.get("name")):
corpus.setdefault((hop, corridor, "river"), set()).add(river["name"])
corpus.setdefault((system_id, "river"), set()).add(river["name"])
counts["preserved"] += 1
continue
name = name_feature(
voice, "river", ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook, system_id,
system_hook, system_id,
world_seed, body_id, river.get("id") or "river_?",
hop, log, verbose,
)
@@ -1695,7 +1634,7 @@ def process_body(
# Oceans / seas / lakes
for water in markers.get("oceans") or []:
if not _is_blank(water.get("name")):
corpus.setdefault((hop, corridor, _feature_type_for_ocean(water)), set()).add(
corpus.setdefault((system_id, _feature_type_for_ocean(water)), set()).add(
water["name"]
)
counts["preserved"] += 1
@@ -1703,7 +1642,7 @@ def process_body(
feature_type = _feature_type_for_ocean(water)
name = name_feature(
voice, feature_type, ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook, system_id,
system_hook, system_id,
world_seed, body_id, water.get("id") or "water_?",
hop, log, verbose,
)
@@ -1716,14 +1655,14 @@ def process_body(
# Mountain ranges
for rng_feat in markers.get("mountain_ranges") or []:
if not _is_blank(rng_feat.get("name")):
corpus.setdefault((hop, corridor, "mountain_range"), set()).add(
corpus.setdefault((system_id, "mountain_range"), set()).add(
rng_feat["name"]
)
counts["preserved"] += 1
continue
name = name_feature(
voice, "mountain_range", ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook, system_id,
system_hook, system_id,
world_seed, body_id, rng_feat.get("id") or "range_?",
hop, log, verbose,
)
@@ -1736,7 +1675,7 @@ def process_body(
# POIs
for poi in markers.get("pois") or []:
if not _is_blank(poi.get("name")):
corpus.setdefault((hop, corridor, _feature_type_for_poi(poi)), set()).add(
corpus.setdefault((system_id, _feature_type_for_poi(poi)), set()).add(
poi["name"]
)
counts["preserved"] += 1
@@ -1744,7 +1683,7 @@ def process_body(
feature_type = _feature_type_for_poi(poi)
name = name_feature(
voice, feature_type, ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook, system_id,
system_hook, system_id,
world_seed, body_id, poi.get("id") or "poi_?",
hop, log, verbose,
)
@@ -1862,14 +1801,6 @@ def main():
default=42,
help="World seed for deterministic naming (default: 42)",
)
parser.add_argument(
"--stem-cap",
type=int,
default=20,
help="Max times any single cultural stem (e.g. 'Arcturus', "
"'Meridian') may appear across the full run before dedup "
"starts rejecting it. 0 = disabled. Default: 20.",
)
parser.add_argument(
"--log",
default=str(REPO_ROOT / ".tmp" / "gemma_naming.log"),
@@ -1968,8 +1899,7 @@ def main():
log.raw(f" sr-voice: {MOCK_STDIO if args.mock else sr_voice_bin}")
if not args.mock:
log.raw(f" model: {model_path}")
log.raw(f" seed: {args.seed} refresh: every {args.refresh} requests "
f"stem-cap: {args.stem_cap}")
log.raw(f" seed: {args.seed} refresh: every {args.refresh} requests")
log.raw(f" {len(markers_paths)} markers.json files to process")
log.raw(f" hop {first_hop} → hop {last_hop}, core-first ordering")
log.raw(f" {total_systems} distinct systems")
@@ -1983,8 +1913,7 @@ def main():
)
log.raw("")
corpus: dict[tuple[str, str], set[str]] = {}
stem_counts: dict[str, int] = {}
corpus: dict[tuple, set[str]] = {}
# 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.
@@ -2046,8 +1975,6 @@ def main():
conn=conn,
blocklist=blocklist,
corpus=corpus,
stem_counts=stem_counts,
stem_cap=args.stem_cap,
system_hook=system_gttr_hooks.get(system_id),
world_seed=args.seed,
hop=hop,