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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user