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:
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user