feat(tooling): skip dedup-exhausted features instead of palette fallback (#833)

When all 3 LLM attempts are rejected (dedup, blocklist, etc.),
name_feature now returns None instead of a deterministic palette
fallback. process_body leaves the name as null in markers.json.

The preserved path (_is_blank) treats null as unnamed, so a fill
round (re-running the script) picks up only the skipped features
with a fresh corpus — zero dedup pressure from the first pass. The
fill round can use a different seed, slower prompt, or a different
backend entirely (e.g. Haiku).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 08:42:32 +02:00
co-authored by Claude Opus 4.6
parent c767f82fbb
commit 3ab28138a0
+30 -34
View File
@@ -1450,21 +1450,12 @@ def name_feature(
_commit_name(cleaned)
return cleaned
# All attempts exhausted — deterministic palette fallback, then dedup.
# Fallback names draw from the palette stems and do NOT count against
# the stem cap (the palette is intentionally narrow and would trigger
# infinite rejection loops otherwise).
salt = _seed_for(world_seed, body_id, local_id, max_attempts) & 0xFFFFFF
fallback = fallback_name(corridor, feature_type, salt)
bump = 0
while _is_duplicate(fallback) and bump < 100:
bump += 1
fallback = fallback_name(corridor, feature_type, salt + bump)
used.add(fallback)
body_used.add(fallback)
# All attempts exhausted. Leave the name as None so the preserved
# path skips it on a fill round — a second pass with a fresh corpus
# will pick it up and try again with different dedup pressure.
reasons = " ".join(f"{k}={v}" for k, v in rejections.items() if v > 0)
log(f" fallback: {body_id}/{local_id}'{fallback}' [{reasons}]")
return fallback
log(f" skipped: {body_id}/{local_id} [{reasons}]")
return None
# ---------------------------------------------------------------------------
@@ -1646,10 +1637,11 @@ def process_body(
world_seed, body_id, city.get("id") or "city_?",
hop, log, verbose,
)
city["name"] = name
counts["cities"] += 1
generated["cities"].append(name)
changed = True
if name is not None:
city["name"] = name
counts["cities"] += 1
generated["cities"].append(name)
changed = True
# Rivers
for river in markers.get("rivers") or []:
@@ -1663,10 +1655,11 @@ def process_body(
world_seed, body_id, river.get("id") or "river_?",
hop, log, verbose,
)
river["name"] = name
counts["rivers"] += 1
generated["rivers"].append(name)
changed = True
if name is not None:
river["name"] = name
counts["rivers"] += 1
generated["rivers"].append(name)
changed = True
# Oceans / seas / lakes
for water in markers.get("oceans") or []:
@@ -1683,10 +1676,11 @@ def process_body(
world_seed, body_id, water.get("id") or "water_?",
hop, log, verbose,
)
water["name"] = name
counts["oceans"] += 1
generated["oceans"].append(name)
changed = True
if name is not None:
water["name"] = name
counts["oceans"] += 1
generated["oceans"].append(name)
changed = True
# Mountain ranges
for rng_feat in markers.get("mountain_ranges") or []:
@@ -1702,10 +1696,11 @@ def process_body(
world_seed, body_id, rng_feat.get("id") or "range_?",
hop, log, verbose,
)
rng_feat["name"] = name
counts["mountain_ranges"] += 1
generated["mountain_ranges"].append(name)
changed = True
if name is not None:
rng_feat["name"] = name
counts["mountain_ranges"] += 1
generated["mountain_ranges"].append(name)
changed = True
# POIs
for poi in markers.get("pois") or []:
@@ -1722,10 +1717,11 @@ def process_body(
world_seed, body_id, poi.get("id") or "poi_?",
hop, log, verbose,
)
poi["name"] = name
counts["pois"] += 1
generated["pois"].append(name)
changed = True
if name is not None:
poi["name"] = name
counts["pois"] += 1
generated["pois"].append(name)
changed = True
if changed:
markers_path.write_text(json.dumps(markers, indent=2) + "\n")