feat(docs): add star map gate topology for 300 systems

300-node gate network with 334 edges, generated from seed config then
hand-balanced and tuned. Gateway (S-001) is the first hop from Earth
with 4 active + 1 dormant Sol aperture.

Final topology: 20% dead_end, 14% spur_end, 31% through_route,
10% loop_member, 19% junction, 6% hub. Frontier sectors have
distinct linear corridors; core is densely interconnected.

Includes generation pipeline (generate, sculpt, patch-core, tune),
seed config, and star-map-plan.md with algorithm documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 22:57:32 +01:00
co-authored by Claude Opus 4.6
parent 7da62369ab
commit ff9014ffc0
7 changed files with 9464 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
Hand-balance the core sector in star-map.json.
Adds 17 edges within the core sector to restore connectivity and topology diversity.
"""
import json
import sys
from collections import defaultdict
JSON_PATH = "/var/mnt/data/projects/settled-reach/planning/docs/design/star-map.json"
# ── New edges to add (all within core sector) ──────────────────────────────────
NEW_EDGES = [
["S-235", "S-067"], # 1: cross-connect two major cores
["S-235", "S-093"], # 2: S-235 reaches deg=5 (hub)
["S-235", "S-010"], # 3: S-235 hub spine extended
["S-067", "S-213"], # 4: S-213 exits dead_end
["S-067", "S-181"], # 5: S-067 reaches deg=5 (hub); S-181 exits dead_end
["S-067", "S-120"], # 6: S-067 hub — links to S-120
["S-263", "S-253"], # 7: connect two spur/through systems
["S-263", "S-139"], # 8: S-263 reaches junction
["S-091", "S-111"], # 9: connect two spur_ends
["S-253", "S-118"], # 10: S-118 exits dead_end
["S-139", "S-168"], # 11: S-168 exits dead_end
["S-027", "S-047"], # 12: connect two spur_ends
["S-093", "S-037"], # 13: S-037 exits dead_end
["S-120", "S-119"], # 14: S-119 exits dead_end
["S-074", "S-027"], # 15: S-074 exits dead_end
["S-114", "S-213"], # 16: S-114 exits dead_end; S-213 reaches junction
["S-111", "S-168"], # 17: S-168 promoted to junction; S-111 reaches junction
]
# ── Topology classification rules (by degree) ─────────────────────────────────
# Special override for S-001 (Gateway): always hub regardless of computed degree.
SPECIAL_HUBS = {"S-001"}
def classify_topology(system_id: str, degree: int) -> str:
if system_id in SPECIAL_HUBS:
return "hub"
if degree >= 5:
return "hub"
if degree == 3 or degree == 4:
return "junction"
if degree == 2:
# Use spur_end as default for deg-2; through_route is a subtype
# We preserve existing through_route classification for existing deg-2 nodes
# unless they change degree. If they stay at 2, they keep their type.
# This function only gets called when degree CHANGES.
return "spur_end"
if degree == 1:
return "dead_end"
# deg=0 should not occur
return "dead_end"
def aperture_for_connections(gate_connections: int, system_id: str) -> int:
"""
Compute aperture_count from gate_connections.
S-001 is special: 5 apertures (4 active Reach + 1 dormant Sol).
For all others: apertures = connections (one aperture per connection).
Aperture count has a cap of 8 per setting rules.
"""
if system_id == "S-001":
return 5 # never changes
return min(gate_connections, 8)
def main():
with open(JSON_PATH, "r") as f:
data = json.load(f)
nodes = data["nodes"]
edges = data["edges"]
# Build adjacency map to compute current degrees
degree = defaultdict(int)
edge_set = set()
for edge in edges:
a, b = edge[0], edge[1]
key = tuple(sorted([a, b]))
edge_set.add(key)
degree[a] += 1
degree[b] += 1
# Verify initial state of core nodes before patching
core_ids = {
"S-001", "S-010", "S-014", "S-027", "S-037", "S-047", "S-067",
"S-074", "S-091", "S-093", "S-111", "S-114", "S-118", "S-119",
"S-120", "S-139", "S-168", "S-181", "S-213", "S-235", "S-253",
"S-263", "S-265", "S-279", "S-297"
}
print("=== BEFORE: Core sector node degrees ===")
for nid in sorted(core_ids):
d = degree.get(nid, 0)
print(f" {nid}: deg={d}")
print()
# Check for duplicate edges in new list
duplicates = []
for edge in NEW_EDGES:
key = tuple(sorted(edge))
if key in edge_set:
duplicates.append(edge)
if duplicates:
print(f"WARNING: Duplicate edges (already exist): {duplicates}", file=sys.stderr)
# Add new edges
added = 0
for edge in NEW_EDGES:
key = tuple(sorted(edge))
if key not in edge_set:
edges.append(edge)
edge_set.add(key)
degree[edge[0]] += 1
degree[edge[1]] += 1
added += 1
else:
print(f" Skipping duplicate: {edge}")
print(f"Added {added} new edges (of {len(NEW_EDGES)} requested).")
print()
# Build node lookup by system_id
node_by_id = {n["system_id"]: n for n in nodes}
# Update topology/aperture/connections for all modified nodes
# We update ALL core nodes so the JSON is consistent.
print("=== Updating core node attributes ===")
# For S-001: keep hub, keep aperture=5, keep gate_connections=4 (special Gateway rule)
# For all others: update based on new degree.
# Track through_route nodes we want to preserve classification if degree stays 2
# (S-093 and S-253 and S-139 were through_route at deg=2; they're all getting upgraded,
# so this doesn't matter — they'll be junction now)
for nid in core_ids:
node = node_by_id.get(nid)
if node is None:
print(f" ERROR: {nid} not found in nodes!", file=sys.stderr)
continue
new_deg = degree.get(nid, 0)
if nid == "S-001":
# Special case: Gateway always hub, aperture=5, connections=4
new_topology = "hub"
new_connections = 4
new_apertures = 5
else:
new_topology = classify_topology(nid, new_deg)
new_connections = new_deg
new_apertures = aperture_for_connections(new_deg, nid)
old_topology = node["gate_topology"]
old_connections = node["gate_connections"]
if (old_topology != new_topology or
old_connections != new_connections):
print(f" {nid}: {old_topology}(c={old_connections}) → "
f"{new_topology}(c={new_connections}, ap={new_apertures})")
node["gate_topology"] = new_topology
node["gate_connections"] = new_connections
node["aperture_count"] = new_apertures
print()
# ── Overall distribution report ───────────────────────────────────────────
print("=== AFTER: Core sector topology breakdown ===")
topology_counts_core = defaultdict(int)
for nid in sorted(core_ids):
t = node_by_id[nid]["gate_topology"]
topology_counts_core[t] += 1
print(f" {nid}: deg={degree.get(nid,0)}, {t}")
print()
print("Core summary:")
for t, c in sorted(topology_counts_core.items()):
print(f" {t}: {c}")
print()
print("=== AFTER: Overall topology distribution ===")
topology_counts_all = defaultdict(int)
for node in nodes:
topology_counts_all[node["gate_topology"]] += 1
total = len(nodes)
for t, c in sorted(topology_counts_all.items()):
pct = 100.0 * c / total
print(f" {t}: {c} ({pct:.1f}%)")
print()
print(f"Total nodes: {total}")
print(f"Total edges: {len(edges)}")
print()
# Update meta
data["_meta"]["edge_count"] = len(edges)
data["_meta"]["core_balanced"] = "2026-03-13"
data["_meta"]["core_balance_note"] = (
"Core sector hand-balanced: +17 edges added, 25 core nodes reclassified. "
"3 hubs (Gateway + S-067 + S-235), 11 junctions, 8 spur_ends, 3 dead_ends."
)
# Write updated JSON
with open(JSON_PATH, "w") as f:
json.dump(data, f, indent=2)
print("Written to star-map.json.")
if __name__ == "__main__":
main()
+613
View File
@@ -0,0 +1,613 @@
#!/usr/bin/env python3
"""
Star Map Sculptor — The Settled Reach
Reads docs/design/star-map.json, adds/removes edges to correct topology
distribution, reclassifies all nodes, writes corrected JSON back.
Edges in star-map.json are stored as arrays: ["S-XXX", "S-YYY"]
Target distribution:
dead_end 20% ~60 systems (degree 1)
spur_end 15% ~45 systems (degree 2, not on cycle, one high-deg neighbour)
through_route 25% ~75 systems (degree 2, not on cycle, both neighbours low-deg)
loop_member 15% ~45 systems (degree 2, on a cycle)
junction 18% ~54 systems (degree 3-4)
hub 7% ~21 systems (degree 5+)
Strategy:
After generation produces a sparse graph (too many dead_ends, too few loops),
the sculptor:
Pass 1 — Add loop-forming edges to convert some dead_ends/through_routes
into loop_members. Prefer same-sector pairs at BFS distance 4-10.
Pass 2 — Strip excess edges from over-connected core sector.
Pass 3 — Strip excess edges from deep_frontier / outer band.
Pass 4 — General cleanup to a target edge count (~330 edges).
Fine-tune — Iterate if loop_member or dead_end counts are still off.
Standard library only. No external dependencies.
"""
from __future__ import annotations
import json
from collections import defaultdict, deque
from pathlib import Path
from typing import Optional
REPO_ROOT = Path(__file__).parent.parent
INPUT_JSON = REPO_ROOT / "docs" / "design" / "star-map.json"
OUTPUT_JSON = INPUT_JSON
TARGETS = {
"dead_end": 0.20,
"spur_end": 0.15,
"through_route": 0.25,
"loop_member": 0.15,
"junction": 0.18,
"hub": 0.07,
}
# ── Graph helpers ──────────────────────────────────────────────────────────────
def build_adjacency(edges: list[list]) -> dict[str, set[str]]:
adj: dict[str, set[str]] = defaultdict(set)
for e in edges:
a, b = e[0], e[1]
adj[a].add(b)
adj[b].add(a)
return adj
def is_connected(all_nodes: list[str], adj: dict[str, set[str]]) -> bool:
if not all_nodes:
return True
start = all_nodes[0]
visited = {start}
queue = deque([start])
while queue:
cur = queue.popleft()
for nb in adj.get(cur, set()):
if nb not in visited:
visited.add(nb)
queue.append(nb)
return len(visited) == len(all_nodes)
def find_bridges(all_nodes: list[str], adj: dict[str, set[str]]) -> set[frozenset]:
"""
Iterative Tarjan bridge-finding on undirected graph.
Returns set of frozenset({u, v}) for each bridge edge.
"""
n = len(all_nodes)
idx_map = {v: i for i, v in enumerate(all_nodes)}
disc = [-1] * n
low = [-1] * n
timer = [0]
bridges: set[frozenset] = set()
for start_node in all_nodes:
si = idx_map[start_node]
if disc[si] != -1:
continue
nbrs_start = sorted(adj.get(start_node, set()))
# Stack: (node, parent_or_None, sorted_neighbor_list, current_index)
stack: list[tuple[str, Optional[str], list[str], int]] = [
(start_node, None, nbrs_start, 0)
]
disc[si] = low[si] = timer[0]
timer[0] += 1
while stack:
node, par, nbr_list, ni = stack[-1]
node_i = idx_map[node]
if ni < len(nbr_list):
nb = nbr_list[ni]
stack[-1] = (node, par, nbr_list, ni + 1)
nb_i = idx_map[nb]
if disc[nb_i] == -1:
disc[nb_i] = low[nb_i] = timer[0]
timer[0] += 1
nb_nbrs = sorted(adj.get(nb, set()))
stack.append((nb, node, nb_nbrs, 0))
elif nb != par:
low[node_i] = min(low[node_i], disc[nb_i])
else:
stack.pop()
if par is not None:
par_i = idx_map[par]
low[par_i] = min(low[par_i], low[node_i])
if low[node_i] > disc[par_i]:
bridges.add(frozenset([par, node]))
return bridges
def find_cycle_members(all_nodes: list[str], adj: dict[str, set[str]]) -> set[str]:
"""
A node is on a cycle iff at least one of its incident edges is not a bridge.
We only need this for degree-2 nodes (loop_member vs through_route/spur_end).
"""
bridge_set = find_bridges(all_nodes, adj)
on_cycle: set[str] = set()
for node in all_nodes:
neighbors = adj.get(node, set())
if len(neighbors) < 2:
continue
non_bridge_count = sum(
1 for nb in neighbors
if frozenset([node, nb]) not in bridge_set
)
if non_bridge_count > 0:
on_cycle.add(node)
return on_cycle
def classify_node(
node_id: str,
adj: dict[str, set[str]],
cycle_members: set[str],
is_gateway: bool,
) -> str:
if is_gateway:
return "hub"
deg = len(adj.get(node_id, set()))
if deg <= 1:
return "dead_end"
if deg == 2:
if node_id in cycle_members:
return "loop_member"
neighbors = list(adj[node_id])
nb_degs = [len(adj.get(nb, set())) for nb in neighbors]
# spur_end: NOT on a cycle, and at least one neighbour is branching
# (degree >= 3). A spur hangs off a busier node — it doesn't require
# BOTH neighbours to be high-degree.
if any(d >= 3 for d in nb_degs):
return "spur_end"
return "through_route"
if deg <= 4:
return "junction"
return "hub"
def reclassify_all(nodes: list[dict], adj: dict[str, set[str]]) -> None:
all_ids = [n["system_id"] for n in nodes]
cycle_members = find_cycle_members(all_ids, adj)
for n in nodes:
nid = n["system_id"]
is_gw = n.get("is_gateway", False)
topo = classify_node(nid, adj, cycle_members, is_gw)
n["gate_topology"] = topo
deg = len(adj.get(nid, set()))
n["gate_connections"] = deg
if is_gw:
# Gateway: keep aperture_count=5 (4 active Reach + 1 dormant Sol)
n["aperture_count"] = max(5, deg)
elif topo == "hub":
# Hub: one spare aperture (max 8 per setting rules)
n["aperture_count"] = min(8, deg + 1)
else:
# All others: aperture == connections (no spare)
n["aperture_count"] = deg
def count_topology(nodes: list[dict], adj: dict[str, set[str]]) -> dict[str, int]:
all_ids = [n["system_id"] for n in nodes]
cycle_members = find_cycle_members(all_ids, adj)
counts: dict[str, int] = defaultdict(int)
for n in nodes:
t = classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False))
counts[t] += 1
return dict(counts)
# ── Edge removal ──────────────────────────────────────────────────────────────
def remove_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None:
adj[a].discard(b)
adj[b].discard(a)
edges[:] = [
e for e in edges
if not ((e[0] == a and e[1] == b) or (e[0] == b and e[1] == a))
]
def remove_edges_to_target(
nodes: list[dict],
edges: list,
adj: dict[str, set[str]],
target: int,
mode: str = "default",
) -> int:
"""
Remove up to `target` non-bridge edges using the given priority mode.
mode: "core" — only remove edges where both endpoints are in core sector
"frontier" — prefer deep_frontier and outer band
"default" — combined: core > frontier > outer > rest, weighted by degree
Returns: number of edges actually removed.
"""
node_map = {n["system_id"]: n for n in nodes}
all_ids = [n["system_id"] for n in nodes]
removed = 0
while removed < target:
bridge_set = find_bridges(all_ids, adj)
best_score: Optional[float] = None
best_a: Optional[str] = None
best_b: Optional[str] = None
for e in edges:
a, b = e[0], e[1]
key = frozenset([a, b])
if key in bridge_set:
continue
da = len(adj.get(a, set()))
db = len(adj.get(b, set()))
if da <= 1 or db <= 1:
continue
na = node_map.get(a, {})
nb_d = node_map.get(b, {})
if na.get("is_gateway") or nb_d.get("is_gateway"):
continue
sector_a = na.get("geographic_sector", "")
sector_b = nb_d.get("geographic_sector", "")
band_a = na.get("geographic_band", "")
band_b = nb_d.get("geographic_band", "")
if mode == "core":
if sector_a != "core" or sector_b != "core":
continue
score = -(da + db)
elif mode == "frontier":
is_frontier = sector_a == "deep_frontier" or sector_b == "deep_frontier"
is_outer = band_a == "outer" or band_b == "outer"
if not (is_frontier or is_outer):
continue
score = -(da + db)
if is_frontier:
score -= 100
else: # default
if sector_a == "core" and sector_b == "core":
base = -400
elif sector_a == "core" or sector_b == "core":
base = -200
elif sector_a == "deep_frontier" and sector_b == "deep_frontier":
base = -350
elif sector_a == "deep_frontier" or sector_b == "deep_frontier":
base = -175
elif band_a == "outer" and band_b == "outer":
base = -100
elif band_a == "outer" or band_b == "outer":
base = -50
else:
base = 0
score = base - (da + db)
if best_score is None or score < best_score:
best_score = score
best_a, best_b = a, b
if best_a is None:
print(f" [sculptor] No removable edges at {removed}/{target} (mode={mode})")
break
remove_edge(best_a, best_b, adj, edges)
removed += 1
return removed
# ── Edge addition ─────────────────────────────────────────────────────────────
def bfs_path_length(adj: dict[str, set[str]], start: str, end: str) -> int:
"""BFS shortest path length from start to end. Returns 9999 if unreachable."""
if start == end:
return 0
visited = {start}
queue = deque([(start, 0)])
while queue:
node, dist = queue.popleft()
for nb in adj.get(node, set()):
if nb == end:
return dist + 1
if nb not in visited:
visited.add(nb)
queue.append((nb, dist + 1))
return 9999
def add_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None:
adj[a].add(b)
adj[b].add(a)
edges.append([a, b])
def add_loop_edges(
nodes: list[dict],
edges: list,
adj: dict[str, set[str]],
target: int,
min_path: int = 4,
max_path: int = 10,
) -> int:
"""
Add up to `target` loop-forming edges.
Connects pairs of nodes that are already connected by a path of length
[min_path, max_path] — adding the shortcut edge creates a cycle, converting
nodes on that path to loop_members.
Prioritises same-sector pairs on inner/core bands.
Avoids gateway (S-001) and avoids edges that already exist.
Returns: number of edges added.
"""
node_map = {n["system_id"]: n for n in nodes}
all_ids = [n["system_id"] for n in nodes]
edges_set = {(e[0], e[1]) for e in edges} | {(e[1], e[0]) for e in edges}
added = 0
# Build candidate pairs: same sector, reasonable degree (not already hubs)
# and not already connected
candidates: list[tuple[str, str, int]] = [] # (a, b, score)
for i, na in enumerate(nodes):
a = na["system_id"]
if a == "S-001":
continue
da = len(adj.get(a, set()))
if da >= 5: # don't inflate hubs
continue
sector_a = na.get("geographic_sector", "")
band_a = na.get("geographic_band", "")
for nb in nodes[i + 1:]:
b = nb["system_id"]
if b == "S-001":
continue
if (a, b) in edges_set:
continue
db = len(adj.get(b, set()))
if db >= 5:
continue
sector_b = nb.get("geographic_sector", "")
band_b = nb.get("geographic_band", "")
# Must be same sector for clean loop topology
if sector_a != sector_b:
continue
# Score: prefer inner-band pairs
score = 0
if band_a in ("inner", "core"):
score += 1
if band_b in ("inner", "core"):
score += 1
candidates.append((a, b, score))
# Sort by score desc, then shuffle within score groups for variety
import random as _random
_random.shuffle(candidates)
candidates.sort(key=lambda x: -x[2])
for a, b, _ in candidates:
if added >= target:
break
# Check current path length — must be in [min_path, max_path]
# (if shorter, adding this edge just creates a tiny cycle; if longer,
# the loop created spans too many systems to classify cleanly)
if (a, b) in edges_set or (b, a) in edges_set:
continue
path_len = bfs_path_length(adj, a, b)
if min_path <= path_len <= max_path:
add_edge(a, b, adj, edges)
edges_set.add((a, b))
edges_set.add((b, a))
added += 1
return added
# ── Reporting ─────────────────────────────────────────────────────────────────
def print_distribution(counts: dict[str, int], total: int, label: str) -> None:
print(f"\n {label}:")
order = ["dead_end", "spur_end", "through_route", "loop_member", "junction", "hub"]
for t in order:
c = counts.get(t, 0)
pct = c / total * 100
tgt = TARGETS.get(t, 0) * 100
delta = pct - tgt
flag = " <<<" if abs(delta) > 4 else ""
print(f" {t:<14} {c:>4} ({pct:5.1f}%) target={tgt:.0f}% delta={delta:+.1f}%{flag}")
# ── Main ───────────────────────────────────────────────────────────────────────
def main() -> None:
print(f"Reading {INPUT_JSON} ...")
with open(INPUT_JSON) as f:
data = json.load(f)
nodes: list[dict] = data["nodes"]
edges: list = data["edges"]
total = len(nodes)
print(f" {total} nodes, {len(edges)} edges")
# Verify edge format
sample = edges[0]
assert isinstance(sample, list) and len(sample) == 2, \
f"Unexpected edge format: {sample}"
adj = build_adjacency(edges)
all_ids = [n["system_id"] for n in nodes]
assert is_connected(all_ids, adj), "ERROR: Input graph is not connected!"
print(" Connectivity: OK")
print_distribution(count_topology(nodes, adj), total, "BEFORE")
# ── Diagnostic: core sector ──────────────────────────────────────────────
node_map = {n["system_id"]: n for n in nodes}
core_nodes = [n for n in nodes if n.get("geographic_sector") == "core"]
core_degs = sorted(
[(n["system_id"], len(adj.get(n["system_id"], set()))) for n in core_nodes],
key=lambda x: -x[1]
)
print(f"\n Core sector: {len(core_nodes)} nodes")
print(" Top core degrees:")
for nid, deg in core_degs[:10]:
print(f" {nid}: deg={deg}")
# ── Pass 1: Add loop-forming edges ───────────────────────────────────────
# Generation now leaves the graph sparse (too many dead_ends, ~0 loops).
# Add edges that form clean cycles of length 4-10 within the same sector.
# Target: push loop_member count toward ~45 (15% of 300).
# We add more than strictly needed here because Pass 3/4 may remove some.
target_lm = int(TARGETS["loop_member"] * total) # 45
current_lm = count_topology(nodes, adj).get("loop_member", 0)
loops_to_add = max(0, target_lm - current_lm + 10) # +10 cushion
if loops_to_add > 0:
print(f"\n Pass 1: add loop-forming edges (target +{loops_to_add})...")
r1 = add_loop_edges(nodes, edges, adj, loops_to_add, min_path=4, max_path=10)
print(f" Added {r1}. Edges: {len(edges)}")
assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 1!"
reclassify_all(nodes, adj)
print_distribution(count_topology(nodes, adj), total, "After pass 1")
# ── Pass 2: Strip core sector ────────────────────────────────────────────
# Core hub count may be inflated. Strip non-bridge core edges to bring
# hub count to ~3 in core (Gateway + 2 others).
core_hubs = sum(
1 for n in nodes
if n.get("geographic_sector") == "core"
and len(adj.get(n["system_id"], set())) >= 5
)
core_strip = max(0, (core_hubs - 3) * 2) # rough: each hub removal ~2 edges
if core_strip > 0:
print(f"\n Pass 2: core sector strip (target -{core_strip} edges)...")
r2 = remove_edges_to_target(nodes, edges, adj, core_strip, mode="core")
print(f" Removed {r2}. Edges: {len(edges)}")
assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 2!"
reclassify_all(nodes, adj)
print_distribution(count_topology(nodes, adj), total, "After pass 2")
# ── Pass 3: Frontier and outer strip ─────────────────────────────────────
# Deep frontier should be sparse. Remove excess connections there.
frontier_hubs = sum(
1 for n in nodes
if n.get("geographic_sector") == "deep_frontier"
and len(adj.get(n["system_id"], set())) >= 5
)
frontier_strip = frontier_hubs * 2
if frontier_strip > 0:
print(f"\n Pass 3: frontier/outer strip (target -{frontier_strip} edges)...")
r3 = remove_edges_to_target(nodes, edges, adj, frontier_strip, mode="frontier")
print(f" Removed {r3}. Edges: {len(edges)}")
assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 3!"
reclassify_all(nodes, adj)
print_distribution(count_topology(nodes, adj), total, "After pass 3")
# ── Pass 4: General cleanup to target edge count ─────────────────────────
# Target ~330 edges for 300 nodes gives an average degree of 2.2,
# consistent with the target distribution (lots of dead_ends + through_routes,
# moderate loops, fewer junctions/hubs).
target_edges = 330
current_edges = len(edges)
if current_edges > target_edges:
need = current_edges - target_edges
print(f"\n Pass 4: general cleanup (target -{need} edges to reach {target_edges})...")
r4 = remove_edges_to_target(nodes, edges, adj, need, mode="default")
print(f" Removed {r4}. Edges: {len(edges)}")
assert is_connected(all_ids, adj), "ERROR: Disconnected after pass 4!"
reclassify_all(nodes, adj)
c4 = count_topology(nodes, adj)
print_distribution(c4, total, "After pass 4")
else:
reclassify_all(nodes, adj)
c4 = count_topology(nodes, adj)
# ── Fine-tune: loop_member deficit? Add more loops ────────────────────────
lm_now = c4.get("loop_member", 0)
tolerance = int(total * 0.04) # 4pp = 12 systems
if lm_now < target_lm - tolerance:
deficit = target_lm - lm_now
print(f"\n Fine-tune A: {lm_now} loop_members < target {target_lm}, +{deficit} edges...")
add_loop_edges(nodes, edges, adj, deficit, min_path=4, max_path=12)
assert is_connected(all_ids, adj), "ERROR: Disconnected after fine-tune A!"
reclassify_all(nodes, adj)
cf = count_topology(nodes, adj)
print_distribution(cf, total, "After fine-tune A")
c4 = cf
# ── Fine-tune: too many loop_members? Strip more ──────────────────────────
lm_now = c4.get("loop_member", 0)
if lm_now > target_lm + tolerance:
extra = min((lm_now - target_lm) // 3, 20)
print(f"\n Fine-tune B: {lm_now} loop_members > target {target_lm}, -{extra} edges...")
remove_edges_to_target(nodes, edges, adj, extra, mode="default")
assert is_connected(all_ids, adj), "ERROR: Disconnected after fine-tune B!"
reclassify_all(nodes, adj)
cf = count_topology(nodes, adj)
print_distribution(cf, total, "After fine-tune B")
# ── Update metadata ──────────────────────────────────────────────────────
data["_meta"]["edge_count"] = len(edges)
data["_meta"]["sculpted"] = "2026-03-13"
data["_meta"]["sculpt_note"] = (
"Sculpted topology: loop edges added, excess core/frontier edges removed. "
"Reclassified all nodes. Aperture counts updated."
)
print(f"\n Writing {OUTPUT_JSON} ...")
with open(OUTPUT_JSON, "w") as f:
json.dump(data, f, indent=2)
print(" Written.")
# ── Final report ─────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("FINAL TOPOLOGY SUMMARY")
print("=" * 60)
final = count_topology(nodes, adj)
print_distribution(final, total, "Final distribution")
print(f"\n Nodes: {total} Edges: {len(edges)}")
print("\n Breakdown by sector:")
st: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for n in nodes:
st[n.get("geographic_sector", "?")][n["gate_topology"]] += 1
for sec in sorted(st.keys()):
sd = st[sec]
n_in = sum(sd.values())
parts = " ".join(f"{t}={c}" for t, c in sorted(sd.items()))
print(f" {sec:<15} n={n_in:>3} {parts}")
print("\n Core sector final degrees:")
core_final = [
(n["system_id"], len(adj.get(n["system_id"], set())), n["gate_topology"])
for n in nodes if n.get("geographic_sector") == "core"
]
core_final.sort(key=lambda x: -x[1])
for nid, deg, topo in core_final:
print(f" {nid}: deg={deg} {topo}")
# Verify aperture >= connections for all nodes
violations = [
n for n in nodes
if n.get("aperture_count", 0) < n.get("gate_connections", 0)
]
if violations:
print(f"\n WARNING: {len(violations)} nodes have aperture < connections!")
for v in violations:
print(f" {v['system_id']}: aperture={v['aperture_count']} < connections={v['gate_connections']}")
else:
print("\n Aperture >= connections: OK (all nodes)")
if __name__ == "__main__":
main()
+185
View File
@@ -0,0 +1,185 @@
{
"_comment": "Star map generation seed configuration. All 300 systems in this map have horizon stations — systems without stations are simply not included. The ~2500 other nearby stars without stations exist in-setting but are not graph nodes.",
"random_seed": 20260313,
"system_count": 300,
"sector_distribution": {
"core": 25,
"north_reach": 50,
"west_reach": 50,
"south_reach": 50,
"east_reach": 50,
"deep_frontier": 75
},
"_comment_deep_frontier": "deep_frontier is larger than any single cardinal sector because it wraps the outer edge of all four cardinal directions. The extra 25 systems (vs 50 per cardinal) represent the scattered outer activation edge.",
"gateway": {
"_comment": "The Gateway system. Pre-seeded. First hop from Earth. Former Grand Central, now institutional/surveillance. Assembly capital is elsewhere.",
"system_id": "S-001",
"geographic_sector": "core",
"geographic_band": "core",
"political_zone": "diplomatic_periphery",
"gate_topology": "hub",
"aperture_count": 5,
"gate_connections": 4,
"_comment_apertures": "5 apertures total: 4 active connections into the Reach + 1 dormant Sol-facing aperture. gate_connections = 4 (Sol aperture is not a traversable connection).",
"settlement_wave": "wave_1",
"is_gateway": true,
"pre_placed": true
},
"topology_targets": {
"_comment": "Target percentage distribution. Checked during Phase 6 validation. Tolerance: +/- 3 percentage points per category.",
"dead_end": 0.20,
"spur_end": 0.15,
"through_route": 0.25,
"loop_member": 0.15,
"junction": 0.18,
"hub": 0.07
},
"hub_count_targets": {
"_comment": "Hub count per sector. Algorithm targets exactly this many hubs per sector during augmentation. Deep frontier is sparse — 0 forced hubs; organic hubs may still arise from cross-sector bridge augmentation.",
"core": 2,
"north_reach": 2,
"west_reach": 2,
"south_reach": 2,
"east_reach": 2,
"deep_frontier": 0
},
"cross_sector_connection_targets": {
"_comment": "Target number of connections crossing each sector boundary. These become chokepoints.",
"core_to_north": 3,
"core_to_west": 3,
"core_to_south": 3,
"core_to_east": 3,
"north_to_deep_frontier": 2,
"west_to_deep_frontier": 2,
"south_to_deep_frontier": 2,
"east_to_deep_frontier": 2,
"north_to_west": 1,
"west_to_south": 1,
"south_to_east": 1,
"east_to_north": 1
},
"star_type_distribution": {
"_comment": "Spectral type targets. M-type dwarfs are most common. Percentages sum to 1.0.",
"M": 0.47,
"K": 0.27,
"G": 0.13,
"F": 0.06,
"binary": 0.04,
"unusual": 0.03
},
"settlement_wave_by_sector": {
"_comment": "Wave probability weights per sector. Core = Wave 1-2 heavy. Deep frontier = Wave 4-5 heavy. With variation.",
"core": {
"wave_1": 0.45,
"wave_2": 0.35,
"wave_3": 0.12,
"wave_4": 0.05,
"wave_5": 0.02,
"unsettled": 0.01
},
"north_reach": {
"wave_1": 0.05,
"wave_2": 0.25,
"wave_3": 0.35,
"wave_4": 0.20,
"wave_5": 0.10,
"unsettled": 0.05
},
"west_reach": {
"wave_1": 0.05,
"wave_2": 0.25,
"wave_3": 0.35,
"wave_4": 0.20,
"wave_5": 0.10,
"unsettled": 0.05
},
"south_reach": {
"wave_1": 0.05,
"wave_2": 0.25,
"wave_3": 0.35,
"wave_4": 0.20,
"wave_5": 0.10,
"unsettled": 0.05
},
"east_reach": {
"wave_1": 0.05,
"wave_2": 0.25,
"wave_3": 0.35,
"wave_4": 0.20,
"wave_5": 0.10,
"unsettled": 0.05
},
"deep_frontier": {
"wave_1": 0.01,
"wave_2": 0.05,
"wave_3": 0.15,
"wave_4": 0.35,
"wave_5": 0.30,
"unsettled": 0.14
}
},
"geographic_band_by_sector": {
"_comment": "Band assignment probabilities per sector. Core uses 'core' band only. Cardinal sectors split inner/outer.",
"core": {
"core": 1.0
},
"north_reach": {
"inner": 0.55,
"outer": 0.45
},
"west_reach": {
"inner": 0.55,
"outer": 0.45
},
"south_reach": {
"inner": 0.55,
"outer": 0.45
},
"east_reach": {
"inner": 0.55,
"outer": 0.45
},
"deep_frontier": {
"inner": 0.25,
"outer": 0.75
}
},
"augmentation_weights": {
"_comment": "Tuning parameters for Phase 3 augmentation passes.",
"hub_target_degree_min": 5,
"hub_target_degree_max": 6,
"junction_target_degree_min": 3,
"junction_target_degree_max": 3,
"loop_formation_target_count": 20,
"loop_min_path_length": 3,
"loop_max_path_length": 8,
"cross_sector_weight_penalty": 0.3,
"inner_to_inner_weight_bonus": 0.4,
"outer_to_inner_weight_bonus": 0.3,
"unused_aperture_probability": 0.15
},
"validation_tolerances": {
"topology_target_tolerance_pct": 0.06,
"min_hubs_total": 15,
"max_hubs_pct": 0.12,
"dead_end_plus_spur_min_pct": 0.28,
"dead_end_plus_spur_max_pct": 0.45,
"gateway_min_connections": 4,
"gateway_max_connections": 5,
"earth_proximity_immediate_max": 20,
"earth_proximity_proximate_max_hops": 10
}
}
+901
View File
@@ -0,0 +1,901 @@
#!/usr/bin/env python3
"""
Star Map Topology Tuner — The Settled Reach
Starting from the current map (300 nodes, 347 edges):
Current vs Target:
dead_end 12.7% (38) → 20% (60) +22
spur_end 13.0% (39) → 15% (45) +6
through_route 29.3% (88) → 25% (75) -13
loop_member 17.7% (53) → 15% (45) -8
junction 23.3% (70) → 18% (54) -16
hub 4.0% (12) → 7% (21) +9
Strategy:
Pass A — Promote junctions→hubs: add edges to degree-4 junctions in core/inner
to push them to degree 5+. Target: +9 hubs.
Pass B — Increase dead_ends: remove non-bridge edges from degree-2+ nodes
in deep_frontier/outer to reduce their degree to 1. Target: +22 dead_ends.
Pass C — Reduce loop_members and through_routes: remove non-bridge edges
from loop_member and through_route nodes in frontier sectors.
Pass D — Final cleanup: reduce remaining junctions by stripping edges.
Constraints:
- Graph must remain fully connected (checked after each pass)
- S-001 (Gateway): degree=4 locked, gate_topology="hub", aperture_count=5
- Max 8 apertures per node (D-095)
- Targets are guidelines (±2-3%), not hard constraints
Standard library only. No external dependencies.
"""
from __future__ import annotations
import json
from collections import defaultdict, deque
from pathlib import Path
from typing import Optional
REPO_ROOT = Path(__file__).parent.parent
INPUT_JSON = REPO_ROOT / "docs" / "design" / "star-map.json"
OUTPUT_JSON = INPUT_JSON
TARGETS = {
"dead_end": 0.20,
"spur_end": 0.15,
"through_route": 0.25,
"loop_member": 0.15,
"junction": 0.18,
"hub": 0.07,
}
GATEWAY_ID = "S-001"
# ── Graph helpers ──────────────────────────────────────────────────────────────
def build_adjacency(edges: list) -> dict[str, set[str]]:
adj: dict[str, set[str]] = defaultdict(set)
for e in edges:
a, b = e[0], e[1]
adj[a].add(b)
adj[b].add(a)
return adj
def is_connected(all_nodes: list[str], adj: dict[str, set[str]]) -> bool:
if not all_nodes:
return True
start = all_nodes[0]
visited = {start}
queue = deque([start])
while queue:
cur = queue.popleft()
for nb in adj.get(cur, set()):
if nb not in visited:
visited.add(nb)
queue.append(nb)
return len(visited) == len(all_nodes)
def find_bridges(all_nodes: list[str], adj: dict[str, set[str]]) -> set[frozenset]:
"""Iterative Tarjan bridge-finding. Returns set of frozenset({u, v})."""
n = len(all_nodes)
idx_map = {v: i for i, v in enumerate(all_nodes)}
disc = [-1] * n
low = [-1] * n
timer = [0]
bridges: set[frozenset] = set()
for start_node in all_nodes:
si = idx_map[start_node]
if disc[si] != -1:
continue
nbrs_start = sorted(adj.get(start_node, set()))
stack: list[tuple[str, Optional[str], list[str], int]] = [
(start_node, None, nbrs_start, 0)
]
disc[si] = low[si] = timer[0]
timer[0] += 1
while stack:
node, par, nbr_list, ni = stack[-1]
node_i = idx_map[node]
if ni < len(nbr_list):
nb = nbr_list[ni]
stack[-1] = (node, par, nbr_list, ni + 1)
nb_i = idx_map[nb]
if disc[nb_i] == -1:
disc[nb_i] = low[nb_i] = timer[0]
timer[0] += 1
nb_nbrs = sorted(adj.get(nb, set()))
stack.append((nb, node, nb_nbrs, 0))
elif nb != par:
low[node_i] = min(low[node_i], disc[nb_i])
else:
stack.pop()
if par is not None:
par_i = idx_map[par]
low[par_i] = min(low[par_i], low[node_i])
if low[node_i] > disc[par_i]:
bridges.add(frozenset([par, node]))
return bridges
def find_cycle_members(all_nodes: list[str], adj: dict[str, set[str]]) -> set[str]:
bridge_set = find_bridges(all_nodes, adj)
on_cycle: set[str] = set()
for node in all_nodes:
neighbors = adj.get(node, set())
if len(neighbors) < 2:
continue
non_bridge_count = sum(
1 for nb in neighbors
if frozenset([node, nb]) not in bridge_set
)
if non_bridge_count > 0:
on_cycle.add(node)
return on_cycle
def classify_node(
node_id: str,
adj: dict[str, set[str]],
cycle_members: set[str],
is_gateway: bool,
) -> str:
if is_gateway:
return "hub"
deg = len(adj.get(node_id, set()))
if deg <= 1:
return "dead_end"
if deg == 2:
if node_id in cycle_members:
return "loop_member"
neighbors = list(adj[node_id])
nb_degs = [len(adj.get(nb, set())) for nb in neighbors]
if any(d >= 3 for d in nb_degs):
return "spur_end"
return "through_route"
if deg <= 4:
return "junction"
return "hub"
def reclassify_all(nodes: list[dict], adj: dict[str, set[str]]) -> None:
all_ids = [n["system_id"] for n in nodes]
cycle_members = find_cycle_members(all_ids, adj)
for n in nodes:
nid = n["system_id"]
is_gw = n.get("is_gateway", False)
topo = classify_node(nid, adj, cycle_members, is_gw)
n["gate_topology"] = topo
deg = len(adj.get(nid, set()))
n["gate_connections"] = deg
if is_gw:
n["aperture_count"] = max(5, deg) # +1 for dormant Sol aperture
else:
n["aperture_count"] = deg
def count_topology(nodes: list[dict], adj: dict[str, set[str]]) -> dict[str, int]:
all_ids = [n["system_id"] for n in nodes]
cycle_members = find_cycle_members(all_ids, adj)
counts: dict[str, int] = defaultdict(int)
for n in nodes:
t = classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False))
counts[t] += 1
return dict(counts)
def remove_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None:
adj[a].discard(b)
adj[b].discard(a)
edges[:] = [
e for e in edges
if not ((e[0] == a and e[1] == b) or (e[0] == b and e[1] == a))
]
def add_edge(a: str, b: str, adj: dict[str, set[str]], edges: list) -> None:
adj[a].add(b)
adj[b].add(a)
edges.append([a, b])
def print_distribution(counts: dict[str, int], total: int, label: str) -> None:
print(f"\n {label}:")
order = ["dead_end", "spur_end", "through_route", "loop_member", "junction", "hub"]
for t in order:
c = counts.get(t, 0)
pct = c / total * 100
tgt = TARGETS.get(t, 0) * 100
delta = pct - tgt
flag = " <<<" if abs(delta) > 4 else ""
print(f" {t:<14} {c:>4} ({pct:5.1f}%) target={tgt:.0f}% delta={delta:+.1f}%{flag}")
# ── Pass A: Promote junctions to hubs by adding edges ─────────────────────────
def promote_junctions_to_hubs(
nodes: list[dict],
edges: list,
adj: dict[str, set[str]],
target_hub_count: int,
) -> int:
"""
Find degree-4 junctions in core/north_reach/east_reach/south_reach/west_reach
inner bands, and add edges to push them to degree 5 (hub).
Strategy: for each candidate hub-target, find another degree-3 or degree-4
node in the same sector that is NOT already connected to it, with BFS
distance 2-6. Prefer nodes whose degree would become 4 (stay junction)
rather than going to 5 themselves.
Returns number of hub promotions achieved.
"""
node_map = {n["system_id"]: n for n in nodes}
all_ids = [n["system_id"] for n in nodes]
edges_set = {frozenset(e[:2]) for e in edges}
# Count current hubs
cycle_members = find_cycle_members(all_ids, adj)
current_hubs = sum(
1 for n in nodes
if classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False)) == "hub"
)
hubs_needed = target_hub_count - current_hubs
if hubs_needed <= 0:
print(f" Pass A: already at {current_hubs} hubs, target={target_hub_count}. Skipping.")
return 0
print(f" Pass A: promoting junctions to hubs. Need {hubs_needed} more hubs.")
promoted = 0
# Priority sectors for hub promotion
priority_sectors = {"core", "north_reach", "east_reach", "south_reach", "west_reach"}
# Priority bands: inner > mid > outer
band_rank = {"inner": 3, "core": 3, "mid": 2, "outer": 1}
# Find all degree-4 junctions in priority sectors (potential hub candidates)
candidates = []
for n in nodes:
nid = n["system_id"]
if nid == GATEWAY_ID:
continue
deg = len(adj.get(nid, set()))
if deg != 4:
continue
sector = n.get("geographic_sector", "")
if sector not in priority_sectors:
continue
band = n.get("geographic_band", "")
br = band_rank.get(band, 0)
candidates.append((nid, sector, br, deg))
# Sort by band rank desc (prefer inner/core band nodes)
candidates.sort(key=lambda x: -x[2])
for (cand_id, cand_sector, cand_br, _) in candidates:
if promoted >= hubs_needed:
break
# Find a suitable neighbor to connect to
# Must be: same sector, not already connected, degree <= 4 (won't become hub itself),
# not gateway, BFS distance 2-6
current_deg = len(adj.get(cand_id, set()))
if current_deg >= 5:
# Already became a hub from a previous promotion
continue
best_partner = None
best_partner_score = -999
for n2 in nodes:
n2id = n2["system_id"]
if n2id == GATEWAY_ID or n2id == cand_id:
continue
if frozenset([cand_id, n2id]) in edges_set:
continue
deg2 = len(adj.get(n2id, set()))
# Don't connect if it would push n2 over 8 apertures
if deg2 >= 8:
continue
# Prefer same sector, allow adjacent sectors
sector2 = n2.get("geographic_sector", "")
if sector2 != cand_sector:
continue
band2 = n2.get("geographic_band", "")
# Prefer connecting to a junction (deg 3-4) so it stays junction
# rather than becoming a hub itself
if deg2 >= 5:
continue # skip — would just inflate an existing hub
# BFS distance check — avoid trivially short connections
# (we don't want to make a multi-edge or a triangle that kills topology)
# We do a lightweight BFS for this check
bfs_dist = _bfs_dist(adj, cand_id, n2id)
if bfs_dist < 2 or bfs_dist > 8:
continue
# Score: prefer closer BFS distance (but not adjacent), prefer same band
br2 = band_rank.get(band2, 0)
score = br2 * 10 - abs(bfs_dist - 3)
if score > best_partner_score:
best_partner_score = score
best_partner = n2id
if best_partner is None:
print(f" {cand_id}: no suitable partner found, skipping")
continue
# Add the edge
add_edge(cand_id, best_partner, adj, edges)
edges_set.add(frozenset([cand_id, best_partner]))
promoted += 1
new_deg = len(adj.get(cand_id, set()))
new_deg2 = len(adj.get(best_partner, set()))
print(f" +edge {cand_id}(deg {new_deg}) <-> {best_partner}(deg {new_deg2})")
assert is_connected(all_ids, adj), "ERROR: Disconnected after Pass A!"
return promoted
def _bfs_dist(adj: dict[str, set[str]], start: str, end: str) -> int:
if start == end:
return 0
visited = {start}
queue = deque([(start, 0)])
while queue:
node, dist = queue.popleft()
if dist >= 9:
return 9999
for nb in adj.get(node, set()):
if nb == end:
return dist + 1
if nb not in visited:
visited.add(nb)
queue.append((nb, dist + 1))
return 9999
# ── Pass B: Create dead_ends by removing edges ────────────────────────────────
def create_dead_ends(
nodes: list[dict],
edges: list,
adj: dict[str, set[str]],
target_dead_end_count: int,
all_ids: list[str],
) -> int:
"""
Remove edges to increase dead_end count toward target.
For each edge removal, we look for a degree-2 node in deep_frontier/outer
where one of its incident edges is NOT a bridge AND removing it leaves
that node at degree 1 (dead_end). We do this carefully:
- The node being demoted: must have degree 2 now (after removal: degree 1 = dead_end)
- The removed edge must NOT be a bridge (so graph stays connected)
OR: it IS a bridge but the other endpoint has degree >= 3 so removing it
just splits off a dead_end leaf, which is fine (the leaf stays connected
to the rest via its remaining edge... wait, no — a bridge removal always
disconnects). So we must only remove non-bridges OR handle the special
case where the node being made dead_end has degree 2 and removing ONE
edge keeps the remaining edge still connecting it to the graph.
Actually: for a degree-2 node, BOTH its edges are bridges (removing either
disconnects the portion of the graph accessible only through that node's
chain). So we need a different approach:
For a degree-3 node: it has 3 edges. If at least one is NOT a bridge,
we can remove it — the node drops to degree 2 (becomes spur_end/through_route).
That's not a dead_end directly.
Better approach: find degree-2 nodes (spur_ends or through_routes) where
one edge IS technically "safe" to remove — meaning the other end of that
edge has degree >= 3, so after removal the graph remains connected
(the degree-2 node becomes a dead_end hanging off its one remaining neighbor,
which still has degree >= 2 connecting it to the rest of the graph).
Wait — if (A-B-C) and B has degree 2, edge A-B and B-C both connect B.
Removing A-B: B becomes dead_end (degree 1, connected via B-C). A drops by 1.
The graph remains connected AS LONG AS A is still connected to the rest.
A is connected to the rest via its other edges (since A had degree >= 2 and
we only removed one edge — A must have degree >= 2 so after removal A has
degree >= 1). If A had degree exactly 2, it now has degree 1 — and A is now
a dead_end too! That might be acceptable, but let's prefer A has degree >= 3
so A stays as junction/hub.
So the rule: pick a node B with degree 2. Find which of B's two neighbors
(call it A) has degree >= 3. Remove edge A-B. B becomes dead_end, A stays
junction/hub. Graph remains connected.
This always works and never disconnects the graph.
Returns: number of dead_ends created.
"""
node_map = {n["system_id"]: n for n in nodes}
# Priority sectors: deep_frontier, then outer band
def node_priority(nid: str) -> int:
n = node_map.get(nid, {})
sector = n.get("geographic_sector", "")
band = n.get("geographic_band", "")
if sector == "deep_frontier":
return 3
if band == "outer":
return 2
if band == "mid":
return 1
return 0
created = 0
# Count current dead_ends
cycle_members = find_cycle_members(all_ids, adj)
current_de = sum(
1 for n in nodes
if classify_node(n["system_id"], adj, cycle_members, n.get("is_gateway", False)) == "dead_end"
)
needed = target_dead_end_count - current_de
if needed <= 0:
print(f" Pass B: already at {current_de} dead_ends, target={target_dead_end_count}. Skipping.")
return 0
print(f" Pass B: creating dead_ends. Need {needed} more.")
max_iterations = needed * 8 # safety limit
iteration = 0
skipped = set() # edges that would disconnect the graph
while created < needed and iteration < max_iterations:
iteration += 1
# Find degree-2 nodes (not gateway) with at least one neighbor of degree >= 3
best_node = None
best_removable_neighbor = None
best_score = -1
for n in nodes:
nid = n["system_id"]
if nid == GATEWAY_ID:
continue
deg = len(adj.get(nid, set()))
if deg != 2:
continue
# Check neighbors — find one with deg >= 3 to remove edge toward
neighbors = list(adj[nid])
# The node being demoted (nid) will keep its OTHER neighbor as its
# sole connection. That other neighbor must have degree >= 2 (i.e.,
# it has at least one other connection besides nid) so the remaining
# B-C subgraph stays reachable from the rest of the graph.
for nb in neighbors:
nb_deg = len(adj.get(nb, set()))
if nb_deg < 3 or nb == GATEWAY_ID:
continue
if (nid, nb) in skipped or (nb, nid) in skipped:
continue
# The remaining neighbor (the one we do NOT remove) must have deg >= 2
other_neighbors = [x for x in neighbors if x != nb]
if not other_neighbors:
continue
other_nb = other_neighbors[0]
other_deg = len(adj.get(other_nb, set()))
if other_deg < 2:
# other_nb is already a dead_end; removing nb-nid would leave
# nid and other_nb disconnected from the rest of the graph.
continue
# Safe: remove edge nid-nb. nid becomes dead_end, graph stays connected.
score = node_priority(nid) * 10 + node_priority(nb) + nb_deg
if score > best_score:
best_score = score
best_node = nid
best_removable_neighbor = nb
if best_node is None:
print(f" No more suitable degree-2 nodes found after {created} dead_ends created.")
break
remove_edge(best_node, best_removable_neighbor, adj, edges)
# Check connectivity after every removal — rollback if disconnected
if not is_connected(all_ids, adj):
# Rollback
adj[best_node].add(best_removable_neighbor)
adj[best_removable_neighbor].add(best_node)
edges.append([best_node, best_removable_neighbor])
skipped.add((best_node, best_removable_neighbor))
continue
created += 1
new_deg = len(adj.get(best_node, set()))
print(f" -edge {best_node}(now deg {new_deg}) <- {best_removable_neighbor}")
return created
# ── Pass C: Reduce loop_members and through_routes ────────────────────────────
def reduce_degree2_excess(
nodes: list[dict],
edges: list,
adj: dict[str, set[str]],
all_ids: list[str],
target_lm: int,
target_tr: int,
) -> int:
"""
Reduce loop_member and through_route counts toward targets.
Method: find loop_member or through_route (degree-2) nodes in frontier/outer
sectors where one neighbor has degree >= 3. Remove the edge to that
high-degree neighbor, converting the degree-2 node to a dead_end.
This simultaneously reduces loop_member/through_route AND increases dead_end.
We stop when both LM and TR are within tolerance of targets, or we've used
our budget.
Note: we may already be creating dead_ends in Pass B, so this pass focuses
on reducing excess degree-2 nodes that are loop_members or through_routes.
"""
node_map = {n["system_id"]: n for n in nodes}
total = len(nodes)
tolerance = int(total * 0.03)
def node_priority(nid: str) -> int:
n = node_map.get(nid, {})
sector = n.get("geographic_sector", "")
band = n.get("geographic_band", "")
if sector == "deep_frontier":
return 3
if band == "outer":
return 2
if band == "mid":
return 1
return 0
removed = 0
max_budget = 50 # enough budget for up to 50 reductions
for _ in range(max_budget):
cycle_members = find_cycle_members(all_ids, adj)
counts = {
t: sum(1 for n in nodes if classify_node(
n["system_id"], adj, cycle_members, n.get("is_gateway", False)
) == t)
for t in ["loop_member", "through_route", "dead_end"]
}
lm_now = counts["loop_member"]
tr_now = counts["through_route"]
lm_ok = lm_now <= target_lm + tolerance
tr_ok = tr_now <= target_tr + tolerance
if lm_ok and tr_ok:
print(f" Pass C done: LM={lm_now} TR={tr_now} within tolerance.")
break
# Find a good candidate to demote
best_node = None
best_nb = None
best_score = -1
for n in nodes:
nid = n["system_id"]
if nid == GATEWAY_ID:
continue
deg = len(adj.get(nid, set()))
if deg != 2:
continue
# Is this node a loop_member or through_route?
topo = classify_node(nid, adj, cycle_members, n.get("is_gateway", False))
if topo not in ("loop_member", "through_route"):
continue
# Check if we still need to reduce this type
if topo == "loop_member" and lm_ok:
continue
if topo == "through_route" and tr_ok:
continue
# Find a neighbor with degree >= 3, AND the OTHER neighbor still
# has degree >= 2 after removal (so nid-other stays connected to graph)
neighbors_list = list(adj[nid])
for nb in neighbors_list:
nb_deg = len(adj.get(nb, set()))
if nb_deg < 3 or nb == GATEWAY_ID:
continue
others = [x for x in neighbors_list if x != nb]
if not others:
continue
other_deg = len(adj.get(others[0], set()))
if other_deg < 2:
continue # would strand nid with only a dead_end neighbor
score = node_priority(nid) * 10 + nb_deg
if score > best_score:
best_score = score
best_node = nid
best_nb = nb
if best_node is None:
print(f" Pass C: no more candidates (LM={lm_now}, TR={tr_now}).")
break
remove_edge(best_node, best_nb, adj, edges)
if not is_connected(all_ids, adj):
# Rollback
adj[best_node].add(best_nb)
adj[best_nb].add(best_node)
edges.append([best_node, best_nb])
continue
removed += 1
print(f" -edge {best_node} <- {best_nb}")
return removed
# ── Pass D: Reduce junction count ─────────────────────────────────────────────
def reduce_junctions(
nodes: list[dict],
edges: list,
adj: dict[str, set[str]],
all_ids: list[str],
target_junction_count: int,
) -> int:
"""
Reduce junction count by removing edges from degree-3 junctions in
frontier/outer sectors, demoting them to degree-2 (spur_end/through_route).
Only removes non-bridge edges (to preserve connectivity).
"""
node_map = {n["system_id"]: n for n in nodes}
total = len(nodes)
tolerance = int(total * 0.03)
def node_priority(nid: str) -> int:
n = node_map.get(nid, {})
sector = n.get("geographic_sector", "")
band = n.get("geographic_band", "")
if sector == "deep_frontier":
return 3
if band == "outer":
return 2
if band == "mid":
return 1
return 0
removed = 0
max_budget = 50
for _ in range(max_budget):
cycle_members = find_cycle_members(all_ids, adj)
counts = {
t: sum(1 for n in nodes if classify_node(
n["system_id"], adj, cycle_members, n.get("is_gateway", False)
) == t)
for t in ["junction"]
}
j_now = counts["junction"]
if j_now <= target_junction_count + tolerance:
print(f" Pass D done: junction={j_now} within tolerance (target={target_junction_count}).")
break
# Find non-bridge edges adjacent to degree-3 junctions in frontier/outer
bridge_set = find_bridges(all_ids, adj)
best_a = None
best_b = None
best_score = -1
for e in edges:
a, b = e[0], e[1]
if frozenset([a, b]) in bridge_set:
continue
if a == GATEWAY_ID or b == GATEWAY_ID:
continue
da = len(adj.get(a, set()))
db = len(adj.get(b, set()))
# At least one endpoint should be a degree-3 junction in frontier/outer
pa = node_priority(a)
pb = node_priority(b)
# Only remove if at least one is degree 3 (demotes to 2) in a priority area
# and the other won't drop below 2 (we don't want to accidentally make more dead_ends)
if da == 3 and pa >= 1 and db >= 3:
score = pa * 10 + pb + db # prefer higher priority, higher degree on the other end
if score > best_score:
best_score = score
best_a, best_b = a, b
elif db == 3 and pb >= 1 and da >= 3:
score = pb * 10 + pa + da
if score > best_score:
best_score = score
best_a, best_b = a, b
if best_a is None:
print(f" Pass D: no suitable non-bridge degree-3 edges found (junction={j_now}).")
break
remove_edge(best_a, best_b, adj, edges)
if not is_connected(all_ids, adj):
adj[best_a].add(best_b)
adj[best_b].add(best_a)
edges.append([best_a, best_b])
continue
removed += 1
da_new = len(adj.get(best_a, set()))
db_new = len(adj.get(best_b, set()))
print(f" -edge {best_a}(now {da_new}) <-> {best_b}(now {db_new})")
return removed
# ── Main ───────────────────────────────────────────────────────────────────────
def main() -> None:
print(f"Reading {INPUT_JSON} ...")
with open(INPUT_JSON) as f:
data = json.load(f)
nodes: list[dict] = data["nodes"]
edges: list = data["edges"]
total = len(nodes)
print(f" {total} nodes, {len(edges)} edges")
sample = edges[0]
assert isinstance(sample, list) and len(sample) == 2, \
f"Unexpected edge format: {sample!r}"
adj = build_adjacency(edges)
all_ids = [n["system_id"] for n in nodes]
# Verify gateway constraints
gw_deg = len(adj.get(GATEWAY_ID, set()))
print(f" Gateway {GATEWAY_ID}: degree={gw_deg}")
assert gw_deg == 4, f"Gateway degree should be 4, got {gw_deg}"
assert is_connected(all_ids, adj), "ERROR: Input graph is not connected!"
print(" Connectivity: OK")
before_counts = count_topology(nodes, adj)
print_distribution(before_counts, total, "BEFORE")
# Targets (rounded)
target_hubs = int(TARGETS["hub"] * total) # 21
target_dead = int(TARGETS["dead_end"] * total) # 60
target_lm = int(TARGETS["loop_member"] * total) # 45
target_tr = int(TARGETS["through_route"] * total) # 75
target_junction = int(TARGETS["junction"] * total) # 54
print(f"\n Targets: hubs={target_hubs} dead_ends={target_dead} "
f"loop_member={target_lm} through_route={target_tr} junction={target_junction}")
# ── Pass A: Promote junctions to hubs ────────────────────────────────────
print("\n--- Pass A: Promote junctions to hubs ---")
a_promoted = promote_junctions_to_hubs(nodes, edges, adj, target_hubs)
reclassify_all(nodes, adj)
after_a = count_topology(nodes, adj)
print_distribution(after_a, total, "After Pass A")
print(f" Edges: {len(edges)}")
# ── Pass B: Create dead_ends ──────────────────────────────────────────────
print("\n--- Pass B: Create dead_ends ---")
b_created = create_dead_ends(nodes, edges, adj, target_dead, all_ids)
reclassify_all(nodes, adj)
after_b = count_topology(nodes, adj)
print_distribution(after_b, total, "After Pass B")
print(f" Edges: {len(edges)}")
# ── Pass C: Reduce loop_member and through_route excess ───────────────────
print("\n--- Pass C: Reduce loop_member / through_route excess ---")
c_removed = reduce_degree2_excess(nodes, edges, adj, all_ids, target_lm, target_tr)
reclassify_all(nodes, adj)
after_c = count_topology(nodes, adj)
print_distribution(after_c, total, "After Pass C")
print(f" Edges: {len(edges)} (Pass C removed {c_removed})")
# ── Pass D: Reduce junction count ─────────────────────────────────────────
print("\n--- Pass D: Reduce junctions ---")
d_removed = reduce_junctions(nodes, edges, adj, all_ids, target_junction)
reclassify_all(nodes, adj)
after_d = count_topology(nodes, adj)
print_distribution(after_d, total, "After Pass D")
print(f" Edges: {len(edges)} (Pass D removed {d_removed})")
# ── Pass E: Second dead_end pass if still short ────────────────────────────
de_now = count_topology(nodes, adj).get("dead_end", 0)
tolerance = int(total * 0.03) # 3% = 9 systems
if de_now < target_dead - tolerance:
print(f"\n--- Pass E: Second dead_end pass (have {de_now}, need {target_dead}) ---")
e_created = create_dead_ends(nodes, edges, adj, target_dead, all_ids)
reclassify_all(nodes, adj)
after_e = count_topology(nodes, adj)
print_distribution(after_e, total, "After Pass E")
print(f" Edges: {len(edges)} (Pass E created {e_created} more dead_ends)")
# ── Pass F: Second junction reduction pass if still high ───────────────────
j_now = count_topology(nodes, adj).get("junction", 0)
if j_now > target_junction + tolerance:
print(f"\n--- Pass F: Second junction reduction (have {j_now}, target {target_junction}) ---")
f_removed = reduce_junctions(nodes, edges, adj, all_ids, target_junction)
reclassify_all(nodes, adj)
after_f = count_topology(nodes, adj)
print_distribution(after_f, total, "After Pass F")
print(f" Edges: {len(edges)} (Pass F removed {f_removed})")
# ── Final reclassify and aperture update ──────────────────────────────────
reclassify_all(nodes, adj)
# ── Verify gateway constraints ────────────────────────────────────────────
gw_node = next(n for n in nodes if n["system_id"] == GATEWAY_ID)
assert gw_node["gate_topology"] == "hub", "Gateway topology changed!"
assert gw_node["aperture_count"] == 5, f"Gateway aperture_count={gw_node['aperture_count']} (should be 5)"
assert len(adj[GATEWAY_ID]) == 4, f"Gateway degree={len(adj[GATEWAY_ID])} (should be 4)"
print(f"\n Gateway constraint check: OK (degree=4, aperture=5, topology=hub)")
# ── Verify no aperture violations ────────────────────────────────────────
violations = [
n for n in nodes
if n.get("aperture_count", 0) > 8
]
if violations:
print(f"\n WARNING: {len(violations)} nodes exceed 8 apertures!")
for v in violations:
print(f" {v['system_id']}: aperture={v['aperture_count']}")
else:
print(" Aperture max-8 constraint: OK")
# ── Update metadata ───────────────────────────────────────────────────────
data["_meta"]["edge_count"] = len(edges)
data["_meta"]["tuned"] = "2026-03-13"
data["_meta"]["tune_note"] = (
"Topology tuning: promoted junctions to hubs, created dead_ends, "
"reduced loop_member/through_route/junction excess. Reclassified all nodes."
)
# ── Write output ──────────────────────────────────────────────────────────
print(f"\n Writing {OUTPUT_JSON} ...")
with open(OUTPUT_JSON, "w") as f:
json.dump(data, f, indent=2)
print(" Written.")
# ── Final summary ─────────────────────────────────────────────────────────
print("\n" + "=" * 60)
print("FINAL TOPOLOGY SUMMARY")
print("=" * 60)
final = count_topology(nodes, adj)
print_distribution(final, total, "Final distribution")
print(f"\n Nodes: {total} Edges: {len(edges)}")
print("\n Breakdown by sector:")
st: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for n in nodes:
st[n.get("geographic_sector", "?")][n["gate_topology"]] += 1
for sec in sorted(st.keys()):
sd = st[sec]
n_in = sum(sd.values())
parts = " ".join(f"{t}={c}" for t, c in sorted(sd.items()))
print(f" {sec:<15} n={n_in:>3} {parts}")
print("\n Core sector final degrees:")
core_final = [
(n["system_id"], len(adj.get(n["system_id"], set())), n["gate_topology"])
for n in nodes if n.get("geographic_sector") == "core"
]
core_final.sort(key=lambda x: -x[1])
for nid, deg, topo in core_final:
print(f" {nid}: deg={deg} {topo}")
if __name__ == "__main__":
main()