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>
902 lines
34 KiB
Python
902 lines
34 KiB
Python
#!/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()
|