feat(db): corp-HQ settlement model + per-settlement population/class bake (T-1074/T-1075, D-242)

Remove the reserved=1 corp-HQ city-pool cross-reference (duplicate co-named cities); corp_specialization keyed on the D-237 vocabulary extended 27->30; authored corp_hq_placement.toml {CityTenant,Standalone} map; headquarters_body backfill via lifted most-populated-body heuristic + body-type tiebreak; standalone HQs emitted as ordinary settlement rows; Zipf rank-size population spread from bodies.population at import; settlement_class defaults PopulationBudget + settlement_name_locked.toml hero pins; retire orphaned populate-corporations.sh; regen systems.db.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 10:29:00 +02:00
co-authored by Claude Fable 5
parent 43a6d55180
commit 62bbf57009
12 changed files with 903 additions and 145 deletions
+225 -100
View File
@@ -1,12 +1,24 @@
"""Atlas index ownership (D-223, #951): canonical atlas_* schema, the
names-only city pool, and the corp-HQ city cross-reference (D-207)."""
"""Atlas index ownership (D-223, #951): canonical atlas_* schema and the
names-only city pool.
The corp-HQ city cross-reference (D-207, `populate_atlas_city_names_corps`)
was REMOVED here (D-242, T-1074) — it inserted one atlas_city_names row per
corp HQ with no UNIQUE(body_id, name), producing duplicate co-named "cities"
(e.g. 10 Groombridge rows on GJ380c). The corp<->settlement relationship now
lives with the corp (economy_import/corporations.py:populate_corp_specialization),
not in the city pool. `most_populated_body_in_system` below is the one piece
of that removed function's logic still needed (by the D-242 replacement).
`populate_settlement_population_class` (D-242, T-1075) bakes the per-settlement
population spread + settlement_class onto atlas_city_names — see its own
docstring for the rank-size derivation and D-196 class-assignment rule."""
import glob
import json
import re
import sqlite3
import tomllib
from .paths import CORPORATIONS_DIR, SCHEMA_SQL, WIKI_STAR_SYSTEMS
from .paths import SCHEMA_SQL, SETTLEMENT_NAME_LOCKED_TOML, WIKI_STAR_SYSTEMS
# Atlas geometry index tables (D-191). These hold computed positions — city
# centres, road/river/rail polylines, ocean/mountain extents. Under D-223 the
@@ -67,8 +79,9 @@ def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int:
- name : pooled city name
- kind : 'city' — capital is chosen at placement (#955)
- economic_role : inherited from bodies.economic_role; fallback 'mixed'
- population : 0 — assigned by the server cascade at placement (#955)
- corp_id : NULL — populated by populate_atlas_city_names_corps (#909)
- population : 0 — baked at import by populate_settlement_population_class (D-242, T-1075)
- corp_id : NULL — SUPERSEDED (D-242): the corp-HQ cross-reference
that used to populate this was removed; always NULL now
- reserved : 0
markers.json is a names-only flavoured pool (D-223): it carries no geometry
@@ -129,6 +142,16 @@ def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int:
print(f" warning: {len(unique)} body dirs not in DB — skipped: {unique[:5]}")
if not dry_run:
# D-242: corporations.headquarters_city_id FK-references
# atlas_city_names(id). corporations is append-only (never cleared —
# db.clear_economics_tables doesn't touch it), so a PRIOR run's
# resolved CityTenant links survive as live FK references into the
# very rows this DELETE is about to remove — clear them first or the
# DELETE hits a FOREIGN KEY constraint failure on every run after the
# first (caught live in the T-1074/T-1075 regen-db integration test).
# Re-populated by corporations.populate_standalone_hq_settlements
# later in this same import run.
conn.execute("UPDATE corporations SET headquarters_city_id = NULL")
conn.execute("DELETE FROM atlas_city_names")
if rows:
conn.executemany(
@@ -141,110 +164,212 @@ def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int:
return len(rows)
def populate_atlas_city_names_corps(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]:
"""Cross-reference corp HQ city names into atlas_city_names (D-207, #909).
# body_type preference for the most_populated_body_in_system tiebreak — lower
# sorts first. Only breaks a population TIE (real population always wins);
# this exists because a plain population-then-body_id sort can otherwise land
# on an asteroid_belt/oort_cloud sibling purely because '-' (the body_id
# naming convention for these types, e.g. "GJ702B-belt") sorts before letters
# — a body type that structurally cannot host a settlement (found live in
# T-1074/T-1075 regen: GJ 702B's 12 bodies are ALL population=0, and the old
# alphabetical-only tiebreak picked "GJ702B-belt" over 6 sibling planets,
# stranding prometheus-labs/kovalev-freight's CityTenant resolution with a
# city-less body). planet/moon/gas_giant can plausibly carry cities or
# stations; asteroid_belt/oort_cloud are geometry, never named settlements.
_BODY_TYPE_SETTLEMENT_PREFERENCE: dict[str, int] = {
"planet": 0,
"moon": 1,
"gas_giant": 2,
"asteroid_belt": 3,
"oort_cloud": 3,
}
For each corporation with a parseable headquarters field ("City (SYSTEM_ID)"):
- If atlas_city_names already has a row with matching name on a body in that
system: UPDATE the row to set corp_id.
- Otherwise: INSERT a reserved row (reserved=1) so the name is protected.
Attaches to the most-populated body in the system (fallback: any body).
Returns (n_updated, n_inserted).
def most_populated_body_in_system(conn: sqlite3.Connection, system_id: str) -> str | None:
"""Return the most-populated body_id in `system_id`, or None if the system
has no bodies.
D-242, T-1074: this is the heuristic the retired
`populate_atlas_city_names_corps` (D-207, #909) used to attach a corp-HQ
row to a body — lifted out here (rather than duplicated) so
`populate_corp_specialization` (economy_import/corporations.py) can reuse
it to backfill `corporations.headquarters_body` for Standalone HQs.
Deterministic tiebreak, in order (D-010 #4): (1) population descending —
real population always wins regardless of type; (2) body_type preference
(planet < moon < gas_giant < asteroid_belt/oort_cloud — see
`_BODY_TYPE_SETTLEMENT_PREFERENCE`) — only engages among population TIES,
so it never overrides an authored population signal, it only picks a
saner body among zero-population siblings; (3) body_id ascending, the
final tiebreak when population AND type both tie.
"""
# Build system_id -> sorted bodies (by population desc, then body_id)
sys_bodies: dict[str, list[tuple[int, str, str]]] = {}
for body_id, sys_id, pop, role in conn.execute(
"SELECT body_id, system_id, COALESCE(population, 0), COALESCE(economic_role, 'mixed') FROM bodies"
).fetchall():
sys_bodies.setdefault(sys_id, []).append((pop, body_id, role))
for v in sys_bodies.values():
v.sort(key=lambda x: (-x[0], x[1]))
rows = conn.execute(
"SELECT body_id, body_type, COALESCE(population, 0) FROM bodies WHERE system_id = ?",
(system_id,),
).fetchall()
if not rows:
return None
rows.sort(
key=lambda r: (
-r[2], # population descending
_BODY_TYPE_SETTLEMENT_PREFERENCE.get(r[1], 3), # body_type preference
r[0], # body_id ascending
)
)
return rows[0][0]
# Build (body_id, name_lower) -> id index for existing atlas_city_names rows
existing: dict[tuple[str, str], int] = {}
body_to_sys: dict[str, str] = {
r[0]: r[1]
for r in conn.execute("SELECT body_id, system_id FROM bodies").fetchall()
# ---------------------------------------------------------------------------
# D-242 population/settlement_class bake (T-1075)
# ---------------------------------------------------------------------------
# Zipf/rank-size curve constants (T-1075: "name + document the curve
# constants — tuning is a code change by design"). weight(rank) =
# ZIPF_WEIGHT_SCALE // rank, i.e. the classic Zipf's-law exponent s=1.0
# (rank-2 city gets half of rank-1's weight, rank-3 a third, ...) expressed
# as an integer basis-weight — no floats anywhere in the derivation (D-010).
# ZIPF_WEIGHT_SCALE only needs to be large enough that //rank doesn't
# truncate the tail ranks to 0 before normalization; 100 pooled cities on the
# densest body (Groombridge, ~16 rows pre-T-1074-cleanup) is far under this.
ZIPF_WEIGHT_SCALE = 1_000_000
def _zipf_population_spread(body_population: int, n_cities: int) -> list[int]:
"""Split `body_population` across `n_cities` settlements via a Zipf/rank-size
curve (T-1075). Returns a list of `n_cities` integer populations, index 0 =
rank 1 (the largest), summing EXACTLY to `body_population` (any integer-
division remainder is added to rank 1, so the total never drifts — the
standard largest-remainder-to-the-leader technique, not silently dropped).
`n_cities == 0` returns `[]`. `body_population <= 0` returns all zeros
(an authored-population gap; not this function's job to invent one, per
D-242's "only position stays seed-derived" — POPULATION is baked from
`bodies.population`, and an unauthored body simply bakes zero).
Which of a body's `atlas_city_names` rows *is* "rank 1" (gets the largest
share) is the caller's business (see `populate_settlement_population_class`
— rank is assigned by ascending `id`, i.e. insertion order, D-010
determinism; there is no population signal to rank by BEFORE this
function runs, which is the whole reason it exists).
"""
if n_cities <= 0:
return []
if body_population <= 0:
return [0] * n_cities
weights = [ZIPF_WEIGHT_SCALE // rank for rank in range(1, n_cities + 1)]
total_weight = sum(weights)
shares = [body_population * w // total_weight for w in weights]
remainder = body_population - sum(shares)
shares[0] += remainder # give the leftover to rank 1 (D-010: deterministic)
return shares
def populate_settlement_population_class(conn: sqlite3.Connection, dry_run: bool) -> dict:
"""Bake per-settlement population + settlement_class onto atlas_city_names
(D-242, T-1075).
MUST run after BOTH D-242/T-1074 steps (`populate_atlas_city_names` and
`corporations.populate_standalone_hq_settlements`) — "the spread runs over
the CORRECTED pool," i.e. Standalone-HQ settlement rows are included in
the per-body city count/rank BEFORE the spread runs, not bolted on after.
Two passes:
(1) Population — for every body with at least one atlas_city_names row,
split `bodies.population` (COALESCE to 0 for unauthored bodies) across
its cities via `_zipf_population_spread`. Rank 1 (the largest share)
is the row with the LOWEST atlas_city_names.id on that body (D-010:
deterministic — insertion order is the only signal available; there
is no pre-existing population to rank by, which is exactly the gap
this function closes). A body with population=0/NULL bakes every one
of its cities to population=0 (not an error — 6/273 populated bodies
currently have zero pooled cities and 9/276 city-bearing bodies
currently have zero authored population; both directions are
legitimate content gaps, not import failures).
(2) settlement_class — every pooled city defaults to 'PopulationBudget'
(D-196; the >=50k-active/<5k-ghost thresholds are read from this baked
population at GENERATION time by a later, not-yet-built consumer —
out of this bake's scope, confirmed: no such consumer exists in
server/src/atlas yet). The authored NameLocked override list
(settlement_name_locked.toml, `[[hero]]` stanzas of body_id + name)
then overrides specific rows to 'NameLocked' — matched by
(body_id, name), NOT the volatile numeric id (see that TOML's header).
A stanza with no matching row is a non-fatal warning (a future
markers.json rename must not hard-fail regen-db).
Returns a coverage dict: {"bodies_spread", "cities_populated",
"name_locked_applied", "name_locked_unmatched"}.
"""
# --- Pass 1: population spread -----------------------------------------
body_population: dict[str, int] = {
r[0]: (r[1] or 0) for r in conn.execute("SELECT body_id, population FROM bodies").fetchall()
}
for row_id, body_id, name in conn.execute(
"SELECT id, body_id, name FROM atlas_city_names"
# body_id -> [(city_id, ...)] ordered by id ascending (rank 1 = lowest id).
city_rows_by_body: dict[str, list[int]] = {}
for city_id, body_id in conn.execute(
"SELECT id, body_id FROM atlas_city_names ORDER BY body_id, id"
).fetchall():
existing[(body_id, name.lower())] = row_id
city_rows_by_body.setdefault(body_id, []).append(city_id)
# Build system_id -> set of body_ids for quick lookup
sys_body_ids: dict[str, set[str]] = {}
for body_id, sys_id in body_to_sys.items():
sys_body_ids.setdefault(sys_id, set()).add(body_id)
population_updates: list[tuple[int, int]] = [] # (population, city_id)
for body_id, city_ids in city_rows_by_body.items():
pop = body_population.get(body_id, 0)
spread = _zipf_population_spread(pop, len(city_ids))
for city_id, city_pop in zip(city_ids, spread):
population_updates.append((city_pop, city_id))
updated: list[tuple[str, int]] = [] # (corp_id, atlas_row_id)
inserted: list[tuple] = [] # insert rows
for corp_id, headquarters_system in conn.execute(
"SELECT corp_id, headquarters_system FROM corporations WHERE headquarters_system IS NOT NULL"
).fetchall():
# Retrieve original headquarters string from wiki to get city name
md_file = CORPORATIONS_DIR / f"{corp_id}.md"
if not md_file.exists():
continue
hq_raw = ""
with open(md_file) as f:
in_fm = False
for line in f:
if line.strip() == "---":
if not in_fm:
in_fm = True
continue
else:
break
if in_fm and line.startswith("headquarters:"):
hq_raw = line.split(":", 1)[1].strip().strip('"')
break
if not hq_raw:
continue
m = re.search(r"\(([^)]+)\)", hq_raw)
city_name = hq_raw[: m.start()].strip() if m else hq_raw.strip()
if not city_name:
continue
# Try to find a matching atlas_city_names row in the same system
body_ids_in_sys = sys_body_ids.get(headquarters_system, set())
match_id: int | None = None
# sorted() for determinism: on a name collision across bodies in the
# same system, set iteration order is not stable (D-010 #4).
for body_id in sorted(body_ids_in_sys):
key = (body_id, city_name.lower())
if key in existing:
match_id = existing[key]
break
if match_id is not None:
updated.append((corp_id, match_id))
else:
# Sol (system 'GJ 0') is exempt from the normal generators (D-223,
# #951) — do not synthesize a reserved corp-HQ row on a Sol body;
# Sol's atlas data comes from its own scripted integration.
if headquarters_system == "GJ 0":
continue
# Insert a reserved row on the most-populated body in the system
candidates = sys_bodies.get(headquarters_system, [])
if not candidates:
continue
_, target_body_id, body_role = candidates[0]
inserted.append((target_body_id, city_name, "city", body_role, 0, corp_id, 1))
# --- Pass 2: settlement_class default + NameLocked override ------------
with open(SETTLEMENT_NAME_LOCKED_TOML, "rb") as f:
hero_data = tomllib.load(f)
hero_entries: list[dict] = hero_data.get("hero", [])
if not dry_run:
for corp_id, row_id in updated:
conn.execute(
"UPDATE atlas_city_names SET corp_id = ? WHERE id = ?",
(corp_id, row_id),
)
if inserted:
if population_updates:
conn.executemany(
"""INSERT OR IGNORE INTO atlas_city_names
(body_id, name, kind, economic_role, population, corp_id, reserved)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
inserted,
"UPDATE atlas_city_names SET population = ? WHERE id = ?",
population_updates,
)
# Default every row to PopulationBudget (D-196). Idempotent: a re-run
# always resets to the default before re-applying overrides below, so
# a stanza REMOVED from settlement_name_locked.toml correctly reverts
# its row to PopulationBudget rather than leaving a stale NameLocked.
conn.execute("UPDATE atlas_city_names SET settlement_class = 'PopulationBudget'")
return len(updated), len(inserted)
# NameLocked matching is a read-only SELECT count (safe under dry_run too
# — a --dry-run run must still be able to REPORT an unmatched hero stanza,
# not just skip detecting it; the WRITE is what's gated on dry_run).
name_locked_applied = 0
name_locked_unmatched: list[str] = []
for entry in hero_entries:
body_id = entry.get("body_id")
name = entry.get("name")
if not body_id or not name:
continue
match_count = conn.execute(
"SELECT COUNT(*) FROM atlas_city_names WHERE body_id = ? AND name = ?",
(body_id, name),
).fetchone()[0]
if match_count == 0:
name_locked_unmatched.append(f"{body_id}/{name}")
continue
if not dry_run:
conn.execute(
"UPDATE atlas_city_names SET settlement_class = 'NameLocked' "
"WHERE body_id = ? AND name = ?",
(body_id, name),
)
name_locked_applied += match_count
if name_locked_unmatched:
print(
f" warning: {len(name_locked_unmatched)} NameLocked hero stanza(s) "
f"matched no atlas_city_names row: {name_locked_unmatched}"
)
return {
"bodies_spread": len(city_rows_by_body),
"cities_populated": len(population_updates),
"name_locked_applied": name_locked_applied,
"name_locked_unmatched": len(name_locked_unmatched),
}
@@ -1,10 +1,14 @@
"""Corporation wiki parsing, D-182 sync, and corp_presence population."""
"""Corporation wiki parsing, D-182 sync, corp_presence population, and the
D-242 corp-HQ specialization/placement bake (T-1074)."""
import re
import sqlite3
import tomllib
from pathlib import Path
from .paths import CORPORATIONS_DIR
from .atlas import most_populated_body_in_system
from .errors import ImportAborted
from .paths import CORP_HQ_PLACEMENT_TOML, CORPORATIONS_DIR
# ---------------------------------------------------------------------------
# Corporation wiki parsing
@@ -51,12 +55,18 @@ def load_wiki_corps() -> list[dict]:
hq = fm.get("headquarters", "")
m = re.search(r"\(([^)]+)\)", hq)
system_id = m.group(1) if m else None
# D-242, T-1074: authored corp_specialization frontmatter key — the
# HQ-placement key (reuses the D-237 specialization_vocabulary
# id-space, extended from 27 to 30 values for corp coverage; see
# wiki/economics/corp_hq_placement.toml). Optional — None until a
# corp's page is authored with the key.
corps.append({
"corp_id": fm["slug"],
"proper_name": fm["title"],
"system_id": system_id,
"tags": fm.get("tags", []),
"scope": fm.get("scope", ""),
"corp_specialization": fm.get("corp_specialization") or None,
})
return corps
@@ -173,6 +183,143 @@ def _resolve_hq_location(
return None
# ---------------------------------------------------------------------------
# D-242 corp-HQ specialization + placement bake (T-1074)
# ---------------------------------------------------------------------------
def populate_corp_specialization(
conn: sqlite3.Connection, wiki_corps: list[dict], dry_run: bool
) -> dict:
"""Phase A of the D-242 corp-HQ model (T-1074).
For every wiki corp: validate its authored `corp_specialization` (frontmatter
key, reuses the D-237 `specialization_vocabulary` id-space — see
wiki/economics/corp_hq_placement.toml header) against the vocabulary table
(must run AFTER `import_system_specialization`, which populates
`specialization_vocabulary`); resolve `hq_placement` ('CityTenant' |
'Standalone') from `corp_hq_placement.toml`; and backfill
`corporations.headquarters_body` via the most-populated-body-in-system
heuristic (`atlas.most_populated_body_in_system` — lifted from the retired
`populate_atlas_city_names_corps`, D-207/#909) for any corp that doesn't
already have one authored.
Must run AFTER `sync_corporations` (corp rows must exist) and BEFORE
`import_corp_presence` (which reads `headquarters_body` back off
`corporations` to resolve corp_presence locations, #909's existing
resolution order) — so `import_corp_presence` picks up the backfilled
value for free, with no change to its own logic.
Does NOT touch `atlas_city_names` — this is Phase A. Phase B
(`populate_standalone_hq_settlements`) runs after `populate_atlas_city_names`
and emits Standalone-HQ settlement rows + resolves CityTenant
`headquarters_city_id` links, since both need the city pool to exist.
HARD error (raises ImportAborted) if any corp's authored
`corp_specialization` is non-empty but not a valid vocabulary id, OR if
any corp's specialization does not resolve to a placement value in
`corp_hq_placement.toml` (a vocabulary/placement-map drift — every
vocabulary id must have a placement stanza). A corp with NO authored
`corp_specialization` is a soft warning (defers headquarters_body
backfill and Phase B placement for that corp) — not fatal, so an
un-authored future corp doesn't block regen-db; but ALL 165 corps in the
live wiki are authored as of T-1074, so this path is not expected to fire
on the shipped data.
Returns a coverage dict: {"total", "specialized", "placed", "backfilled_hq"}.
"""
with open(CORP_HQ_PLACEMENT_TOML, "rb") as f:
placement_map: dict[str, dict] = tomllib.load(f)
valid_spec_ids = {
r[0] for r in conn.execute("SELECT specialization_id FROM specialization_vocabulary").fetchall()
}
errors: list[str] = []
unauthored: list[str] = []
# (corp_id, corp_specialization, hq_placement) rows to write.
spec_rows: list[tuple[str, str, str]] = []
for corp in wiki_corps:
corp_id = corp["corp_id"]
spec = corp.get("corp_specialization")
if not spec:
unauthored.append(corp_id)
continue
if spec not in valid_spec_ids:
errors.append(
f"{corp_id}: corp_specialization '{spec}' not in specialization_vocabulary "
f"(add it to wiki/economics/specialization_vocabulary.toml or fix the typo)"
)
continue
placement_entry = placement_map.get(spec)
if not placement_entry or not placement_entry.get("hq_placement"):
errors.append(
f"{corp_id}: specialization '{spec}' has no hq_placement stanza in "
f"corp_hq_placement.toml (every specialization_vocabulary id needs one)"
)
continue
placement = placement_entry["hq_placement"]
if placement not in ("CityTenant", "Standalone"):
errors.append(
f"{corp_id}: specialization '{spec}' hq_placement '{placement}' invalid "
f"(expected 'CityTenant' or 'Standalone')"
)
continue
spec_rows.append((corp_id, spec, placement))
if errors:
print(f" CORP SPECIALIZATION ERRORS ({len(errors)}):")
for e in errors:
print(f" - {e}")
raise ImportAborted()
if unauthored:
print(
f" warning: {len(unauthored)} corp(s) have no authored "
f"corp_specialization (headquarters_body/placement skipped): {unauthored[:5]}"
)
n_backfilled = 0
if not dry_run:
for corp_id, spec, placement in spec_rows:
conn.execute(
"UPDATE corporations SET corp_specialization = ?, hq_placement = ? "
"WHERE corp_id = ?",
(spec, placement, corp_id),
)
# Backfill headquarters_body — only for rows that don't already have one
# (never clobber an authored value; today none are authored, but the
# column exists precisely so a future ticket CAN author one directly).
for corp_id, _spec, _placement in spec_rows:
row = conn.execute(
"SELECT headquarters_system, headquarters_body FROM corporations WHERE corp_id = ?",
(corp_id,),
).fetchone()
if row is None:
continue
hq_system, hq_body = row
if hq_body:
continue
if not hq_system:
continue
resolved = most_populated_body_in_system(conn, hq_system)
if resolved:
conn.execute(
"UPDATE corporations SET headquarters_body = ? WHERE corp_id = ?",
(resolved, corp_id),
)
n_backfilled += 1
return {
"total": len(wiki_corps),
"specialized": len(spec_rows),
"placed": len(spec_rows),
"backfilled_hq": n_backfilled,
}
def import_corp_presence(
conn: sqlite3.Connection,
wiki_corps: list[dict],
@@ -236,3 +383,109 @@ def import_corp_presence(
)
return len(rows)
def populate_standalone_hq_settlements(conn: sqlite3.Connection, dry_run: bool) -> dict:
"""Phase B of the D-242 corp-HQ model (T-1074).
MUST run after `populate_atlas_city_names` (D-223, atlas.py) — that step's
`DELETE FROM atlas_city_names` at the top of its own run means Phase B is
always writing into a freshly-cleared, freshly-repopulated pool, so this
function is the SOLE remaining writer for corp-HQ rows on every run: no
idempotency guard is needed (nothing from a prior run can survive to
collide with this one). Also must run after `populate_corp_specialization`
(Phase A — needs corp_specialization/hq_placement/headquarters_body set).
Two independent passes over `corporations` rows with
`hq_placement IS NOT NULL AND headquarters_body IS NOT NULL`:
- Standalone: INSERT one new atlas_city_names row per corp — a company-town
settlement named after the corp (`proper_name`), on `headquarters_body`,
with `economic_role` from `corp_hq_placement.toml`'s
`standalone_economic_role` (the existing 10-value D-195/D-197 vocabulary
— NOT the 30-value specialization_vocabulary, which `match_cities`/
`CompatibilityMatrix` do not index). `population = 0` — T-1075 bakes the
per-settlement population spread over the corrected pool (this row
included) in a later step; this function only creates the row.
`settlement_class` is left NULL here too, for the same reason (T-1075
assigns it from the baked population).
- CityTenant: resolve `corporations.headquarters_city_id` to an existing
atlas_city_names row on `headquarters_body` — the lowest `id` (D-010:
deterministic, no population signal exists yet to rank by; population is
baked in a later step by T-1075). A body with a Standalone-only pool (no
ordinary city — e.g. a purely industrial body with zero wiki-pooled
names) has nothing to tenant; the corp is logged and left untenanted
rather than erroring — a corp with no city to join on its own HQ body is
a content gap (an empty markers.json city pool for that body), not an
import-time bug.
Returns a coverage dict: {"standalone_inserted", "tenant_linked", "tenant_unmatched"}.
"""
with open(CORP_HQ_PLACEMENT_TOML, "rb") as f:
placement_map: dict[str, dict] = tomllib.load(f)
rows = conn.execute(
"""SELECT corp_id, proper_name, hq_placement, headquarters_body, corp_specialization
FROM corporations
WHERE hq_placement IS NOT NULL AND headquarters_body IS NOT NULL"""
).fetchall()
valid_body_ids = {r[0] for r in conn.execute("SELECT body_id FROM bodies").fetchall()}
standalone_inserts: list[tuple] = [] # (body_id, name, kind, economic_role, population)
tenant_candidates: list[tuple[str, str]] = [] # (corp_id, headquarters_body)
unmatched_bodies: list[str] = []
for corp_id, proper_name, placement, hq_body, spec in rows:
if hq_body not in valid_body_ids:
unmatched_bodies.append(f"{corp_id} (headquarters_body '{hq_body}' not in bodies)")
continue
if placement == "Standalone":
role = (placement_map.get(spec) or {}).get("standalone_economic_role") or "manufacturing"
standalone_inserts.append((hq_body, proper_name, "city", role, 0))
elif placement == "CityTenant":
tenant_candidates.append((corp_id, hq_body))
if not dry_run and standalone_inserts:
conn.executemany(
"""INSERT INTO atlas_city_names (body_id, name, kind, economic_role, population)
VALUES (?, ?, ?, ?, ?)""",
standalone_inserts,
)
n_inserted = len(standalone_inserts)
# Tenant-matching is a read-only SELECT (safe under dry_run too — a
# --dry-run run must still be able to REPORT the "no city to tenant on
# this body" case, not just skip detecting it; the WRITE is what's gated
# on dry_run, not the check).
n_linked = 0
n_unmatched = 0
unmatched_corps: list[str] = []
for corp_id, hq_body in tenant_candidates:
city_row = conn.execute(
"SELECT id FROM atlas_city_names WHERE body_id = ? ORDER BY id LIMIT 1",
(hq_body,),
).fetchone()
if city_row is None:
unmatched_corps.append(f"{corp_id} (no city on body '{hq_body}' to tenant)")
n_unmatched += 1
continue
if not dry_run:
conn.execute(
"UPDATE corporations SET headquarters_city_id = ? WHERE corp_id = ?",
(city_row[0], corp_id),
)
n_linked += 1
if unmatched_bodies:
print(f" warning: {len(unmatched_bodies)} corp(s) skipped (bad headquarters_body): "
f"{unmatched_bodies[:5]}")
if unmatched_corps:
print(f" warning: {len(unmatched_corps)} CityTenant corp(s) unmatched: "
f"{unmatched_corps[:5]}")
return {
"standalone_inserted": n_inserted,
"tenant_linked": n_linked,
"tenant_unmatched": n_unmatched,
}
@@ -345,6 +345,9 @@ COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37)
("system_economy", "economic_specialization", "TEXT"), # D-237 — authored specialization layer
("system_economy", "cultural_specialization", "TEXT"), # D-237 — authored specialization layer
("corporations", "corp_specialization", "TEXT"), # D-242 — HQ-placement key (reused D-237 vocab)
("corporations", "hq_placement", "TEXT"), # D-242 — 'CityTenant' | 'Standalone', baked at import
("corporations", "headquarters_city_id", "INTEGER REFERENCES atlas_city_names(id)"), # D-242 CityTenant link
]
@@ -13,9 +13,11 @@ from generator_sources import (
ARCHITECTURE_TRAIT_CATALOG_TOML,
ARCHITECTURE_ZONE_BIAS_TOML,
COLOR_REGISTER_BANDS_TOML,
CORP_HQ_PLACEMENT_TOML,
GENERATE_BRANDS_WRAPPER,
OBJECT_TAG_VOCABULARY_TOML,
REPO_ROOT,
SETTLEMENT_NAME_LOCKED_TOML,
SPECIALIZATION_VOCAB_TOML,
SYSTEM_SPECIALIZATION_TOML,
)
@@ -29,6 +31,7 @@ __all__ = [
"COLOR_REGISTER_BANDS_TOML",
"COMMODITIES_TOML",
"CORPORATIONS_DIR",
"CORP_HQ_PLACEMENT_TOML",
"CURRENCY_ZONES_TOML",
"DB_PATH",
"GENERATED_BRANDS_TOML",
@@ -36,6 +39,7 @@ __all__ = [
"OBJECT_TAG_VOCABULARY_TOML",
"REPO_ROOT",
"SCHEMA_SQL",
"SETTLEMENT_NAME_LOCKED_TOML",
"SPECIALIZATION_VOCAB_TOML",
"STAR_MAP",
"SYSTEM_SPECIALIZATION_TOML",
+40 -4
View File
@@ -9,7 +9,11 @@ Reads TOML/JSON source files and populates the economics tables:
- currency_zone on star_systems (default TRACTUS_PRIMARY)
- gate_energy_connected on star_systems (D-186: false for MARK_PRIMARY zones)
- corporations from wiki/corporations/*.md (sync + insert new records)
- corp_specialization/hq_placement/headquarters_body/headquarters_city_id
on corporations (D-242: HQ-placement key + baked
CityTenant link, from corp_hq_placement.toml)
- corp_presence from wiki/corporations/*.md (headquarters location data)
- atlas_city_names Standalone-HQ settlement rows (D-242, T-1074)
Validation (hard errors, non-zero exit on any failure):
- Wiki corporation names must match DB proper_name records (D-182 sync constraint)
@@ -178,6 +182,16 @@ def main() -> None:
n_db_corps = conn.execute("SELECT COUNT(*) FROM corporations").fetchone()[0]
print(f" {n_db_corps} corporations in DB ({len(wiki_corps)} from wiki)")
# 7b. Corp specialization + HQ placement (D-242, T-1074) — Phase A.
# Must run after corp sync (rows must exist) and before corp_presence
# (which reads headquarters_body back off corporations).
print(" [7b/10] Importing corp specialization + HQ placement (D-242)...")
corp_spec = corporations.populate_corp_specialization(conn, wiki_corps, args.dry_run)
print(
f" {corp_spec['specialized']}/{corp_spec['total']} corps specialized, "
f"{corp_spec['backfilled_hq']} headquarters_body backfilled"
)
# 8. Corp presence from wiki headquarters data
print(" [8/10] Importing corp presence...")
commodity_ids = {
@@ -208,10 +222,32 @@ def main() -> None:
n_cities = atlas.populate_atlas_city_names(conn, args.dry_run)
print(f" {n_cities} city name rows")
# 13. atlas_city_names corp HQ cross-reference (D-207, #909)
print(" [13/13] Cross-referencing corp HQ cities into atlas_city_names...")
n_updated, n_inserted = atlas.populate_atlas_city_names_corps(conn, args.dry_run)
print(f" {n_updated} rows updated, {n_inserted} reserved rows inserted")
# 13. Standalone-HQ settlements + CityTenant city links (D-242, T-1074) — Phase B.
# SUPERSEDES the retired corp-HQ cross-reference (D-207, #909) — that
# step inserted one atlas_city_names row per corp HQ with no
# UNIQUE(body_id, name), producing duplicate co-named "cities" (10
# Groombridge rows on GJ380c). Must run after populate_atlas_city_names
# (the city pool Standalone HQs join and CityTenant HQs tenant) and
# after step 7b (corp_specialization/hq_placement/headquarters_body).
print(" [13/13] Emitting Standalone-HQ settlements + CityTenant links (D-242)...")
hq_settlements = corporations.populate_standalone_hq_settlements(conn, args.dry_run)
print(
f" {hq_settlements['standalone_inserted']} Standalone-HQ settlements, "
f"{hq_settlements['tenant_linked']} CityTenant links "
f"({hq_settlements['tenant_unmatched']} unmatched)"
)
# 13b. Per-settlement population + settlement_class bake (D-242, T-1075).
# Runs over the CORRECTED pool — after both T-1074 steps, so
# Standalone-HQ settlements are included in the rank-size spread, not
# bolted on after.
print(" [13b/13] Baking settlement population + settlement_class (D-242)...")
pop_bake = atlas.populate_settlement_population_class(conn, args.dry_run)
print(
f" {pop_bake['cities_populated']} cities populated across "
f"{pop_bake['bodies_spread']} bodies, {pop_bake['name_locked_applied']} "
f"NameLocked pins applied"
)
# 14. Architecture-flavor trait templates (D-232, #993). Catalog first,
# then sparse per-body hero bias (FK -> trait_templates + bodies).
+12
View File
@@ -74,6 +74,16 @@ SPECIALIZATION_VOCAB_TOML: Path = (
SYSTEM_SPECIALIZATION_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "system_specialization.toml"
)
# D-242 corp-HQ settlement model (T-1074): specialization -> {CityTenant,
# Standalone} placement map, keyed on the reused D-237 vocabulary above.
CORP_HQ_PLACEMENT_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "corp_hq_placement.toml"
)
# D-242 population/settlement_class bake (T-1075): the small authored
# NameLocked hero-city override list.
SETTLEMENT_NAME_LOCKED_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "settlement_name_locked.toml"
)
# D-232 architecture-flavor trait-template catalog + sparse hero bias (#993).
ARCHITECTURE_TRAIT_CATALOG_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "architecture_trait_catalog.toml"
@@ -132,6 +142,8 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
REPO_ROOT / "tooling" / "schema_version.py",
SPECIALIZATION_VOCAB_TOML,
SYSTEM_SPECIALIZATION_TOML,
CORP_HQ_PLACEMENT_TOML,
SETTLEMENT_NAME_LOCKED_TOML,
ARCHITECTURE_TRAIT_CATALOG_TOML,
ARCHITECTURE_TRAIT_BIAS_TOML,
OBJECT_TAG_VOCABULARY_TOML,
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Populate corporations table from wiki data + Cygni B combines + DSMC
set -euo pipefail
DB="$(cd "$(dirname "$0")/.." && pwd)/server/data/systems.db"
sqlite3 "$DB" <<'SQL'
-- From wiki/corporations/
INSERT OR IGNORE INTO corporations VALUES ('gate-corporation', 'Gate Corporation', 'corporation', 'reach-wide', 'GJ 251', NULL, 'Span gate infrastructure', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('mvg', 'Mastroianni Vehicle Group', 'corporation', 'reach-wide', 'GJ 764', NULL, 'Transit vehicles, trains, haulers', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('prometheus-labs', 'Prometheus Labs', 'corporation', 'reach-wide', 'GJ 702B', NULL, 'Longevity hardware, neural augmentation', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('adams-ford', 'Adams & Ford Publishing', 'corporation', 'reach-wide', 'GJ 280A', NULL, 'The Drifters Guide to the Reach', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('mercado-travessia', 'Mercado Travessia', 'corporation', 'sector', 'GJ 1156', NULL, 'Grocery and household goods', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('stalownia-kowalski', 'Stalownia Kowalski', 'corporation', 'reach-wide', 'GJ 896A', NULL, 'Heavy mining and construction equipment', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('ferreira-monteiro', 'Ferreira Monteiro', 'corporation', 'reach-wide', 'GJ 884', NULL, 'Trade arbitration and commercial law', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('calloway-distillery', 'Calloway Distillery', 'corporation', 'sector', 'GJ 3325', NULL, 'Single malt whisky', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('thrds', 'thrds', 'corporation', 'sector', 'GJ 475', NULL, 'Cold-weather technical clothing', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('nordmark-skog', 'Nordmark Skog', 'corporation', 'sector', 'GJ 534', NULL, 'Timber and structural panels', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('talbrau', 'Talbräu', 'corporation', 'local', 'GJ 798', NULL, 'Franconian farmhouse lager', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('vins-de-grand-vide', 'Vins de Grand Vide', 'corporation', 'sector', 'GJ 395', NULL, 'Negociant wine cooperative', NULL, NULL, NULL);
-- DSMC from cultural-migration-pressure.md
INSERT OR IGNORE INTO corporations VALUES ('dsmc', 'Dorfhausen Sydney Mining Corporation', 'corporation', 'reach-wide', 'GJ 3522', NULL, 'Heavy extraction, mining infrastructure', NULL, NULL, NULL);
-- Cygni B combines (7 majors)
INSERT OR IGNORE INTO corporations VALUES ('vethara', 'Vethara Combine', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Primary hull fabrication, capital-class freight haulers', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('kossler-drun', 'Kossler-Drun', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Station module construction, habitat segments, docking architecture', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('amberan', 'Amberan Works', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Gate-adjacent platform fabrication', NULL, 'Deepest involvement in Institute design-rights dispute', NULL);
INSERT OR IGNORE INTO corporations VALUES ('talavec', 'Talavec & Sons', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Drive systems and propulsion assemblies', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('orren-skel', 'Orren-Skel', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Precision components and sensor integration', NULL, 'The combine everyone subcontracts to', NULL);
INSERT OR IGNORE INTO corporations VALUES ('marhoud', 'Marhoud Fabrication', 'combine', 'reach-wide', 'GJ 820B', NULL, 'Freight hauler production lines, volume manufacturing', NULL, NULL, NULL);
INSERT OR IGNORE INTO corporations VALUES ('drevast', 'Drevast Extraction & Processing', 'combine', 'system', 'GJ 820B', NULL, 'In-system ore extraction and feedstock processing', NULL, 'Controls feedstock supply the other six need', NULL);
SQL
echo "Populated $(sqlite3 "$DB" 'SELECT COUNT(*) FROM corporations') corporations"