feat(content): Sol body name pass and atlas tooling — complete #849 atlas cohesion
Finalizes #849 core-world atlas cohesion: GJ0d (Earth/Sol) markers.json cleaned of erroneous data, refine_log updated with Sol body gap notes, atlas_quality_analysis.py added for ongoing metric tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
atlas_quality_analysis.py — Atlas content quality audit for Sprint 36 (#849/#838).
|
||||
|
||||
Queries atlas_* tables in systems.db and reports on:
|
||||
1. Cross-body name collisions (same name, same feature type, different bodies)
|
||||
2. Cardinal/directional name density per body
|
||||
3. Generic/lazy name patterns
|
||||
4. Earth-echo concentration in high-visibility systems
|
||||
5. Top-stem frequency across all named features
|
||||
|
||||
Usage:
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py [--db server/data/systems.db]
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py --system GJ380
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py --top-collisions 20
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py --body GJ71c
|
||||
|
||||
D-191 §8: markers.json is pixel-space [row, col] against 512×256.
|
||||
Re-run after any hand-refine pass to verify improvements.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sqlite3
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DEFAULT_DB = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
|
||||
CARDINAL_RE = re.compile(
|
||||
r"\b(north|south|east|west|eastern|western|northern|southern|"
|
||||
r"upper|lower|new|great|old|central|inner|outer|kita|minami|higashi|nishi)\b",
|
||||
re.I,
|
||||
)
|
||||
GENERIC_RE = re.compile(
|
||||
r"\b(hilly|sector|zone|district)\b"
|
||||
r"|^(great|the great|hilly)\b"
|
||||
r"|^(valley floor|ridge line|ridge crest|flat ground|riverbend)$",
|
||||
re.I,
|
||||
)
|
||||
EARTH_ECHO_RE = re.compile(
|
||||
r"\b(manchester|london|paris|berlin|tokyo|beijing|new york|sydney|dubai|"
|
||||
r"route \d+|sector \d+|block \d+)\b",
|
||||
re.I,
|
||||
)
|
||||
FEATURE_TABLES = [
|
||||
("atlas_cities", "city"),
|
||||
("atlas_rivers", "river"),
|
||||
("atlas_oceans", "ocean"),
|
||||
("atlas_mountain_ranges", "mountain"),
|
||||
]
|
||||
|
||||
|
||||
def open_db(path: str) -> sqlite3.Connection:
|
||||
return sqlite3.connect(path)
|
||||
|
||||
|
||||
def build_body_index(conn: sqlite3.Connection) -> dict:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"SELECT body_id, system_id, proper_name, cultural_corridor, population "
|
||||
"FROM bodies WHERE inhabited=1"
|
||||
)
|
||||
return {
|
||||
r[0]: {"system_id": r[1], "name": r[2], "corridor": r[3], "pop": r[4]}
|
||||
for r in c.fetchall()
|
||||
}
|
||||
|
||||
|
||||
def gather_all_names(conn: sqlite3.Connection) -> dict[str, list[tuple[str, str, str]]]:
|
||||
"""body_id → [(feature_type, name, local_id), ...]"""
|
||||
c = conn.cursor()
|
||||
result = defaultdict(list)
|
||||
for tbl, feat_type in FEATURE_TABLES:
|
||||
try:
|
||||
c.execute(f"SELECT body_id, name, local_id FROM {tbl} WHERE name IS NOT NULL AND name != ''")
|
||||
for body_id, name, local_id in c.fetchall():
|
||||
result[body_id].append((feat_type, name, local_id))
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def cross_body_collisions(conn: sqlite3.Connection, limit: int = 20) -> dict:
|
||||
c = conn.cursor()
|
||||
collisions = {}
|
||||
for tbl, feat_type in FEATURE_TABLES:
|
||||
try:
|
||||
c.execute(
|
||||
f"SELECT name, COUNT(DISTINCT body_id) as cnt, GROUP_CONCAT(DISTINCT body_id) "
|
||||
f"FROM {tbl} WHERE name IS NOT NULL AND name != '' "
|
||||
f"GROUP BY name HAVING cnt > 1 ORDER BY cnt DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
collisions[feat_type] = [(r[0], r[1], r[2]) for r in c.fetchall()]
|
||||
except sqlite3.OperationalError:
|
||||
collisions[feat_type] = []
|
||||
return collisions
|
||||
|
||||
|
||||
def stem_frequency(names: list[str], top_n: int = 30) -> list[tuple[str, int]]:
|
||||
stems = Counter()
|
||||
for name in names:
|
||||
words = name.split()
|
||||
if words:
|
||||
stems[words[0].lower()] += 1
|
||||
return stems.most_common(top_n)
|
||||
|
||||
|
||||
def body_quality_report(body_id: str, names: list[tuple], conn: sqlite3.Connection) -> dict:
|
||||
total = len(names)
|
||||
if total == 0:
|
||||
return {}
|
||||
cardinal = sum(1 for _, n, _ in names if CARDINAL_RE.search(n))
|
||||
generic = sum(1 for _, n, _ in names if GENERIC_RE.search(n))
|
||||
earth = sum(1 for _, n, _ in names if EARTH_ECHO_RE.search(n))
|
||||
|
||||
c = conn.cursor()
|
||||
# collision count: how many of this body's names appear on other bodies (same type)
|
||||
colliding = 0
|
||||
for feat_type, name, _ in names:
|
||||
tbl = [t for t, f in FEATURE_TABLES if f == feat_type][0]
|
||||
try:
|
||||
c.execute(
|
||||
f"SELECT COUNT(DISTINCT body_id) FROM {tbl} WHERE name=? AND body_id!=?",
|
||||
(name, body_id),
|
||||
)
|
||||
others = c.fetchone()[0]
|
||||
if others > 0:
|
||||
colliding += 1
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"cardinal": cardinal,
|
||||
"cardinal_pct": cardinal / total,
|
||||
"generic": generic,
|
||||
"earth_echo": earth,
|
||||
"colliding": colliding,
|
||||
"colliding_pct": colliding / total,
|
||||
}
|
||||
|
||||
|
||||
def run_analysis(args):
|
||||
conn = open_db(args.db)
|
||||
body_index = build_body_index(conn)
|
||||
all_names_by_body = gather_all_names(conn)
|
||||
|
||||
# Filter by system or body if requested
|
||||
if args.system:
|
||||
body_index = {k: v for k, v in body_index.items() if v["system_id"] == args.system}
|
||||
if args.body:
|
||||
body_index = {k: v for k, v in body_index.items() if k == args.body}
|
||||
|
||||
print("=" * 70)
|
||||
print("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
|
||||
print(f"DB: {args.db}")
|
||||
if args.system:
|
||||
print(f"Filter: system={args.system}")
|
||||
if args.body:
|
||||
print(f"Filter: body={args.body}")
|
||||
print("=" * 70)
|
||||
|
||||
# --- 1. Cross-body collisions ---
|
||||
print("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
|
||||
collisions = cross_body_collisions(conn, limit=args.top_collisions)
|
||||
for feat_type, rows in collisions.items():
|
||||
if rows:
|
||||
print(f"\n {feat_type}:")
|
||||
for name, cnt, bodies in rows:
|
||||
print(f" '{name}' — {cnt} bodies: {bodies[:80]}")
|
||||
|
||||
# --- 2. Per-body quality scores ---
|
||||
print("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
|
||||
reports = []
|
||||
for bid, info in body_index.items():
|
||||
names = all_names_by_body.get(bid, [])
|
||||
if not names:
|
||||
continue
|
||||
report = body_quality_report(bid, names, conn)
|
||||
if not report:
|
||||
continue
|
||||
reports.append((bid, info, report))
|
||||
|
||||
reports.sort(key=lambda x: -x[2]["colliding_pct"])
|
||||
|
||||
print(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
|
||||
f"{'Coll%':>6} {'Card%':>6} {'Gen':>4} {'Echo':>4}")
|
||||
for bid, info, rep in reports[:30]:
|
||||
print(
|
||||
f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
|
||||
f"{rep['colliding_pct']:>6.0%} {rep['cardinal_pct']:>6.0%} "
|
||||
f"{rep['generic']:>4} {rep['earth_echo']:>4}"
|
||||
)
|
||||
|
||||
# --- 3. Stem frequency ---
|
||||
print("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
|
||||
all_names_flat = [n for names in all_names_by_body.values() for _, n, _ in names]
|
||||
for stem, cnt in stem_frequency(all_names_flat, top_n=20):
|
||||
print(f" {stem:<20} {cnt}")
|
||||
|
||||
# --- 4. Detailed body report (if --body specified) ---
|
||||
if args.body and args.body in all_names_by_body:
|
||||
bid = args.body
|
||||
info = body_index.get(bid, {})
|
||||
names = all_names_by_body[bid]
|
||||
print(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
|
||||
c = conn.cursor()
|
||||
for feat_type, name, local_id in sorted(names, key=lambda x: x[0]):
|
||||
tbl = [t for t, f in FEATURE_TABLES if f == feat_type][0]
|
||||
c.execute(
|
||||
f"SELECT COUNT(DISTINCT body_id) FROM {tbl} WHERE name=? AND body_id!=?",
|
||||
(name, bid),
|
||||
)
|
||||
others = c.fetchone()[0]
|
||||
flag = f" *** COLLISION ×{others}" if others > 0 else ""
|
||||
cardinal = " [cardinal]" if CARDINAL_RE.search(name) else ""
|
||||
generic = " [generic]" if GENERIC_RE.search(name) else ""
|
||||
print(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
|
||||
|
||||
# --- 5. Sol gap check ---
|
||||
print("\n[ 5. SOL SYSTEM GAP CHECK ]")
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT body_id, proper_name, population FROM bodies WHERE system_id='GJ 0' AND inhabited=1")
|
||||
sol_bodies = c.fetchall()
|
||||
for bid, bname, pop in sol_bodies:
|
||||
has_cities = bid in all_names_by_body and any(f == "city" for f, _, _ in all_names_by_body[bid])
|
||||
status = "HAS DATA" if has_cities else "*** EMPTY — needs authoring"
|
||||
print(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
|
||||
|
||||
conn.close()
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to systems.db")
|
||||
parser.add_argument("--system", help="Filter to one system (e.g. GJ380)")
|
||||
parser.add_argument("--body", help="Filter to one body (e.g. GJ71c)")
|
||||
parser.add_argument("--top-collisions", type=int, default=15, help="Collision list limit")
|
||||
args = parser.parse_args()
|
||||
run_analysis(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -234,7 +234,37 @@ in log and move on if already above quality bar."
|
||||
|
||||
#### GJ0d — Earth (8.5B pop, core world)
|
||||
|
||||
50 cities from earth_features.json (London, Tokyo, Delhi, Shanghai, etc.). sol_import.py
|
||||
sol_import.py originally placed 50 cities from earth_features.json. Team-lead directed trim
|
||||
to 8-12 cultural touchstones (max 8 cities on any other body; 50 = 13% of all atlas cities
|
||||
on one body). Criterion: would a player setting a bookmark to "Earth" recognize this as a
|
||||
touchstone? One per major historical/cultural cluster.
|
||||
|
||||
**Cities kept (11):**
|
||||
|
||||
| City | Cluster |
|
||||
|---|---|
|
||||
| London | Western Europe — historical capital |
|
||||
| Moscow | Eastern Europe / Russia |
|
||||
| Istanbul | Bridge city — Europe-Asia hinge |
|
||||
| New York | North America |
|
||||
| São Paulo | South America |
|
||||
| Cairo | Africa + ancient world |
|
||||
| Delhi | South Asia |
|
||||
| Tokyo | Japan / East Asia |
|
||||
| Beijing | China / East Asia |
|
||||
| Singapore | Southeast Asia — maritime hub |
|
||||
| Sydney | Oceania |
|
||||
|
||||
**Cities cut (39):** Paris, Berlin, Mexico City, Los Angeles, Toronto, Chicago, Lima, Bogotá,
|
||||
Rio de Janeiro, Buenos Aires, Lagos, Kinshasa, Johannesburg, Nairobi, Tehran, Baghdad, Riyadh,
|
||||
Ankara, Karachi, Shanghai, Mumbai, Jakarta, Dhaka, Manila, Bangkok, Seoul, Osaka, Chongqing,
|
||||
Kolkata, Lahore, Shenzhen, Bangalore, Ho Chi Minh City, Luanda, Addis Ababa, Santiago, Taipei,
|
||||
Hong Kong, Casablanca.
|
||||
|
||||
**Generator note:** sol_import.py has no `--top-n` city filter — it uses the full earth_features.json
|
||||
list. If Sol is regenerated, earth_features.json should be trimmed to the 11 kept cities, or a
|
||||
filter added in sol_import.py. Filed as finding in #853.
|
||||
|
||||
auto-detected 11 rivers and 1 ocean. Three rivers were auto-detected ocean-channel artifacts
|
||||
in the western Pacific island region; named with geographically proximate rivers. The large
|
||||
ocean (area_fraction=0.7049) represents Earth's interconnected world ocean.
|
||||
@@ -246,7 +276,7 @@ ocean (area_fraction=0.7049) represents Earth's interconnected world ocean.
|
||||
| river | null (river_4, ~27°N/136°E) | Tone River | Japan, Kanto plain |
|
||||
| river | null (river_10, ~23°N/135°E) | Cagayan | Northern Philippines, largest Philippine river |
|
||||
|
||||
**DB sync:** `generate_atlas.py --body GJ0d` ✓
|
||||
**DB sync:** `generate_atlas.py --body GJ0d` ✓ (11 cities, trimmed from 50)
|
||||
|
||||
---
|
||||
|
||||
@@ -316,20 +346,58 @@ Team-lead confirmed: defer this sprint. Finding documented for future pass.
|
||||
|
||||
---
|
||||
|
||||
## Audit metrics (before/after comparison)
|
||||
## Audit metrics — final confirmed delta
|
||||
|
||||
All audited systems post-fix:
|
||||
Final audit run: 2026-04-19. All 6 systems re-run after all edits. Results below are
|
||||
from `atlas_cohesion_audit.py` against `server/data/systems.db`.
|
||||
|
||||
| System | Empty names before | After | Lazy outputs before | After | Cardinals before | After | Key issues resolved |
|
||||
|--------|-------------------|----|--------------------|----|-----------------|-------|-----|
|
||||
| GJ 144 (Ran) | 0 | 0 | 10+ | 0 | 1 | 0 | Aldren/GJ380c cross-system collision fixed |
|
||||
| GJ 71 (Tau Ceti) | 0 | 0 | 2 | 0 | 0 | 0 | Concordia, Basilica Nova cross-body collisions |
|
||||
| GJ 244A (Sirius) | 0 | 0 | 2 | 0 | 3 | 0 | Westwall not present; Cairnside resolved |
|
||||
| GJ 380 (Groombridge) | 0 | 0 | 2 | 0 | 0 | 0 | Lazy suffixes on two #833 features |
|
||||
| GJ 699 (Barnard's) | 0 | 0 | 1 | 0 | 0 | 0 | Civic vocab on rivers/oceans; street addresses on moon |
|
||||
| GJ 0 (Sol) | 33 | 0 | 0 | 0 | 0 | 0 | All auto-detected null-name features named |
|
||||
### Inhabited body targets (in scope for this ticket)
|
||||
|
||||
**Total name edits across all bodies:** 23 (non-Sol) + 33 (Sol) = 56 total.
|
||||
| System | Body | Empty names: before→after | Lazy outputs: before→after | Cardinals: before→after |
|
||||
|--------|------|--------------------------|---------------------------|------------------------|
|
||||
| GJ 144 | GJ144d Kallast (2B) | 0→0 | 3→0 | 0→0 |
|
||||
| GJ 144 | GJ144e Vethis (1.2B) | 0→0 | 7→0 | 1→0 |
|
||||
| GJ 71 | GJ71c Threshold (600M) | 0→0 | 1→0 | 0→0 |
|
||||
| GJ 71 | GJ71d Arden (500M) | 0→0 | 1→0 | 0→0 |
|
||||
| GJ 71 | GJ71d-1 Verantis (20M) | 0→0 | 0→0 | 0→0 |
|
||||
| GJ 244A | GJ244Ad Edict (400M) | 0→0 | 2→0 | 3→0 |
|
||||
| GJ 380 | GJ380c Lendel (900M) | 0→0 | 2→0 | 0→0 |
|
||||
| GJ 699 | GJ699b Verada (1.9B) | 0→0 | 8→0 | 0→0 |
|
||||
| GJ 699 | GJ699b-1 (uninhabited moon) | 8→0 | 8→0 | 0→0 |
|
||||
| GJ 0 | GJ0d Earth (8.5B) | 4→0 | 0→0 | 0→0 |
|
||||
| GJ 0 | GJ0d-1 Luna (350M) | 24→0 | 0→0 | 0→0 |
|
||||
| GJ 0 | GJ0e Mars (1.2B) | 4→0 | 0→0 | 0→0 |
|
||||
| GJ 0 | GJ0f-2 Europa (30M) | 1→0 | 0→0 | 0→0 |
|
||||
|
||||
**All inhabited targets: zero empty names, zero lazy outputs, zero cardinals after fixes.**
|
||||
|
||||
### Remaining audit flags — out of scope or false positives
|
||||
|
||||
After fixes, the audit still reports flags on:
|
||||
|
||||
**Out of scope — uninhabited/low-pop bodies (not "high-visibility"):**
|
||||
- GJ144b, GJ144c, GJ144d-1, GJ144e-1, GJ144f, GJ144g-1, GJ144g-2: "Canyon View", "Dry Gulch",
|
||||
"Stone Creek" etc. These are #833 batch artifacts on non-target bodies. Captured in #853.
|
||||
- GJ71e: "Meridian Point" (uninhabited body, not in scope)
|
||||
- GJ380b, GJ380d, GJ380e: various lazy patterns (uninhabited, not in scope)
|
||||
|
||||
**False positives on quality cross-reference names (distinctive stem + common suffix):**
|
||||
- `'Rán's Run'` (GJ144d) — Rán- arc; % Run pattern-matched but stem is unique proper name
|
||||
- `'Greywash Fork'` (GJ144e) — Grey- arc; % Fork but Greywash is not a generic stem
|
||||
- `'Kelside Run'` (GJ144e) — Kel- arc; % Run but Kelside is distinctive
|
||||
- `'Greystone Ridge'` (GJ144e) — Grey- arc; % Ridge but Greystone is distinctive
|
||||
|
||||
These four are intentional renames (listed in the FIXES table above) that happen to end with
|
||||
a suffix in LAZY_PATTERNS. The script does not evaluate stem quality, only suffix pattern.
|
||||
A future pass on the audit script could add a stem-distinctiveness filter.
|
||||
|
||||
### Earth city count correction
|
||||
|
||||
Per team-lead direction: Earth trimmed from **50 → 11 cities** (cultural/historical touchstones,
|
||||
one per major cluster). 39 cities cut. DB synced. See GJ0d section above for full cut list.
|
||||
|
||||
**Total name edits across all bodies:** 23 (non-Sol) + 33 (Sol) = 56 feature renames.
|
||||
**Earth city cut:** 39 removed.
|
||||
|
||||
Intentional same-body cross-feature stem dups (quality arcs) now visible in audit output for:
|
||||
- GJ144d: Rán- (city + river), Seter- (mountain + ocean)
|
||||
@@ -378,7 +446,7 @@ SR_DB_PATH="$(pwd)/server/data/systems.db" tooling/db/sqlite-query \
|
||||
- `wiki/star-systems/GJ-699/bodies/GJ699b/markers.json` (8 edits)
|
||||
- `wiki/star-systems/GJ-699/bodies/GJ699b-1/markers.json` (8 edits)
|
||||
- `tooling/planet-gen/sol_name_fixes.py` (new — names 33 null-name Sol features)
|
||||
- `wiki/star-systems/GJ-0/bodies/GJ0d/markers.json` (1 ocean + 3 rivers named)
|
||||
- `wiki/star-systems/GJ-0/bodies/GJ0d/markers.json` (1 ocean + 3 rivers named; 39 cities cut → 11)
|
||||
- `wiki/star-systems/GJ-0/bodies/GJ0d-1/markers.json` (24 mountain ranges named)
|
||||
- `wiki/star-systems/GJ-0/bodies/GJ0e/markers.json` (4 mountain ranges named)
|
||||
- `wiki/star-systems/GJ-0/bodies/GJ0f-2/markers.json` (1 mountain range named)
|
||||
|
||||
Reference in New Issue
Block a user