feat(tooling): scope cross-body dedup to hop+corridor (#833)

Changed dedup key from (corridor, feature_type) to
(hop, corridor, feature_type). Systems at the same gate-hop distance
in the same corridor are near neighbors and shouldn't share feature
names; systems at different hops can. This prevents corpus exhaustion
where Gemma's narrow range-name distribution ("The Ridge", "Blackwood
Range") collides after ~20 bodies and drives fallback rates toward
100%.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 08:37:28 +02:00
co-authored by Claude Opus 4.6
parent a64077f1ad
commit c767f82fbb
+23 -19
View File
@@ -1310,7 +1310,7 @@ def name_feature(
feature_type: str,
ctx: dict,
blocklist: set[str],
corpus: dict[tuple[str, str], set[str]],
corpus: dict[tuple, set[str]],
body_used: set[str],
stem_counts: dict[str, int],
stem_cap: int,
@@ -1318,22 +1318,23 @@ def name_feature(
world_seed: int,
body_id: str,
local_id: str,
hop: int,
log: "Logger",
verbose: bool,
max_attempts: int = 3,
) -> str:
"""Request a name from Gemma, enforce blocklist + corridor dedup +
"""Request a name from Gemma, enforce blocklist + hop/corridor dedup +
per-body cross-type dedup, fall back to the palette generator on
persistent failure.
Dedup scopes:
- `corpus[(corridor, feature_type)]` — cross-body dedup within the
same corridor and feature type. Two rivers in the north_reach
should not share a name; two rivers on opposite arcs can.
- `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.
- `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, which reads as ridiculous
even when the types differ.
mountain range on the same world.
"""
corridor = ctx.get("cultural_corridor") or "core"
if feature_type not in _PROMPT_CONFIG:
@@ -1342,7 +1343,7 @@ def name_feature(
palette = palette_for(corridor)
planet_class = ctx.get("planet_class") or "habitable"
dedup_key = (corridor, feature_type)
dedup_key = (hop, corridor, feature_type)
used = corpus.setdefault(dedup_key, set())
# Capture mode: build the attempt-0 prompt, log it, return a unique
@@ -1578,11 +1579,12 @@ def process_body(
voice: VoiceSubprocess,
conn: sqlite3.Connection,
blocklist: set[str],
corpus: dict[tuple[str, str], set[str]],
corpus: dict[tuple, set[str]],
stem_counts: dict[str, int],
stem_cap: int,
system_hook: str | None,
world_seed: int,
hop: int,
log: "Logger",
verbose: bool,
) -> dict:
@@ -1632,7 +1634,7 @@ def process_body(
# Cities
for city in markers.get("cities") or []:
if not _is_blank(city.get("name")):
corpus.setdefault((corridor, _feature_type_for_city(city)), set()).add(
corpus.setdefault((hop, corridor, _feature_type_for_city(city)), set()).add(
city["name"]
)
counts["preserved"] += 1
@@ -1642,7 +1644,7 @@ def process_body(
voice, feature_type, ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook,
world_seed, body_id, city.get("id") or "city_?",
log, verbose,
hop, log, verbose,
)
city["name"] = name
counts["cities"] += 1
@@ -1652,14 +1654,14 @@ def process_body(
# Rivers
for river in markers.get("rivers") or []:
if not _is_blank(river.get("name")):
corpus.setdefault((corridor, "river"), set()).add(river["name"])
corpus.setdefault((hop, corridor, "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,
world_seed, body_id, river.get("id") or "river_?",
log, verbose,
hop, log, verbose,
)
river["name"] = name
counts["rivers"] += 1
@@ -1669,7 +1671,7 @@ def process_body(
# Oceans / seas / lakes
for water in markers.get("oceans") or []:
if not _is_blank(water.get("name")):
corpus.setdefault((corridor, _feature_type_for_ocean(water)), set()).add(
corpus.setdefault((hop, corridor, _feature_type_for_ocean(water)), set()).add(
water["name"]
)
counts["preserved"] += 1
@@ -1679,7 +1681,7 @@ def process_body(
voice, feature_type, ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook,
world_seed, body_id, water.get("id") or "water_?",
log, verbose,
hop, log, verbose,
)
water["name"] = name
counts["oceans"] += 1
@@ -1689,7 +1691,7 @@ def process_body(
# Mountain ranges
for rng_feat in markers.get("mountain_ranges") or []:
if not _is_blank(rng_feat.get("name")):
corpus.setdefault((corridor, "mountain_range"), set()).add(
corpus.setdefault((hop, corridor, "mountain_range"), set()).add(
rng_feat["name"]
)
counts["preserved"] += 1
@@ -1698,7 +1700,7 @@ def process_body(
voice, "mountain_range", ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook,
world_seed, body_id, rng_feat.get("id") or "range_?",
log, verbose,
hop, log, verbose,
)
rng_feat["name"] = name
counts["mountain_ranges"] += 1
@@ -1708,7 +1710,7 @@ def process_body(
# POIs
for poi in markers.get("pois") or []:
if not _is_blank(poi.get("name")):
corpus.setdefault((corridor, _feature_type_for_poi(poi)), set()).add(
corpus.setdefault((hop, corridor, _feature_type_for_poi(poi)), set()).add(
poi["name"]
)
counts["preserved"] += 1
@@ -1718,7 +1720,7 @@ def process_body(
voice, feature_type, ctx, blocklist, corpus, body_used,
stem_counts, stem_cap, system_hook,
world_seed, body_id, poi.get("id") or "poi_?",
log, verbose,
hop, log, verbose,
)
poi["name"] = name
counts["pois"] += 1
@@ -2008,6 +2010,7 @@ def main():
seen_systems.add(system_id)
t0 = time.time()
hop = hop_order.get(body_id, (99, ""))[0]
counts = process_body(
body_id=body_id,
system_id=system_id,
@@ -2020,6 +2023,7 @@ def main():
stem_cap=args.stem_cap,
system_hook=system_gttr_hooks.get(system_id),
world_seed=args.seed,
hop=hop,
log=log,
verbose=args.verbose,
)