gator-command 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gator_command/__init__.py +2 -0
- gator_command/cli.py +137 -0
- gator_command/scripts/crawler.py +633 -0
- gator_command/scripts/dashboard/dashboard.css +982 -0
- gator_command/scripts/dashboard/dashboard.html +84 -0
- gator_command/scripts/dashboard/dashboard.js +419 -0
- gator_command/scripts/dashboard/views/audit.js +270 -0
- gator_command/scripts/dashboard/views/fleet.js +307 -0
- gator_command/scripts/dashboard/views/repo.js +599 -0
- gator_command/scripts/dashboard/views/settings.js +173 -0
- gator_command/scripts/dashboard/views/updates.js +308 -0
- gator_command/scripts/enforcer-prompt.md +22 -0
- gator_command/scripts/extract-claude-sessions.py +489 -0
- gator_command/scripts/extract-codex-sessions.py +477 -0
- gator_command/scripts/extract-gemini-sessions.py +410 -0
- gator_command/scripts/gator-audit.py +956 -0
- gator_command/scripts/gator-charter-draft.py +919 -0
- gator_command/scripts/gator-charter-lint.py +427 -0
- gator_command/scripts/gator-charter-verify.py +606 -0
- gator_command/scripts/gator-dashboard.py +1271 -0
- gator_command/scripts/gator-deploy.py +916 -0
- gator_command/scripts/gator-drift.py +569 -0
- gator_command/scripts/gator-enforce.py +82 -0
- gator_command/scripts/gator-fleet-intel.py +460 -0
- gator_command/scripts/gator-fleet-report.py +615 -0
- gator_command/scripts/gator-init-command-post.py +315 -0
- gator_command/scripts/gator-init.py +434 -0
- gator_command/scripts/gator-machine-id.py +153 -0
- gator_command/scripts/gator-policy-status.py +631 -0
- gator_command/scripts/gator-pulse.py +459 -0
- gator_command/scripts/gator-repo-status.py +649 -0
- gator_command/scripts/gator-session-common.py +372 -0
- gator_command/scripts/gator-session-sink.py +831 -0
- gator_command/scripts/gator-sessions.py +1244 -0
- gator_command/scripts/gator-update.py +615 -0
- gator_command/scripts/gator-version.py +38 -0
- gator_command/scripts/gator_core.py +489 -0
- gator_command/scripts/gator_remote.py +381 -0
- gator_command/scripts/gator_runtime.py +142 -0
- gator_command/scripts/gatorize-actions.sh +989 -0
- gator_command/scripts/gatorize-lib.sh +166 -0
- gator_command/scripts/gatorize-post.sh +394 -0
- gator_command/scripts/gatorize.py +1163 -0
- gator_command/scripts/gatorize.sh +185 -0
- gator_command/scripts/generate_markdown.py +212 -0
- gator_command/scripts/generate_wiki.py +424 -0
- gator_command/scripts/graph_health.py +780 -0
- gator_command/scripts/memex-lint.py +286 -0
- gator_command/scripts/memex-lint.sh +205 -0
- gator_command/scripts/memex.py +1472 -0
- gator_command/scripts/memex_formatters.py +191 -0
- gator_command/scripts/memex_state.py +236 -0
- gator_command/scripts/spawn.py +650 -0
- gator_command/templates/gator-starter/blueprints/README.md +32 -0
- gator_command/templates/gator-starter/charterignore +53 -0
- gator_command/templates/gator-starter/charters/README.md +178 -0
- gator_command/templates/gator-starter/charters/_template.md +31 -0
- gator_command/templates/gator-starter/commands/commit.md +33 -0
- gator_command/templates/gator-starter/commands/init.md +11 -0
- gator_command/templates/gator-starter/commands/update.md +5 -0
- gator_command/templates/gator-starter/constitution.md +165 -0
- gator_command/templates/gator-starter/field-guides/README.md +25 -0
- gator_command/templates/gator-starter/gator-start-up.md +119 -0
- gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
- gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
- gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
- gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
- gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
- gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
- gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
- gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
- gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
- gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
- gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
- gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
- gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
- gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
- gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
- gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
- gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
- gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
- gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
- gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
- gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
- gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
- gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
- gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
- gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
- gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
- gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
- gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
- gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
- gator_command/templates/gator-starter/sessions/.gitignore +7 -0
- gator_command/templates/gator-starter/vault/.gitkeep +0 -0
- gator_command/templates/gator-starter/whiteboard.md +5 -0
- gator_command-1.0.0.dist-info/METADATA +122 -0
- gator_command-1.0.0.dist-info/RECORD +110 -0
- gator_command-1.0.0.dist-info/WHEEL +5 -0
- gator_command-1.0.0.dist-info/entry_points.txt +2 -0
- gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
- gator_command-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Graph-health analysis for the Memex thread graph.
|
|
3
|
+
|
|
4
|
+
Reports:
|
|
5
|
+
- Backlink index (who links to each thread)
|
|
6
|
+
- 3-hop reachability violations
|
|
7
|
+
- Single-bridge edges (removal disconnects the graph)
|
|
8
|
+
- Graph visualization (PNG)
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python gator-command/scripts/graph_health.py
|
|
12
|
+
python gator-command/scripts/graph_health.py --image docs/wiki/thread-graph.png
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import io
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import networkx as nx
|
|
26
|
+
|
|
27
|
+
# Re-use the existing thread/link infrastructure
|
|
28
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
|
+
from generate_wiki import extract_links, load_threads
|
|
30
|
+
|
|
31
|
+
RED = "\033[0;31m"
|
|
32
|
+
YELLOW = "\033[0;33m"
|
|
33
|
+
GREEN = "\033[0;32m"
|
|
34
|
+
CYAN = "\033[0;36m"
|
|
35
|
+
NC = "\033[0m"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def error(msg: str) -> None:
|
|
39
|
+
print(f"{RED}ERROR:{NC} {msg}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def warn(msg: str) -> None:
|
|
43
|
+
print(f"{YELLOW}WARN:{NC} {msg}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ok(msg: str) -> None:
|
|
47
|
+
print(f"{GREEN}OK:{NC} {msg}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def info(msg: str) -> None:
|
|
51
|
+
print(f"{CYAN}INFO:{NC} {msg}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_graph(memex_dir: Path, graph_filter: str | None = None) -> tuple[nx.DiGraph, dict[Path, str]]:
|
|
55
|
+
"""Build a directed graph from thread cross-references.
|
|
56
|
+
|
|
57
|
+
If graph_filter is set, only include threads whose `graph:` frontmatter
|
|
58
|
+
matches the filter. Cross-graph links to excluded threads are dropped.
|
|
59
|
+
If graph_filter is None, all threads are included (whole-graph analysis).
|
|
60
|
+
"""
|
|
61
|
+
threads = load_threads(memex_dir)
|
|
62
|
+
|
|
63
|
+
# Build path-to-graph mapping for all threads
|
|
64
|
+
path_to_graph: dict[Path, str] = {}
|
|
65
|
+
for thread in threads:
|
|
66
|
+
resolved = thread.source_path.resolve()
|
|
67
|
+
path_to_graph[resolved] = thread.graph
|
|
68
|
+
|
|
69
|
+
# Filter threads if requested
|
|
70
|
+
if graph_filter is not None:
|
|
71
|
+
threads = [t for t in threads if t.graph == graph_filter]
|
|
72
|
+
|
|
73
|
+
G = nx.DiGraph()
|
|
74
|
+
path_to_title: dict[Path, str] = {}
|
|
75
|
+
|
|
76
|
+
for thread in threads:
|
|
77
|
+
resolved = thread.source_path.resolve()
|
|
78
|
+
path_to_title[resolved] = thread.title
|
|
79
|
+
G.add_node(resolved, title=thread.title, tier=thread.tier_label,
|
|
80
|
+
graph=thread.graph)
|
|
81
|
+
|
|
82
|
+
for thread in threads:
|
|
83
|
+
source = thread.source_path.resolve()
|
|
84
|
+
links = extract_links(thread.connection_lines, thread.source_path)
|
|
85
|
+
for link in links:
|
|
86
|
+
if link.target_path and link.target_path in path_to_title:
|
|
87
|
+
G.add_edge(source, link.target_path, annotation=link.annotation)
|
|
88
|
+
|
|
89
|
+
return G, path_to_title
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def report_backlinks(G: nx.DiGraph, path_to_title: dict[Path, str]) -> int:
|
|
93
|
+
"""Report inbound links for each thread. Returns orphan count as errors."""
|
|
94
|
+
print("-- Backlink Index --")
|
|
95
|
+
errors = 0
|
|
96
|
+
backlinks: dict[Path, list[Path]] = defaultdict(list)
|
|
97
|
+
for source, target in G.edges():
|
|
98
|
+
backlinks[target].append(source)
|
|
99
|
+
|
|
100
|
+
for node in sorted(G.nodes(), key=lambda n: path_to_title.get(n, "")):
|
|
101
|
+
title = path_to_title[node]
|
|
102
|
+
inbound = backlinks.get(node, [])
|
|
103
|
+
if inbound:
|
|
104
|
+
sources = ", ".join(path_to_title[s] for s in inbound)
|
|
105
|
+
info(f"{title} <- {sources}")
|
|
106
|
+
else:
|
|
107
|
+
error(f"{title} <- (no inbound links — orphan)")
|
|
108
|
+
errors += 1
|
|
109
|
+
print()
|
|
110
|
+
return errors
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def report_reachability(G: nx.DiGraph, path_to_title: dict[Path, str], health: dict, max_hops: int = 3) -> int:
|
|
114
|
+
"""Report reachability, split into intra-cluster (errors) and cross-cluster (info)."""
|
|
115
|
+
print(f"-- {max_hops}-Hop Navigability (cluster-aware) --")
|
|
116
|
+
errors = 0
|
|
117
|
+
|
|
118
|
+
U = G.to_undirected()
|
|
119
|
+
|
|
120
|
+
if U.number_of_nodes() == 0:
|
|
121
|
+
ok("No threads to check")
|
|
122
|
+
print()
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
if not nx.is_connected(U):
|
|
126
|
+
components = list(nx.connected_components(U))
|
|
127
|
+
error(f"Graph is disconnected: {len(components)} components")
|
|
128
|
+
for i, comp in enumerate(components):
|
|
129
|
+
titles = sorted(path_to_title[n] for n in comp)
|
|
130
|
+
info(f" Component {i + 1}: {', '.join(titles)}")
|
|
131
|
+
errors += 1
|
|
132
|
+
print()
|
|
133
|
+
return errors
|
|
134
|
+
|
|
135
|
+
intra = health.get("intra_hop_violations", [])
|
|
136
|
+
cross = health.get("cross_hop_violations", [])
|
|
137
|
+
|
|
138
|
+
if intra:
|
|
139
|
+
error(f"{len(intra)} intra-cluster pair(s) exceed {max_hops}-hop limit:")
|
|
140
|
+
for v in intra:
|
|
141
|
+
warn(f" {v['a']} <-> {v['b']} ({v['hops']} hops)")
|
|
142
|
+
errors += len(intra)
|
|
143
|
+
else:
|
|
144
|
+
ok(f"All intra-cluster pairs reachable within {max_hops} hops")
|
|
145
|
+
|
|
146
|
+
if cross:
|
|
147
|
+
info(f"{len(cross)} cross-cluster pair(s) exceed {max_hops} hops (penalized at 0.25x weight):")
|
|
148
|
+
for v in cross[:10]:
|
|
149
|
+
info(f" {v['a']} <-> {v['b']} ({v['hops']} hops)")
|
|
150
|
+
if len(cross) > 10:
|
|
151
|
+
info(f" ... and {len(cross) - 10} more")
|
|
152
|
+
else:
|
|
153
|
+
ok(f"All cross-cluster pairs also within {max_hops} hops")
|
|
154
|
+
|
|
155
|
+
print()
|
|
156
|
+
return errors
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def report_bridges(G: nx.DiGraph, path_to_title: dict[Path, str]) -> int:
|
|
160
|
+
"""Report single-bridge edges whose removal disconnects the graph."""
|
|
161
|
+
print("-- Single-Bridge Detection --")
|
|
162
|
+
errors = 0
|
|
163
|
+
U = G.to_undirected()
|
|
164
|
+
|
|
165
|
+
if U.number_of_nodes() < 2:
|
|
166
|
+
ok("Too few threads for bridge detection")
|
|
167
|
+
print()
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
bridges = list(nx.bridges(U))
|
|
171
|
+
if bridges:
|
|
172
|
+
warn(f"{len(bridges)} bridge edge(s) found (removal would disconnect the graph):")
|
|
173
|
+
for u, v in bridges:
|
|
174
|
+
warn(f" {path_to_title[u]} <-> {path_to_title[v]}")
|
|
175
|
+
errors += len(bridges)
|
|
176
|
+
else:
|
|
177
|
+
ok("No bridge edges — graph is 2-edge-connected")
|
|
178
|
+
print()
|
|
179
|
+
return errors
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def report_clusters(health: dict) -> None:
|
|
183
|
+
"""Report detected clusters."""
|
|
184
|
+
print("-- Cluster Detection (Louvain) --")
|
|
185
|
+
clusters = health.get("cluster_summary", [])
|
|
186
|
+
if not clusters:
|
|
187
|
+
info("No clusters detected")
|
|
188
|
+
print()
|
|
189
|
+
return
|
|
190
|
+
info(f"{len(clusters)} cluster(s) detected")
|
|
191
|
+
for c in clusters:
|
|
192
|
+
print(f" Cluster {c['id'] + 1} ({c['size']} nodes):")
|
|
193
|
+
for name in c["members"]:
|
|
194
|
+
print(f" - {name}")
|
|
195
|
+
print()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def report_efficiency(health: dict) -> int:
|
|
199
|
+
"""Report edge redundancy / efficiency."""
|
|
200
|
+
print("-- Edge Efficiency --")
|
|
201
|
+
errors = 0
|
|
202
|
+
ratio = health.get("redundancy_ratio", 0)
|
|
203
|
+
redundant = health.get("redundant_edges", 0)
|
|
204
|
+
total = health.get("total_undirected_edges", 0)
|
|
205
|
+
|
|
206
|
+
if total == 0:
|
|
207
|
+
ok("No edges to evaluate")
|
|
208
|
+
print()
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
# Finding 4 fix: report branches must match scoring branches
|
|
212
|
+
if ratio < 0.30:
|
|
213
|
+
warn(f"{redundant}/{total} edges redundant ({ratio:.0%}) — too sparse, fragile tree structure")
|
|
214
|
+
errors += 1
|
|
215
|
+
elif ratio <= 0.60:
|
|
216
|
+
ok(f"{redundant}/{total} edges redundant ({ratio:.0%}) — within sweet spot")
|
|
217
|
+
else:
|
|
218
|
+
warn(f"{redundant}/{total} edges redundant ({ratio:.0%}) — over-linked, {redundant - int(total * 0.60)} edges are noise")
|
|
219
|
+
errors += 1
|
|
220
|
+
info(f"Sweet spot: 30-60% redundancy. Below 30% = fragile tree. Above 60% = noisy.")
|
|
221
|
+
print()
|
|
222
|
+
return errors
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def report_legibility(health: dict) -> int:
|
|
226
|
+
"""Report peripheral node access to high-betweenness hubs."""
|
|
227
|
+
print("-- Legibility (peripheral access to hubs) --")
|
|
228
|
+
errors = 0
|
|
229
|
+
peripherals = health.get("peripherals", 0)
|
|
230
|
+
stranded = health.get("peripherals_stranded", [])
|
|
231
|
+
hubs = health.get("hubs", [])
|
|
232
|
+
|
|
233
|
+
if peripherals == 0:
|
|
234
|
+
ok("No peripheral nodes (degree <= 3)")
|
|
235
|
+
print()
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
info(f"Hubs (top 20% betweenness): {', '.join(hubs)}")
|
|
239
|
+
reached = peripherals - len(stranded)
|
|
240
|
+
if stranded:
|
|
241
|
+
warn(f"{reached}/{peripherals} peripheral nodes reach a hub within 2 hops")
|
|
242
|
+
for name in stranded:
|
|
243
|
+
warn(f" Stranded: {name}")
|
|
244
|
+
errors += len(stranded)
|
|
245
|
+
else:
|
|
246
|
+
ok(f"All {peripherals} peripheral nodes reach a hub within 2 hops")
|
|
247
|
+
print()
|
|
248
|
+
return errors
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def detect_clusters(U: nx.Graph, path_to_title: dict[Path, str]) -> list[set]:
|
|
252
|
+
"""Detect natural clusters using Louvain community detection."""
|
|
253
|
+
if U.number_of_nodes() == 0:
|
|
254
|
+
return []
|
|
255
|
+
try:
|
|
256
|
+
from networkx.algorithms.community import louvain_communities
|
|
257
|
+
return list(louvain_communities(U, seed=42))
|
|
258
|
+
except ImportError:
|
|
259
|
+
# Fallback: treat entire graph as one cluster
|
|
260
|
+
return [set(U.nodes())]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def compute_redundancy(U: nx.Graph) -> tuple[int, int]:
|
|
264
|
+
"""Count redundant edges (endpoints within 2 hops even without the edge).
|
|
265
|
+
|
|
266
|
+
Returns (redundant_count, total_undirected_edges).
|
|
267
|
+
"""
|
|
268
|
+
redundant = 0
|
|
269
|
+
total = U.number_of_edges()
|
|
270
|
+
for u, v in list(U.edges()):
|
|
271
|
+
H = U.copy()
|
|
272
|
+
H.remove_edge(u, v)
|
|
273
|
+
if nx.has_path(H, u, v) and nx.shortest_path_length(H, u, v) <= 2:
|
|
274
|
+
redundant += 1
|
|
275
|
+
return redundant, total
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def compute_health(G: nx.DiGraph, path_to_title: dict[Path, str], max_hops: int = 3) -> dict:
|
|
279
|
+
"""Compute graph stats and a health score.
|
|
280
|
+
|
|
281
|
+
Model v2.1 — subway-informed, cluster-aware (post Codex review):
|
|
282
|
+
- Navigability: intra-cluster 3-hop (full weight) + cross-cluster (0.25 weight)
|
|
283
|
+
- Resilience: bridge edges (unchanged)
|
|
284
|
+
- Connectivity: intra-cluster orphans (20 pts) + global-only orphans (10 pts)
|
|
285
|
+
- Efficiency: edge redundancy ratio (sweet spot 30-60%)
|
|
286
|
+
- Legibility: peripheral nodes' access to high-betweenness hubs
|
|
287
|
+
"""
|
|
288
|
+
n_nodes = G.number_of_nodes()
|
|
289
|
+
n_edges = G.number_of_edges()
|
|
290
|
+
U = G.to_undirected()
|
|
291
|
+
|
|
292
|
+
# Basic stats
|
|
293
|
+
avg_degree = (2 * U.number_of_edges() / n_nodes) if n_nodes > 0 else 0
|
|
294
|
+
components = nx.number_connected_components(U) if n_nodes > 0 else 0
|
|
295
|
+
|
|
296
|
+
# Cluster detection
|
|
297
|
+
clusters = detect_clusters(U, path_to_title)
|
|
298
|
+
n_clusters = len(clusters)
|
|
299
|
+
node_to_cluster: dict[Path, int] = {}
|
|
300
|
+
for i, comm in enumerate(clusters):
|
|
301
|
+
for n in comm:
|
|
302
|
+
node_to_cluster[n] = i
|
|
303
|
+
cluster_summary = []
|
|
304
|
+
for i, comm in enumerate(clusters):
|
|
305
|
+
names = sorted(path_to_title.get(n, "?") for n in comm)
|
|
306
|
+
cluster_summary.append({"id": i, "size": len(comm), "members": names})
|
|
307
|
+
|
|
308
|
+
# Bridges
|
|
309
|
+
bridges = list(nx.bridges(U)) if n_nodes >= 2 else []
|
|
310
|
+
|
|
311
|
+
# --- NAVIGABILITY: cluster-aware 3-hop reachability ---
|
|
312
|
+
# Finding 5 fix: compute all-pairs distances once, outside the loop
|
|
313
|
+
intra_violations = []
|
|
314
|
+
cross_violations = []
|
|
315
|
+
all_distances: dict[Path, dict[Path, int]] = {}
|
|
316
|
+
if n_nodes > 0 and nx.is_connected(U):
|
|
317
|
+
all_distances = dict(nx.all_pairs_shortest_path_length(U))
|
|
318
|
+
nodes = list(G.nodes())
|
|
319
|
+
seen_pairs = set()
|
|
320
|
+
for source in nodes:
|
|
321
|
+
lengths = nx.single_source_shortest_path_length(U, source, cutoff=max_hops)
|
|
322
|
+
for target in nodes:
|
|
323
|
+
if target != source and target not in lengths:
|
|
324
|
+
pair = tuple(sorted([source, target], key=lambda n: path_to_title.get(n, "")))
|
|
325
|
+
if pair not in seen_pairs:
|
|
326
|
+
seen_pairs.add(pair)
|
|
327
|
+
actual_dist = all_distances[source].get(target, float("inf"))
|
|
328
|
+
entry = {
|
|
329
|
+
"a": path_to_title[pair[0]],
|
|
330
|
+
"b": path_to_title[pair[1]],
|
|
331
|
+
"hops": actual_dist,
|
|
332
|
+
}
|
|
333
|
+
if node_to_cluster.get(source) == node_to_cluster.get(target):
|
|
334
|
+
intra_violations.append(entry)
|
|
335
|
+
else:
|
|
336
|
+
cross_violations.append(entry)
|
|
337
|
+
|
|
338
|
+
# --- CONNECTIVITY: per-cluster orphan detection ---
|
|
339
|
+
inbound_counts = defaultdict(int)
|
|
340
|
+
for _, target in G.edges():
|
|
341
|
+
inbound_counts[target] += 1
|
|
342
|
+
# Global orphans (no inbound from anywhere)
|
|
343
|
+
global_orphans = [path_to_title[n] for n in G.nodes() if inbound_counts.get(n, 0) == 0]
|
|
344
|
+
# Intra-cluster orphans (no inbound from within own cluster)
|
|
345
|
+
intra_cluster_orphans = []
|
|
346
|
+
for node in G.nodes():
|
|
347
|
+
cluster_id = node_to_cluster.get(node)
|
|
348
|
+
cluster_members = clusters[cluster_id] if cluster_id is not None else set()
|
|
349
|
+
has_intra_inbound = False
|
|
350
|
+
for source, target in G.edges():
|
|
351
|
+
if target == node and source in cluster_members and source != node:
|
|
352
|
+
has_intra_inbound = True
|
|
353
|
+
break
|
|
354
|
+
if not has_intra_inbound:
|
|
355
|
+
intra_cluster_orphans.append(path_to_title[node])
|
|
356
|
+
|
|
357
|
+
# --- EFFICIENCY: edge redundancy ratio ---
|
|
358
|
+
redundant_count, total_undirected = compute_redundancy(U)
|
|
359
|
+
redundancy_ratio = (redundant_count / total_undirected) if total_undirected > 0 else 0.0
|
|
360
|
+
|
|
361
|
+
# --- LEGIBILITY: peripheral access to hubs ---
|
|
362
|
+
# Peripheral = degree <= 3. Hub = top 20% by betweenness centrality.
|
|
363
|
+
# Score: fraction of peripherals that reach a hub within 2 hops.
|
|
364
|
+
bc = nx.betweenness_centrality(U) if n_nodes > 0 else {}
|
|
365
|
+
if bc:
|
|
366
|
+
bc_threshold = sorted(bc.values(), reverse=True)[max(0, n_nodes // 5 - 1)] if n_nodes >= 5 else 0
|
|
367
|
+
hubs = {n for n, v in bc.items() if v >= bc_threshold and v > 0}
|
|
368
|
+
else:
|
|
369
|
+
hubs = set()
|
|
370
|
+
# If no nodes have positive betweenness (e.g., complete graphs where all paths
|
|
371
|
+
# are length 1), fall back to top 20% by degree. A fully connected graph has no
|
|
372
|
+
# stranded peripherals — legibility should be 100, not 0.
|
|
373
|
+
if not hubs and n_nodes > 0:
|
|
374
|
+
degree_sorted = sorted(U.degree(), key=lambda x: -x[1])
|
|
375
|
+
top_count = max(1, n_nodes // 5)
|
|
376
|
+
hubs = {n for n, _ in degree_sorted[:top_count]}
|
|
377
|
+
peripherals = [n for n in U.nodes() if U.degree(n) <= 3]
|
|
378
|
+
peripherals_with_hub_access = 0
|
|
379
|
+
peripherals_stranded = []
|
|
380
|
+
for p in peripherals:
|
|
381
|
+
lengths = nx.single_source_shortest_path_length(U, p, cutoff=2)
|
|
382
|
+
if any(h in lengths for h in hubs):
|
|
383
|
+
peripherals_with_hub_access += 1
|
|
384
|
+
else:
|
|
385
|
+
peripherals_stranded.append(path_to_title[p])
|
|
386
|
+
|
|
387
|
+
# ===== SCORING =====
|
|
388
|
+
|
|
389
|
+
# Navigability (Finding 1 fix: soft cross-cluster penalty, not zero-cost)
|
|
390
|
+
# Intra-cluster violations are full-weight errors (break local navigation).
|
|
391
|
+
# Cross-cluster violations get a soft penalty: each one costs 1/4 what an intra-cluster
|
|
392
|
+
# violation costs. This prevents cluster growth from hiding long paths for free.
|
|
393
|
+
if n_nodes <= 1:
|
|
394
|
+
navigability_score = 100
|
|
395
|
+
elif components > 1:
|
|
396
|
+
navigability_score = 0
|
|
397
|
+
else:
|
|
398
|
+
total_pairs = n_nodes * (n_nodes - 1) // 2
|
|
399
|
+
# Weighted violation count: intra = 1.0, cross = 0.25
|
|
400
|
+
weighted_violations = len(intra_violations) + len(cross_violations) * 0.25
|
|
401
|
+
navigability_score = max(0, int(100 * (1 - weighted_violations / max(total_pairs, 1))))
|
|
402
|
+
|
|
403
|
+
# Resilience: bridges (unchanged)
|
|
404
|
+
if n_edges == 0:
|
|
405
|
+
resilience_score = 100
|
|
406
|
+
elif len(bridges) == 0:
|
|
407
|
+
resilience_score = 100
|
|
408
|
+
else:
|
|
409
|
+
resilience_score = max(0, 100 - (len(bridges) * 30))
|
|
410
|
+
|
|
411
|
+
# Connectivity: per-cluster orphans (Finding 2 fix: use intra_cluster_orphans, not global)
|
|
412
|
+
# A thread with no inbound links from within its own cluster is disconnected from its
|
|
413
|
+
# neighborhood, even if some cross-cluster thread links to it. Both are problems, but
|
|
414
|
+
# intra-cluster orphans are weighted heavier (they break local navigability).
|
|
415
|
+
if n_nodes == 0:
|
|
416
|
+
connectivity_score = 100
|
|
417
|
+
else:
|
|
418
|
+
# Intra-cluster orphans: 20 points each (breaks local navigation)
|
|
419
|
+
# Global orphans that aren't intra-cluster orphans: 10 points each (less severe)
|
|
420
|
+
intra_only = set(intra_cluster_orphans)
|
|
421
|
+
global_only = [o for o in global_orphans if o not in intra_only]
|
|
422
|
+
penalty = len(intra_cluster_orphans) * 20 + len(global_only) * 10
|
|
423
|
+
connectivity_score = max(0, 100 - penalty)
|
|
424
|
+
|
|
425
|
+
# Efficiency: sweet spot is 30-60% redundancy
|
|
426
|
+
# Below 30% = fragile (tree-like). Above 70% = noisy (over-linked).
|
|
427
|
+
# 100 at 30-60%, drops linearly outside that band.
|
|
428
|
+
if total_undirected == 0:
|
|
429
|
+
efficiency_score = 100
|
|
430
|
+
elif redundancy_ratio < 0.30:
|
|
431
|
+
# Too sparse — fragile
|
|
432
|
+
efficiency_score = max(0, int(100 * (redundancy_ratio / 0.30)))
|
|
433
|
+
elif redundancy_ratio <= 0.60:
|
|
434
|
+
# Sweet spot
|
|
435
|
+
efficiency_score = 100
|
|
436
|
+
else:
|
|
437
|
+
# Over-linked — noisy. 100% redundancy = 0 score.
|
|
438
|
+
efficiency_score = max(0, int(100 * (1 - (redundancy_ratio - 0.60) / 0.40)))
|
|
439
|
+
|
|
440
|
+
# Legibility: peripheral access to hubs
|
|
441
|
+
if len(peripherals) == 0:
|
|
442
|
+
legibility_score = 100
|
|
443
|
+
else:
|
|
444
|
+
legibility_score = int(100 * peripherals_with_hub_access / len(peripherals))
|
|
445
|
+
|
|
446
|
+
# Overall: 5 dimensions, equal weight
|
|
447
|
+
overall = (navigability_score + resilience_score + connectivity_score
|
|
448
|
+
+ efficiency_score + legibility_score) // 5
|
|
449
|
+
if overall >= 80:
|
|
450
|
+
verdict = "HEALTHY"
|
|
451
|
+
elif overall >= 50:
|
|
452
|
+
verdict = "FAIR"
|
|
453
|
+
else:
|
|
454
|
+
verdict = "UNHEALTHY"
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
"nodes": n_nodes,
|
|
458
|
+
"edges": n_edges,
|
|
459
|
+
"avg_degree": round(avg_degree, 1),
|
|
460
|
+
"components": components,
|
|
461
|
+
"clusters": n_clusters,
|
|
462
|
+
"cluster_summary": cluster_summary,
|
|
463
|
+
"orphans": global_orphans,
|
|
464
|
+
"intra_cluster_orphans": intra_cluster_orphans,
|
|
465
|
+
"bridges": len(bridges),
|
|
466
|
+
"intra_hop_violations": intra_violations,
|
|
467
|
+
"cross_hop_violations": cross_violations,
|
|
468
|
+
"redundancy_ratio": round(redundancy_ratio, 2),
|
|
469
|
+
"redundant_edges": redundant_count,
|
|
470
|
+
"total_undirected_edges": total_undirected,
|
|
471
|
+
"peripherals": len(peripherals),
|
|
472
|
+
"peripherals_stranded": peripherals_stranded,
|
|
473
|
+
"hubs": sorted(path_to_title.get(h, "?") for h in hubs),
|
|
474
|
+
"navigability_score": navigability_score,
|
|
475
|
+
"resilience_score": resilience_score,
|
|
476
|
+
"connectivity_score": connectivity_score,
|
|
477
|
+
"efficiency_score": efficiency_score,
|
|
478
|
+
"legibility_score": legibility_score,
|
|
479
|
+
"overall": overall,
|
|
480
|
+
"verdict": verdict,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def render_graph_image(
|
|
485
|
+
G: nx.DiGraph, path_to_title: dict[Path, str], output_path: Path, health: dict | None = None
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Render the thread graph as a PNG image with optional health overlay."""
|
|
488
|
+
import matplotlib
|
|
489
|
+
|
|
490
|
+
matplotlib.use("Agg")
|
|
491
|
+
import matplotlib.pyplot as plt
|
|
492
|
+
import matplotlib.patches as mpatches
|
|
493
|
+
|
|
494
|
+
print("-- Graph Visualization --")
|
|
495
|
+
|
|
496
|
+
# Build a labeled graph for display
|
|
497
|
+
labels = {node: title for node, title in path_to_title.items() if node in G}
|
|
498
|
+
|
|
499
|
+
BG = "#f0f0f0"
|
|
500
|
+
TEXT_COLOR = "#222222"
|
|
501
|
+
PANEL_BG = "#ffffff"
|
|
502
|
+
PANEL_EDGE = "#cccccc"
|
|
503
|
+
EDGE_COLOR = "#8888aa"
|
|
504
|
+
|
|
505
|
+
# Color by cluster (from health data) with tier as shape indicator via edge style
|
|
506
|
+
CLUSTER_PALETTE = ["#4A90D9", "#E07B39", "#5BA55B", "#C75B8F", "#8B6FBF", "#C4A132", "#49B6A6"]
|
|
507
|
+
node_to_cluster_map: dict[Path, int] = {}
|
|
508
|
+
if health and "cluster_summary" in health:
|
|
509
|
+
clusters = detect_clusters(G.to_undirected(), path_to_title)
|
|
510
|
+
for i, comm in enumerate(clusters):
|
|
511
|
+
for n in comm:
|
|
512
|
+
node_to_cluster_map[n] = i
|
|
513
|
+
node_colors = [CLUSTER_PALETTE[node_to_cluster_map.get(n, 0) % len(CLUSTER_PALETTE)] for n in G.nodes()]
|
|
514
|
+
# Reference threads get a lighter alpha via edge ring
|
|
515
|
+
node_edge_colors = []
|
|
516
|
+
for n in G.nodes():
|
|
517
|
+
tier = G.nodes[n].get("tier", "")
|
|
518
|
+
if tier == "Reference thread":
|
|
519
|
+
node_edge_colors.append("#888888")
|
|
520
|
+
else:
|
|
521
|
+
node_edge_colors.append("#ffffff")
|
|
522
|
+
|
|
523
|
+
# Use gridspec: graph on top, stats panel below
|
|
524
|
+
if health:
|
|
525
|
+
fig = plt.figure(figsize=(14, 11))
|
|
526
|
+
gs = fig.add_gridspec(2, 1, height_ratios=[4, 1], hspace=0.05)
|
|
527
|
+
ax = fig.add_subplot(gs[0])
|
|
528
|
+
ax_stats = fig.add_subplot(gs[1])
|
|
529
|
+
else:
|
|
530
|
+
fig, ax = plt.subplots(1, 1, figsize=(14, 9))
|
|
531
|
+
ax_stats = None
|
|
532
|
+
|
|
533
|
+
fig.patch.set_facecolor(BG)
|
|
534
|
+
ax.set_facecolor(BG)
|
|
535
|
+
|
|
536
|
+
pos = nx.spring_layout(G, k=2.5, iterations=50, seed=42)
|
|
537
|
+
|
|
538
|
+
# Draw edges
|
|
539
|
+
nx.draw_networkx_edges(
|
|
540
|
+
G, pos, ax=ax,
|
|
541
|
+
edge_color=EDGE_COLOR,
|
|
542
|
+
arrows=True,
|
|
543
|
+
arrowsize=15,
|
|
544
|
+
arrowstyle="-|>",
|
|
545
|
+
connectionstyle="arc3,rad=0.1",
|
|
546
|
+
width=1.5,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Draw nodes
|
|
550
|
+
nx.draw_networkx_nodes(
|
|
551
|
+
G, pos, ax=ax,
|
|
552
|
+
node_color=node_colors,
|
|
553
|
+
node_size=800,
|
|
554
|
+
edgecolors=node_edge_colors,
|
|
555
|
+
linewidths=2.0,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Draw labels
|
|
559
|
+
nx.draw_networkx_labels(
|
|
560
|
+
G, pos, labels=labels, ax=ax,
|
|
561
|
+
font_size=9,
|
|
562
|
+
font_color=TEXT_COLOR,
|
|
563
|
+
font_weight="bold",
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
ax.set_title("Memex Thread Graph", color=TEXT_COLOR, fontsize=14, fontweight="bold", pad=20)
|
|
567
|
+
|
|
568
|
+
# Legend — clusters + tier indicators
|
|
569
|
+
from matplotlib.lines import Line2D
|
|
570
|
+
|
|
571
|
+
legend_elements = []
|
|
572
|
+
if health and "cluster_summary" in health:
|
|
573
|
+
for c in health["cluster_summary"]:
|
|
574
|
+
color = CLUSTER_PALETTE[c["id"] % len(CLUSTER_PALETTE)]
|
|
575
|
+
legend_elements.append(
|
|
576
|
+
Line2D([0], [0], marker="o", color=BG, markerfacecolor=color,
|
|
577
|
+
markersize=10, label=f"Cluster {c['id'] + 1} ({c['size']} nodes)")
|
|
578
|
+
)
|
|
579
|
+
legend_elements.append(
|
|
580
|
+
Line2D([0], [0], marker="o", color=BG, markerfacecolor="#CCCCCC",
|
|
581
|
+
markeredgecolor="#888888", markeredgewidth=2,
|
|
582
|
+
markersize=10, label="Reference thread (gray ring)")
|
|
583
|
+
)
|
|
584
|
+
ax.legend(handles=legend_elements, loc="lower right", facecolor=PANEL_BG,
|
|
585
|
+
edgecolor=PANEL_EDGE, labelcolor=TEXT_COLOR, fontsize=8)
|
|
586
|
+
|
|
587
|
+
ax.axis("off")
|
|
588
|
+
|
|
589
|
+
# Health stats panel below graph
|
|
590
|
+
if health and ax_stats is not None:
|
|
591
|
+
ax_stats.set_facecolor(BG)
|
|
592
|
+
ax_stats.axis("off")
|
|
593
|
+
|
|
594
|
+
verdict = health["verdict"]
|
|
595
|
+
verdict_colors = {"HEALTHY": "#2E7D32", "FAIR": "#F57F17", "UNHEALTHY": "#C62828"}
|
|
596
|
+
verdict_color = verdict_colors.get(verdict, TEXT_COLOR)
|
|
597
|
+
|
|
598
|
+
stats_lines = (
|
|
599
|
+
f"Nodes: {health['nodes']} Edges: {health['edges']} Avg degree: {health['avg_degree']} Clusters: {health['clusters']}\n"
|
|
600
|
+
f"Components: {health['components']} Bridges: {health['bridges']} Orphans: {len(health['orphans'])} Redundancy: {health['redundancy_ratio']:.0%}\n"
|
|
601
|
+
f"\n"
|
|
602
|
+
f"Navigability: {health['navigability_score']} "
|
|
603
|
+
f"Resilience: {health['resilience_score']} "
|
|
604
|
+
f"Connectivity: {health['connectivity_score']} "
|
|
605
|
+
f"Efficiency: {health['efficiency_score']} "
|
|
606
|
+
f"Legibility: {health['legibility_score']}"
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
props = dict(boxstyle="round,pad=0.8", facecolor=PANEL_BG, edgecolor=PANEL_EDGE, alpha=0.95)
|
|
610
|
+
|
|
611
|
+
# Verdict line (colored, bold)
|
|
612
|
+
ax_stats.text(
|
|
613
|
+
0.5, 0.82,
|
|
614
|
+
f"Health: {verdict} ({health['overall']}/100)",
|
|
615
|
+
transform=ax_stats.transAxes,
|
|
616
|
+
fontsize=12,
|
|
617
|
+
fontfamily="monospace",
|
|
618
|
+
fontweight="bold",
|
|
619
|
+
color=verdict_color,
|
|
620
|
+
verticalalignment="center",
|
|
621
|
+
horizontalalignment="center",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Stats below verdict
|
|
625
|
+
ax_stats.text(
|
|
626
|
+
0.5, 0.35, stats_lines,
|
|
627
|
+
transform=ax_stats.transAxes,
|
|
628
|
+
fontsize=10,
|
|
629
|
+
fontfamily="monospace",
|
|
630
|
+
color=TEXT_COLOR,
|
|
631
|
+
verticalalignment="center",
|
|
632
|
+
horizontalalignment="center",
|
|
633
|
+
bbox=props,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
plt.tight_layout()
|
|
637
|
+
|
|
638
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
639
|
+
fig.savefig(str(output_path), dpi=150, facecolor=fig.get_facecolor())
|
|
640
|
+
plt.close(fig)
|
|
641
|
+
|
|
642
|
+
ok(f"Graph image saved to {output_path}")
|
|
643
|
+
print()
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def main() -> int:
|
|
647
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
648
|
+
parser.add_argument(
|
|
649
|
+
"--repo-root",
|
|
650
|
+
default=Path(__file__).resolve().parents[2],
|
|
651
|
+
type=Path,
|
|
652
|
+
)
|
|
653
|
+
parser.add_argument(
|
|
654
|
+
"--image",
|
|
655
|
+
default=None,
|
|
656
|
+
type=Path,
|
|
657
|
+
help="Output path for graph PNG (e.g., docs/wiki/thread-graph.png)",
|
|
658
|
+
)
|
|
659
|
+
parser.add_argument(
|
|
660
|
+
"--json",
|
|
661
|
+
default=None,
|
|
662
|
+
type=Path,
|
|
663
|
+
help="Output path for health JSON (e.g., docs/wiki/graph-health.json)",
|
|
664
|
+
)
|
|
665
|
+
parser.add_argument(
|
|
666
|
+
"--max-hops",
|
|
667
|
+
default=3,
|
|
668
|
+
type=int,
|
|
669
|
+
help="Maximum hops for reachability check (default: 3)",
|
|
670
|
+
)
|
|
671
|
+
parser.add_argument(
|
|
672
|
+
"--graph",
|
|
673
|
+
default=None,
|
|
674
|
+
type=str,
|
|
675
|
+
help="Filter by graph namespace (e.g., 'design', 'user'). Default: all threads.",
|
|
676
|
+
)
|
|
677
|
+
args = parser.parse_args()
|
|
678
|
+
|
|
679
|
+
repo_root = args.repo_root.resolve()
|
|
680
|
+
memex_dir = repo_root / "gator-command"
|
|
681
|
+
|
|
682
|
+
# Ensure UTF-8 output on Windows
|
|
683
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
|
684
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
|
685
|
+
|
|
686
|
+
graph_label = f" ({args.graph} layer)" if args.graph else ""
|
|
687
|
+
print("==============================")
|
|
688
|
+
print(f" Graph Health Report{graph_label}")
|
|
689
|
+
print("==============================")
|
|
690
|
+
print()
|
|
691
|
+
|
|
692
|
+
G, path_to_title = build_graph(memex_dir, graph_filter=args.graph)
|
|
693
|
+
|
|
694
|
+
# Validate --graph filter: fail on empty result from a non-None filter
|
|
695
|
+
if args.graph is not None and G.number_of_nodes() == 0:
|
|
696
|
+
# Check if the graph name exists at all
|
|
697
|
+
all_threads = load_threads(memex_dir)
|
|
698
|
+
known_graphs = sorted(set(t.graph for t in all_threads))
|
|
699
|
+
error(f"No threads found with graph: {args.graph}")
|
|
700
|
+
info(f"Known graph values: {', '.join(known_graphs)}")
|
|
701
|
+
return 1
|
|
702
|
+
|
|
703
|
+
health = compute_health(G, path_to_title, max_hops=args.max_hops)
|
|
704
|
+
|
|
705
|
+
info(f"Nodes: {health['nodes']}, Edges: {health['edges']}, Avg degree: {health['avg_degree']}")
|
|
706
|
+
info(f"Components: {health['components']}, Clusters: {health['clusters']}, Bridges: {health['bridges']}, Orphans: {len(health['orphans'])}")
|
|
707
|
+
print()
|
|
708
|
+
|
|
709
|
+
errors = 0
|
|
710
|
+
report_clusters(health)
|
|
711
|
+
errors += report_backlinks(G, path_to_title)
|
|
712
|
+
errors += report_reachability(G, path_to_title, health, max_hops=args.max_hops)
|
|
713
|
+
errors += report_bridges(G, path_to_title)
|
|
714
|
+
errors += report_efficiency(health)
|
|
715
|
+
errors += report_legibility(health)
|
|
716
|
+
|
|
717
|
+
# Health score summary
|
|
718
|
+
print("-- Health Score (v2 — subway model) --")
|
|
719
|
+
verdict_color = GREEN if health["verdict"] == "HEALTHY" else (YELLOW if health["verdict"] == "FAIR" else RED)
|
|
720
|
+
print(f"{verdict_color}{health['verdict']}{NC} ({health['overall']}/100)")
|
|
721
|
+
info(f"Navigability: {health['navigability_score']} Resilience: {health['resilience_score']} Connectivity: {health['connectivity_score']} Efficiency: {health['efficiency_score']} Legibility: {health['legibility_score']}")
|
|
722
|
+
print()
|
|
723
|
+
|
|
724
|
+
if args.image:
|
|
725
|
+
image_path = args.image
|
|
726
|
+
if not image_path.is_absolute():
|
|
727
|
+
image_path = repo_root / image_path
|
|
728
|
+
render_graph_image(G, path_to_title, image_path, health=health)
|
|
729
|
+
|
|
730
|
+
if args.json:
|
|
731
|
+
json_path = args.json
|
|
732
|
+
if not json_path.is_absolute():
|
|
733
|
+
json_path = repo_root / json_path
|
|
734
|
+
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
735
|
+
json_data = {
|
|
736
|
+
"date": dt.date.today().isoformat(),
|
|
737
|
+
"model": "v2.1-subway",
|
|
738
|
+
"verdict": health["verdict"],
|
|
739
|
+
"overall": health["overall"],
|
|
740
|
+
"nodes": health["nodes"],
|
|
741
|
+
"edges": health["edges"],
|
|
742
|
+
"avg_degree": health["avg_degree"],
|
|
743
|
+
"components": health["components"],
|
|
744
|
+
"clusters": health["clusters"],
|
|
745
|
+
"cluster_summary": health["cluster_summary"],
|
|
746
|
+
"bridges": health["bridges"],
|
|
747
|
+
"orphans": health["orphans"],
|
|
748
|
+
"intra_cluster_orphans": health["intra_cluster_orphans"],
|
|
749
|
+
"intra_hop_violations": len(health["intra_hop_violations"]),
|
|
750
|
+
"cross_hop_violations": len(health["cross_hop_violations"]),
|
|
751
|
+
"redundancy_ratio": health["redundancy_ratio"],
|
|
752
|
+
"redundant_edges": health["redundant_edges"],
|
|
753
|
+
"total_undirected_edges": health["total_undirected_edges"],
|
|
754
|
+
"peripherals": health["peripherals"],
|
|
755
|
+
"peripherals_stranded": health["peripherals_stranded"],
|
|
756
|
+
"hubs": health["hubs"],
|
|
757
|
+
"scores": {
|
|
758
|
+
"navigability": health["navigability_score"],
|
|
759
|
+
"resilience": health["resilience_score"],
|
|
760
|
+
"connectivity": health["connectivity_score"],
|
|
761
|
+
"efficiency": health["efficiency_score"],
|
|
762
|
+
"legibility": health["legibility_score"],
|
|
763
|
+
},
|
|
764
|
+
}
|
|
765
|
+
json_path.write_text(json.dumps(json_data, indent=2) + "\n", encoding="utf-8")
|
|
766
|
+
ok(f"Health JSON saved to {json_path}")
|
|
767
|
+
print()
|
|
768
|
+
|
|
769
|
+
print("==============================")
|
|
770
|
+
if errors == 0:
|
|
771
|
+
print(f"{GREEN}All graph-health checks passed.{NC}")
|
|
772
|
+
else:
|
|
773
|
+
print(f"{RED}{errors} issue(s) found.{NC}")
|
|
774
|
+
print("==============================")
|
|
775
|
+
|
|
776
|
+
return min(errors, 1)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
if __name__ == "__main__":
|
|
780
|
+
raise SystemExit(main())
|