Files
obsidian-wiki/obsidian_wiki/graph_analysis.py
T
Codex 82890606aa feat(wiki): sync upstream v2026.10.4 core maintenance
Selectively port source hashing, locked manifest/memory writes, path-aware
link lint and graph analysis, and revision-checked staging from upstream
4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions.

Preserve local archive/catalog citations, curated manifest metadata, config
location, file-index and MCP behavior. Wire maintenance guidance into four
skills without migrating vault contents or changing running services.

Validation: 609 tests passed, including existing MCP/file-asset regressions
and imported upstream maintenance tests. Read-only vault status left shared
metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
2026-10-31 12:39:48 +00:00

915 lines
34 KiB
Python

"""Vault graph analysis: community detection, god nodes, bridges, surprising connections.
Reads the vault's wikilink graph (from page frontmatter and [[wikilinks]]),
then runs the same family of algorithms graphify uses on code graphs — all in
pure Python (no networkx / numpy):
1. Degree-based god-node ranking (most-linked pages)
2. Community detection — Leiden via `obsidian-wiki[graph]` if installed,
else greedy label propagation (pure Python, no binary deps)
3. Community cohesion — intra-community edge density (graphify
`cohesion_score`); < 0.15 flags a fragmented cluster
4. Bridge pages — Brandes betweenness centrality; the pages that sit on
the most shortest paths between other pages (graphify's bridge nodes)
5. Surprising connections — cross-community edges ranked by unexpectedness,
de-duplicated one-per-community-pair so a single hub can't dominate
6. Suggested questions — derived from bridges, isolates, dead-end hubs and
low-cohesion communities (graphify `suggest_questions`)
7. Path finding / neighbourhoods — `shortest_path` and BFS `neighborhood`
(graphify `path` and blast-radius / `affected`)
8. Graph diff — compare against a previous snapshot (graphify `graph_diff`)
Output JSON:
{
"god_nodes": [{"page": "...", "degree": N, "in_degree": N, "out_degree": N}, ...],
"bridges": [{"page": "...", "betweenness": 0.N, "connects": [comm_id, ...]}, ...],
"communities": [{"id": N, "size": N, "pages": [...], "label": "...", "cohesion": 0.N}, ...],
"surprising_connections": [{"source": "...", "target": "...", "score": 0.N,
"note": "bridges community A -> community B"}, ...],
"suggested_questions": [{"type": "...", "question": "...", "why": "..."}, ...],
"dead_ends": ["page with 0 outgoing links", ...],
"isolated": ["page with 0 links at all", ...],
"stats": {"pages": N, "edges": N, "communities": N, "density": 0.N}
}
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import random
import re
import time
from collections import defaultdict
from pathlib import Path
from typing import Any
from obsidian_wiki.links import PageIndex, strip_code
from obsidian_wiki.provenance import archive_wikilink_relpath
from obsidian_wiki.vault import SKIP_DIRS as VAULT_SKIP_DIRS
from obsidian_wiki.vault import iter_md, okignore_patterns, okignored, skipped_dir # noqa: F401 (re-exported)
# ---------------------------------------------------------------------------
# Wikilink / frontmatter parsing
# ---------------------------------------------------------------------------
_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]")
# Local `.md` targets only: skip URLs (any `scheme:`) and require `.md` to end
# the path, before an optional `#anchor` or `"title"`.
_MD_LINK_RE = re.compile(
r"\[.*?\]\((?![A-Za-z][A-Za-z0-9+.-]*:)([^)#\s]+\.md)(?:[#\s][^)]*)?\)"
)
_TAGS_RE = re.compile(r"^tags:\s*\[([^\]]+)\]", re.MULTILINE)
_TAGS_LIST_RE = re.compile(r"^tags:\s*\n((?:\s+-\s+\S+\n)+)", re.MULTILINE)
def _slug(page_name: str) -> str:
"""Normalise a wikilink target to a page slug."""
return page_name.strip().lower().replace(" ", "-")
def _page_slug(path: Path, root: Path) -> str:
return _slug(path.stem)
# --- Shared page selection -------------------------------------------------
# Every module that builds a graph over the vault MUST use these, so the
# wikilink graph is identical no matter which entry point produced it.
#: Directories that hold staging / archive / config material, not knowledge.
#: Dot-prefixed paths (`.venv`, `.git`) are skipped wholesale by `is_wiki_page`;
#: this list also covers the non-hidden equivalents (`venv/`, `node_modules/`).
SKIP_DIRS = VAULT_SKIP_DIRS | {"_meta", "_readouts"}
#: Vault bookkeeping files at the vault ROOT. They link to (almost) every page,
#: so leaving them in makes every pair of pages look ~2 hops apart and lets a
#: table of contents outrank real knowledge in every degree/centrality ranking.
#: Only the root-level files are skipped — a legitimate `concepts/index.md`
#: is still a normal page.
SKIP_ROOT_FILES = frozenset({"index", "log", "hot", "_insights"})
def is_wiki_page(path: Path, vault: Path) -> bool:
"""True if `path` is a knowledge page of `vault` (not staging/bookkeeping)."""
try:
rel = path.relative_to(vault)
except ValueError:
return False
if skipped_dir(rel, SKIP_DIRS):
return False
if len(rel.parts) == 1 and path.stem in SKIP_ROOT_FILES:
return False
return True
def iter_pages(vault: Path) -> list[Path]:
"""All knowledge pages in the vault, in stable sorted order."""
return [p for p in iter_md(vault, SKIP_DIRS) if is_wiki_page(p, vault)]
def parse_vault_graph(vault: Path) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""Parse all .md pages and return (outgoing_edges, page_tags).
outgoing_edges: {source_slug: [target_slug, ...]}
page_tags: {slug: [tag, ...]}
"""
outgoing: dict[str, list[str]] = defaultdict(list)
tags_map: dict[str, list[str]] = {}
pages: list[Path] = iter_pages(vault)
rels = {page: page.relative_to(vault).as_posix() for page in pages}
index = PageIndex(rels.values())
for page in pages:
src = index.id_for(rels[page])
try:
text = page.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
links_text = strip_code(text)
# Tags
m = _TAGS_RE.search(text)
if m:
tags_map[src] = [t.strip().strip("'\"") for t in m.group(1).split(",")]
else:
m2 = _TAGS_LIST_RE.search(text)
if m2:
tags_map[src] = [ln.strip().lstrip("- ") for ln in m2.group(1).splitlines() if ln.strip()]
# Wikilinks
for link in _WIKILINK_RE.findall(links_text):
if archive_wikilink_relpath(vault, link) is not None:
continue
target = index.resolve(link, rels[page])
if target and target != src:
outgoing[src].append(target)
# Markdown links (when OBSIDIAN_LINK_FORMAT=markdown)
for href in _MD_LINK_RE.findall(links_text):
if archive_wikilink_relpath(vault, href) is not None:
continue
target = index.resolve(href, rels[page])
if target and target != src:
outgoing[src].append(target)
if src not in outgoing:
outgoing[src] = []
# Ensure every known page appears as a key
for page_id in index.ids():
outgoing.setdefault(page_id, [])
return dict(outgoing), tags_map
# ---------------------------------------------------------------------------
# Graph metrics
# ---------------------------------------------------------------------------
def _build_degree_tables(
outgoing: dict[str, list[str]]
) -> tuple[dict[str, int], dict[str, int], dict[str, int]]:
out_deg: dict[str, int] = {n: len(v) for n, v in outgoing.items()}
in_deg: dict[str, int] = defaultdict(int)
for targets in outgoing.values():
for t in targets:
in_deg[t] += 1
degree = {n: out_deg.get(n, 0) + in_deg.get(n, 0) for n in outgoing}
for n in in_deg:
degree.setdefault(n, in_deg[n])
return degree, dict(in_deg), out_deg
def god_nodes(outgoing: dict[str, list[str]], top_n: int = 20) -> list[dict]:
degree, in_deg, out_deg = _build_degree_tables(outgoing)
ranked = sorted(degree, key=lambda n: -degree[n])[:top_n]
return [
{"page": n, "degree": degree[n],
"in_degree": in_deg.get(n, 0), "out_degree": out_deg.get(n, 0)}
for n in ranked
]
def dead_ends(outgoing: dict[str, list[str]]) -> list[str]:
"""Pages with no outgoing links."""
return [n for n, targets in outgoing.items() if not targets]
def isolated(outgoing: dict[str, list[str]]) -> list[str]:
"""Pages with zero links in either direction."""
_, in_deg, out_deg = _build_degree_tables(outgoing)
return [n for n in outgoing if in_deg.get(n, 0) == 0 and out_deg.get(n, 0) == 0]
# ---------------------------------------------------------------------------
# Community detection — greedy modularity (Newman-Girvan, pure Python)
# ---------------------------------------------------------------------------
def _modularity(communities: list[set[str]], outgoing: dict[str, list[str]]) -> float:
"""Approximate modularity Q for an undirected projection of outgoing edges."""
all_edges = [(s, t) for s, targets in outgoing.items() for t in targets]
m = len(all_edges)
if m == 0:
return 0.0
degree = defaultdict(int)
for s, t in all_edges:
degree[s] += 1
degree[t] += 1
node_comm: dict[str, int] = {}
for i, comm in enumerate(communities):
for n in comm:
node_comm[n] = i
Q = 0.0
for s, t in all_edges:
if node_comm.get(s) == node_comm.get(t):
Q += 1 - (degree[s] * degree[t]) / (2 * m)
return Q / m
def detect_communities_greedy(outgoing: dict[str, list[str]]) -> list[set[str]]:
"""Greedy modularity community detection (label propagation variant).
Fast O(n·k) approach suitable for vaults up to ~5 000 pages. Each node
adopts the most frequent label among its neighbours; iterate until stable.
Falls back gracefully to one community per page if the graph is empty.
"""
nodes = list(outgoing.keys())
if not nodes:
return []
# Build undirected adjacency
adj: dict[str, list[str]] = defaultdict(list)
for src, targets in outgoing.items():
for t in targets:
adj[src].append(t)
adj[t].append(src)
# Initialise: each node in its own community (label = index)
labels: dict[str, int] = {n: i for i, n in enumerate(nodes)}
# Visit nodes in a shuffled order each round. A fixed sequential sweep
# cascades one label along chains and collapses well-separated clusters
# into a single community (a barbell graph comes back as one group). The
# RNG is seeded from the node set, so the result stays reproducible.
rng = random.Random(len(nodes))
order = list(nodes)
for _ in range(20): # max 20 rounds
changed = False
rng.shuffle(order)
for n in order:
neighbours = adj[n]
if not neighbours:
continue
freq: dict[int, int] = defaultdict(int)
for nb in neighbours:
freq[labels[nb]] += 1
best = max(freq, key=lambda lbl: (freq[lbl], -lbl))
if best != labels[n]:
labels[n] = best
changed = True
if not changed:
break
# Group by label
groups: dict[int, set[str]] = defaultdict(set)
for n, lbl in labels.items():
groups[lbl].add(n)
return list(groups.values())
def detect_communities(outgoing: dict[str, list[str]]) -> list[set[str]]:
"""Try Leiden (leidenalg + igraph) first; fall back to greedy label propagation."""
try:
import igraph as ig
import leidenalg
nodes = list(outgoing.keys())
node_idx = {n: i for i, n in enumerate(nodes)}
edges = [
(node_idx[s], node_idx[t])
for s, targets in outgoing.items()
for t in targets
if t in node_idx
]
g = ig.Graph(n=len(nodes), edges=edges, directed=False)
partition = leidenalg.find_partition(g, leidenalg.ModularityVertexPartition)
return [
{nodes[i] for i in cluster}
for cluster in partition
]
except ImportError:
return detect_communities_greedy(outgoing)
def _undirected_adj(outgoing: dict[str, list[str]]) -> dict[str, set[str]]:
"""Undirected simple-graph adjacency (duplicate/self edges collapsed)."""
adj: dict[str, set[str]] = {n: set() for n in outgoing}
for src, targets in outgoing.items():
for t in targets:
if t == src:
continue
adj[src].add(t)
adj.setdefault(t, set()).add(src)
return adj
def _node_community_map(communities: list[set[str]]) -> dict[str, int]:
return {n: i for i, comm in enumerate(communities) for n in comm}
# ---------------------------------------------------------------------------
# Community cohesion (graphify: cluster.cohesion_score)
# ---------------------------------------------------------------------------
def cohesion_score(outgoing: dict[str, list[str]], community: set[str]) -> float:
"""Ratio of actual intra-community (undirected) edges to the maximum possible.
1.0 = clique, 0.0 = no internal links. Singletons score 1.0.
"""
n = len(community)
if n <= 1:
return 1.0
adj = _undirected_adj(outgoing)
actual = sum(1 for a in community for b in adj.get(a, ()) if b in community and a < b)
possible = n * (n - 1) / 2
return round(actual / possible, 4) if possible else 0.0
# ---------------------------------------------------------------------------
# Betweenness centrality — Brandes (2001), pure Python
# (graphify: nx.betweenness_centrality → "bridge nodes")
# ---------------------------------------------------------------------------
def betweenness_centrality(
outgoing: dict[str, list[str]],
normalized: bool = True,
) -> dict[str, float]:
"""Exact node betweenness on the undirected projection (Brandes algorithm).
O(V·E) — fine for vaults up to a few thousand pages. Values are normalised
to [0, 1] by 2/((n-1)(n-2)) so scores are comparable across vault sizes.
"""
adj = _undirected_adj(outgoing)
nodes = list(adj)
bc: dict[str, float] = {n: 0.0 for n in nodes}
for s in nodes:
stack: list[str] = []
preds: dict[str, list[str]] = {n: [] for n in nodes}
sigma: dict[str, float] = {n: 0.0 for n in nodes}
dist: dict[str, int] = {n: -1 for n in nodes}
sigma[s] = 1.0
dist[s] = 0
queue = [s]
qi = 0
while qi < len(queue):
v = queue[qi]
qi += 1
stack.append(v)
for w in adj[v]:
if dist[w] < 0:
dist[w] = dist[v] + 1
queue.append(w)
if dist[w] == dist[v] + 1:
sigma[w] += sigma[v]
preds[w].append(v)
delta: dict[str, float] = {n: 0.0 for n in nodes}
while stack:
w = stack.pop()
for v in preds[w]:
delta[v] += (sigma[v] / sigma[w]) * (1.0 + delta[w])
if w != s:
bc[w] += delta[w]
n = len(nodes)
# Undirected: every pair counted twice
scale = 0.5
if normalized and n > 2:
scale = 1.0 / ((n - 1) * (n - 2))
return {k: v * scale for k, v in bc.items()}
# --- Betweenness cache -----------------------------------------------------
# Betweenness is the ONLY expensive step in this module: O(V*E), ~42s on a
# 5 000-page vault, versus 0.13s to parse it and 0.03s for communities. So it
# is the only thing worth caching.
#
# The cache key is a hash of the GRAPH ITSELF, not file mtimes. That makes a
# stale hit impossible by construction: if any link changed, the key changes;
# if only prose changed, the old (still-correct) result is reused. No
# invalidation logic to get wrong.
CACHE_FILENAME = ".graph-cache.json"
_CACHE_MAX_ENTRIES = 3
#: Don't write a cache file for graphs that recompute faster than this — small
#: vaults should not accumulate cache files for no gain.
_CACHE_MIN_SECONDS = 0.5
def graph_fingerprint(outgoing: dict[str, list[str]]) -> str:
"""Stable hash of the undirected graph topology (nodes + edges)."""
adj = _undirected_adj(outgoing)
h = hashlib.sha256()
for node in sorted(adj):
h.update(node.encode("utf-8"))
h.update(b"\x00")
for nb in sorted(adj[node]):
h.update(nb.encode("utf-8"))
h.update(b"\x01")
h.update(b"\x02")
return h.hexdigest()[:32]
def _cache_path(vault: Path) -> Path:
return vault / CACHE_FILENAME
def _read_cache(vault: Path) -> dict:
"""Read the cache, failing open (a corrupt or unreadable cache is ignored)."""
try:
data = json.loads(_cache_path(vault).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return {}
return data if isinstance(data, dict) and data.get("version") == 1 else {}
def _write_cache(vault: Path, data: dict) -> None:
"""Atomically replace the cache file. Never raises — caching is best-effort."""
target = _cache_path(vault)
tmp = target.with_suffix(f".tmp{os.getpid()}")
try:
tmp.write_text(json.dumps(data), encoding="utf-8")
os.replace(tmp, target) # atomic; concurrent writers can't tear the file
except OSError:
try:
tmp.unlink()
except OSError:
pass
def betweenness_cached(
outgoing: dict[str, list[str]],
vault: Path | None = None,
normalized: bool = True,
) -> dict[str, float]:
"""`betweenness_centrality`, memoised on disk under `vault` by graph topology.
Falls back to a plain in-process computation when `vault` is None or the
cache is unusable — the result is always identical either way.
"""
if vault is None:
return betweenness_centrality(outgoing, normalized=normalized)
key = f"{graph_fingerprint(outgoing)}:{int(normalized)}"
cache = _read_cache(vault)
hit = cache.get("betweenness", {}).get(key)
if isinstance(hit, dict) and hit.keys() >= outgoing.keys():
return {n: float(hit[n]) for n in outgoing}
started = time.monotonic()
scores = betweenness_centrality(outgoing, normalized=normalized)
elapsed = time.monotonic() - started
if elapsed >= _CACHE_MIN_SECONDS:
entries = cache.get("betweenness", {})
if not isinstance(entries, dict):
entries = {}
entries[key] = {n: round(v, 10) for n, v in scores.items()}
# Keep the cache bounded — most recent keys win.
for old in list(entries)[:-_CACHE_MAX_ENTRIES]:
entries.pop(old, None)
_write_cache(vault, {"version": 1, "betweenness": entries})
return scores
def bridge_pages(
outgoing: dict[str, list[str]],
communities: list[set[str]],
top_n: int = 10,
vault: Path | None = None,
) -> list[dict]:
"""Pages with the highest betweenness — removing them fragments the graph.
Each entry lists which communities the page's neighbours belong to, so the
caller can say "`P` bridges cluster A ↔ cluster B".
"""
bc = betweenness_cached(outgoing, vault)
if not bc:
return []
adj = _undirected_adj(outgoing)
node_comm = _node_community_map(communities)
ranked = sorted((n for n, s in bc.items() if s > 0), key=lambda n: -bc[n])[:top_n]
out = []
for n in ranked:
own = node_comm.get(n)
others = sorted({node_comm[nb] for nb in adj[n] if nb in node_comm and node_comm[nb] != own})
out.append({
"page": n,
"betweenness": round(bc[n], 4),
"community": own,
"connects": others,
})
return out
# ---------------------------------------------------------------------------
# Surprising connections
# ---------------------------------------------------------------------------
def surprising_connections(
outgoing: dict[str, list[str]],
communities: list[set[str]],
top_n: int = 20,
) -> list[dict]:
"""Edges that cross community boundaries, ranked by unexpectedness.
Score = 1 / sqrt(cross_degree(source) * cross_degree(target))
Low cross-degree nodes connected across communities are the most surprising.
Results are ordered so that each (community A, community B) boundary is
represented once before any boundary repeats — graphify's dedup, which
stops one high-betweenness hub from filling every slot.
"""
node_comm = _node_community_map(communities)
# Count how many cross-community edges each node already has
cross_deg: dict[str, int] = defaultdict(int)
for src, targets in outgoing.items():
for t in targets:
if node_comm.get(src) != node_comm.get(t):
cross_deg[src] += 1
cross_deg[t] += 1
results = []
seen: set[tuple[str, str]] = set()
for src, targets in outgoing.items():
for t in targets:
pair = tuple(sorted((src, t)))
if pair in seen:
continue
if node_comm.get(src) != node_comm.get(t):
cd_s = cross_deg.get(src, 1)
cd_t = cross_deg.get(t, 1)
score = 1.0 / math.sqrt(cd_s * cd_t)
cs, ct = node_comm.get(src), node_comm.get(t)
results.append({
"source": src, "target": t, "score": round(score, 4),
"note": f"bridges community {cs} -> community {ct}",
"_pair": (min(cs, ct), max(cs, ct)) if cs is not None and ct is not None else None,
})
seen.add(pair)
results.sort(key=lambda x: -x["score"])
# One representative per community pair first, then the rest by score.
seen_pairs: set = set()
first: list[dict] = []
rest: list[dict] = []
for r in results:
pair = r.pop("_pair")
if pair not in seen_pairs:
seen_pairs.add(pair)
first.append(r)
else:
rest.append(r)
return (first + rest)[:top_n]
# ---------------------------------------------------------------------------
# Path finding & neighbourhoods (graphify: `path`, `affected` blast radius)
# ---------------------------------------------------------------------------
def _node_for(outgoing: dict[str, list[str]], term: str) -> str:
"""Node id for a page reference: an exact id, else the shortest id whose
path ends with it. Pages that share a name have path-qualified ids, so a
bare name has to be matched by suffix."""
term = "/".join(_slug(part) for part in term.strip().removesuffix(".md").split("/") if part)
if term in outgoing:
return term
matches = sorted((n for n in outgoing if n.endswith(f"/{term}")), key=lambda n: (n.count("/"), n))
return matches[0] if matches else term
def shortest_path(
outgoing: dict[str, list[str]],
source: str,
target: str,
directed: bool = False,
) -> list[str] | None:
"""BFS shortest path between two pages. Follows links in either direction
unless `directed=True`. Returns the node list, or None if unreachable."""
source, target = _node_for(outgoing, source), _node_for(outgoing, target)
if source not in outgoing or target not in outgoing:
return None
if source == target:
return [source]
if directed:
adj: dict[str, Any] = {n: set(v) for n, v in outgoing.items()}
else:
adj = _undirected_adj(outgoing)
prev: dict[str, str | None] = {source: None}
queue = [source]
qi = 0
while qi < len(queue):
v = queue[qi]
qi += 1
for w in sorted(adj.get(v, ())):
if w in prev:
continue
prev[w] = v
if w == target:
path = [w]
while prev[path[-1]] is not None:
path.append(prev[path[-1]]) # type: ignore[arg-type]
return path[::-1]
queue.append(w)
return None
def neighborhood(
outgoing: dict[str, list[str]],
seed: str,
depth: int = 2,
direction: str = "both",
) -> list[dict]:
"""Breadth-first neighbourhood of a page up to `depth` hops.
direction: "out" (pages this one links to), "in" (pages linking here —
the blast radius if this page is renamed/removed), or "both".
Returns [{"page", "depth", "via"}] ordered by depth then name.
"""
seed = _node_for(outgoing, seed)
if seed not in outgoing:
return []
incoming: dict[str, set[str]] = defaultdict(set)
for s, targets in outgoing.items():
for t in targets:
incoming[t].add(s)
def nbrs(n: str) -> set[str]:
out: set[str] = set()
if direction in ("out", "both"):
out |= set(outgoing.get(n, ()))
if direction in ("in", "both"):
out |= incoming.get(n, set())
out.discard(n)
return out
seen = {seed: 0}
via = {seed: None}
frontier = [seed]
for d in range(1, depth + 1):
nxt = []
for v in frontier:
for w in sorted(nbrs(v)):
if w not in seen:
seen[w] = d
via[w] = v
nxt.append(w)
frontier = nxt
if not frontier:
break
return [
{"page": n, "depth": d, "via": via[n]}
for n, d in sorted(seen.items(), key=lambda kv: (kv[1], kv[0]))
if n != seed
]
# ---------------------------------------------------------------------------
# Graph diff (graphify: analyze.graph_diff)
# ---------------------------------------------------------------------------
_SNAPSHOT_RE = re.compile(r"<!--\s*GRAPH_SNAPSHOT:\s*(\{.*?\})\s*-->", re.DOTALL)
def snapshot(outgoing: dict[str, list[str]]) -> dict[str, list]:
"""Compact, JSON-serialisable snapshot for embedding in `_insights.md`."""
edges = sorted({(s, t) for s, ts in outgoing.items() for t in ts})
return {"nodes": sorted(outgoing), "edges": [list(e) for e in edges]}
def load_snapshot(path: Path) -> dict[str, list] | None:
"""Read a snapshot from a JSON file or from the `<!-- GRAPH_SNAPSHOT: ... -->`
comment inside a previous `_insights.md`. Returns None if not found."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
m = _SNAPSHOT_RE.search(text)
raw = m.group(1) if m else text
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
if not isinstance(data, dict) or "nodes" not in data:
return None
return {"nodes": list(data.get("nodes", [])), "edges": [list(e) for e in data.get("edges", [])]}
def graph_diff(old: dict[str, list], new: dict[str, list]) -> dict[str, Any]:
"""What changed between two snapshots (see `snapshot`)."""
old_nodes, new_nodes = set(old.get("nodes", [])), set(new.get("nodes", []))
old_edges = {tuple(e) for e in old.get("edges", [])}
new_edges = {tuple(e) for e in new.get("edges", [])}
def in_targets(edges: set) -> set[str]:
return {t for _, t in edges}
old_in, new_in = in_targets(old_edges), in_targets(new_edges)
added_edges = sorted(new_edges - old_edges)
removed_edges = sorted(old_edges - new_edges)
return {
"added_pages": sorted(new_nodes - old_nodes),
"removed_pages": sorted(old_nodes - new_nodes),
"added_edges": [list(e) for e in added_edges],
"removed_edges": [list(e) for e in removed_edges],
"newly_connected": sorted((new_in - old_in) & old_nodes),
"lost_incoming": sorted((old_in - new_in) & new_nodes),
"summary": {
"pages": len(new_nodes) - len(old_nodes),
"edges": len(new_edges) - len(old_edges),
},
}
# ---------------------------------------------------------------------------
# Suggested questions (graphify: analyze.suggest_questions)
# ---------------------------------------------------------------------------
def suggest_questions(
outgoing: dict[str, list[str]],
communities: list[set[str]],
labels: dict[int, str],
bridges: list[dict],
top_n: int = 7,
) -> list[dict]:
"""Questions the graph structure is uniquely positioned to raise.
Priority: bridge pages → dead-end hubs → isolated pages → low-cohesion
communities. Each has `type`, `question`, `why`.
"""
questions: list[dict] = []
degree, in_deg, out_deg = _build_degree_tables(outgoing)
# 1. Bridge pages -> cross-cutting questions
for b in bridges[:3]:
if not b["connects"]:
continue
own = labels.get(b["community"], f"community {b['community']}")
others = ", ".join(f"`{labels.get(c, f'community {c}')}`" for c in b["connects"])
questions.append({
"type": "bridge_node",
"question": f"Why does `{b['page']}` connect `{own}` to {others}?",
"why": f"High betweenness ({b['betweenness']}) — this page is a cross-community bridge.",
})
# 2. Sink hubs: many incoming, no outgoing
sinks = sorted(
(n for n in outgoing if out_deg.get(n, 0) == 0 and in_deg.get(n, 0) >= 3),
key=lambda n: -in_deg[n],
)[:2]
for n in sinks:
questions.append({
"type": "sink_hub",
"question": f"What should `{n}` link out to?",
"why": f"{in_deg[n]} pages point at it but it links nowhere — a dead end in a busy area.",
})
# 3. Isolated / weakly connected pages
weak = sorted(n for n in outgoing if degree.get(n, 0) <= 1)
if weak:
shown = ", ".join(f"`{n}`" for n in weak[:3])
questions.append({
"type": "isolated_nodes",
"question": f"What connects {shown} to the rest of the vault?",
"why": f"{len(weak)} weakly-connected pages — possible gaps or missing links.",
})
# 4. Low-cohesion communities
for i, comm in enumerate(communities):
if len(comm) >= 5:
c = cohesion_score(outgoing, comm)
if c < 0.15:
questions.append({
"type": "low_cohesion",
"question": f"Should `{labels.get(i, f'community {i}')}` be split into more focused topics?",
"why": f"Cohesion {c} across {len(comm)} pages — they share a cluster but are weakly interlinked.",
})
if not questions:
return [{
"type": "no_signal",
"question": None,
"why": "Not enough structure to raise questions — no bridges, sink hubs, isolates or fragmented clusters.",
}]
return questions[:top_n]
# ---------------------------------------------------------------------------
# Community labelling (heuristic: most common tag or longest shared prefix)
# ---------------------------------------------------------------------------
def _label_community(
pages: list[str],
tags_map: dict[str, list[str]],
taken: set[str] | None = None,
) -> str:
"""Most common tag; if that label is already `taken` by a bigger community,
qualify it with the next most common tag (`ai-security/roadmap`) or a
top page name so every community label is unique."""
freq: dict[str, int] = defaultdict(int)
for p in pages:
for tag in tags_map.get(p, []):
if not tag.startswith("visibility/"):
freq[tag] += 1
if freq:
ranked = sorted(freq, key=lambda t: (-freq[t], t))
label = ranked[0]
if taken is not None and label in taken:
for alt in ranked[1:]:
cand = f"{label}/{alt}"
if cand not in taken:
return cand
return f"{label}/{pages[0]}"
return label
# Fallback: shared prefix of page names
if len(pages) == 1:
return pages[0]
prefix = pages[0]
for p in pages[1:]:
while not p.startswith(prefix):
prefix = prefix[:-1]
if not prefix:
break
return prefix.rstrip("-_") or f"cluster-{pages[0][:20]}"
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def analyse_vault(
vault: Path,
top_n: int = 20,
previous_snapshot: dict[str, list] | None = None,
include_snapshot: bool = False,
) -> dict[str, Any]:
outgoing, tags_map = parse_vault_graph(vault)
if not outgoing:
return {
"god_nodes": [], "bridges": [], "communities": [],
"surprising_connections": [], "suggested_questions": [],
"dead_ends": [], "isolated": [],
"stats": {"pages": 0, "edges": 0, "communities": 0, "density": 0.0},
}
communities_raw = detect_communities(outgoing)
# Sort communities largest-first
communities_raw.sort(key=lambda c: -len(c))
labels: dict[int, str] = {}
taken: set[str] = set()
for i, c in enumerate(communities_raw):
lbl = _label_community(sorted(c), tags_map, taken)
labels[i] = lbl
taken.add(lbl)
total_edges = sum(len(v) for v in outgoing.values())
n_pages = len(outgoing)
undirected_edges = sum(len(v) for v in _undirected_adj(outgoing).values()) // 2
density = undirected_edges / (n_pages * (n_pages - 1) / 2) if n_pages > 1 else 0.0
bridges = bridge_pages(outgoing, communities_raw, top_n=min(top_n, 10), vault=vault)
result: dict[str, Any] = {
"god_nodes": god_nodes(outgoing, top_n),
"bridges": bridges,
"communities": [
{
"id": i,
"size": len(comm),
"pages": sorted(comm),
"label": labels[i],
"cohesion": cohesion_score(outgoing, comm),
}
for i, comm in enumerate(communities_raw)
],
"surprising_connections": surprising_connections(outgoing, communities_raw, top_n),
"suggested_questions": suggest_questions(outgoing, communities_raw, labels, bridges),
"dead_ends": dead_ends(outgoing),
"isolated": isolated(outgoing),
"stats": {
"pages": n_pages,
"edges": total_edges,
"communities": len(communities_raw),
"density": round(density, 4),
},
}
if previous_snapshot is not None:
result["diff"] = graph_diff(previous_snapshot, snapshot(outgoing))
if include_snapshot:
result["snapshot"] = snapshot(outgoing)
return result