#!/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()