graphmark 0.1.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.
- graphmark/__init__.py +3 -0
- graphmark/cli.py +101 -0
- graphmark/config.py +44 -0
- graphmark/dismiss.py +59 -0
- graphmark/export.py +24 -0
- graphmark/graph.py +107 -0
- graphmark/interfaces.py +33 -0
- graphmark/metrics.py +211 -0
- graphmark/model.py +44 -0
- graphmark/parse.py +72 -0
- graphmark-0.1.0.dist-info/METADATA +57 -0
- graphmark-0.1.0.dist-info/RECORD +15 -0
- graphmark-0.1.0.dist-info/WHEEL +4 -0
- graphmark-0.1.0.dist-info/entry_points.txt +2 -0
- graphmark-0.1.0.dist-info/licenses/LICENSE +21 -0
graphmark/__init__.py
ADDED
graphmark/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Command-line interface for graphmark."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from graphmark.config import VaultConfig, load_config
|
|
10
|
+
from graphmark.export import to_dot, to_json
|
|
11
|
+
from graphmark.graph import NormalizeResolver, VaultGraph
|
|
12
|
+
from graphmark.metrics import (
|
|
13
|
+
bridges,
|
|
14
|
+
clusters,
|
|
15
|
+
gaps,
|
|
16
|
+
hubs,
|
|
17
|
+
neighborhood,
|
|
18
|
+
orphans,
|
|
19
|
+
pagerank,
|
|
20
|
+
siloed_notes,
|
|
21
|
+
stats,
|
|
22
|
+
)
|
|
23
|
+
from graphmark.parse import WikilinkExtractor
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load(args: argparse.Namespace) -> tuple[VaultGraph, VaultConfig]:
|
|
27
|
+
if args.config is not None:
|
|
28
|
+
config = load_config(Path(args.config))
|
|
29
|
+
if args.root is not None:
|
|
30
|
+
config.root = Path(args.root)
|
|
31
|
+
elif args.root is not None:
|
|
32
|
+
config = VaultConfig(root=Path(args.root))
|
|
33
|
+
else:
|
|
34
|
+
print("error: --config or --root required", file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
graph = VaultGraph.build(config, WikilinkExtractor(), NormalizeResolver())
|
|
37
|
+
return graph, config
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
parser = argparse.ArgumentParser(prog="graphmark")
|
|
42
|
+
parser.add_argument("--config", metavar="PATH", help="TOML config file")
|
|
43
|
+
parser.add_argument("--root", metavar="PATH", help="Vault root (overrides --config root)")
|
|
44
|
+
|
|
45
|
+
sub = parser.add_subparsers(dest="command")
|
|
46
|
+
|
|
47
|
+
sub.add_parser("stats")
|
|
48
|
+
sub.add_parser("orphans")
|
|
49
|
+
|
|
50
|
+
hubs_p = sub.add_parser("hubs")
|
|
51
|
+
hubs_p.add_argument("--n", type=int, default=10)
|
|
52
|
+
|
|
53
|
+
sub.add_parser("clusters")
|
|
54
|
+
sub.add_parser("bridges")
|
|
55
|
+
sub.add_parser("siloed")
|
|
56
|
+
|
|
57
|
+
nb_p = sub.add_parser("neighborhood")
|
|
58
|
+
nb_p.add_argument("--note", required=True)
|
|
59
|
+
nb_p.add_argument("--depth", type=int, default=1)
|
|
60
|
+
|
|
61
|
+
pr_p = sub.add_parser("pagerank")
|
|
62
|
+
pr_p.add_argument("--n", type=int, default=10)
|
|
63
|
+
pr_p.add_argument("--alpha", type=float, default=0.85)
|
|
64
|
+
|
|
65
|
+
exp_p = sub.add_parser("export")
|
|
66
|
+
exp_p.add_argument("format", choices=["dot"])
|
|
67
|
+
|
|
68
|
+
sub.add_parser("gaps")
|
|
69
|
+
|
|
70
|
+
args = parser.parse_args()
|
|
71
|
+
|
|
72
|
+
if args.command is None:
|
|
73
|
+
parser.print_help()
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
graph, config = _load(args)
|
|
77
|
+
|
|
78
|
+
if args.command == "stats":
|
|
79
|
+
print(to_json(stats(graph)))
|
|
80
|
+
elif args.command == "orphans":
|
|
81
|
+
print(to_json(orphans(graph, config)))
|
|
82
|
+
elif args.command == "hubs":
|
|
83
|
+
print(to_json(hubs(graph, n=args.n)))
|
|
84
|
+
elif args.command == "clusters":
|
|
85
|
+
print(to_json(clusters(graph)))
|
|
86
|
+
elif args.command == "bridges":
|
|
87
|
+
print(to_json(bridges(graph)))
|
|
88
|
+
elif args.command == "siloed":
|
|
89
|
+
print(to_json(siloed_notes(graph)))
|
|
90
|
+
elif args.command == "neighborhood":
|
|
91
|
+
print(to_json(neighborhood(graph, args.note, depth=args.depth)))
|
|
92
|
+
elif args.command == "pagerank":
|
|
93
|
+
print(to_json(pagerank(graph, n=args.n, alpha=args.alpha)))
|
|
94
|
+
elif args.command == "export" and args.format == "dot":
|
|
95
|
+
print(to_dot(graph))
|
|
96
|
+
elif args.command == "gaps":
|
|
97
|
+
print(to_json(gaps(graph, lambda _rel, _k: [])))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
graphmark/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Vault configuration — the domain seam that makes the engine general.
|
|
2
|
+
|
|
3
|
+
``VaultConfig`` holds every vault-specific policy the engine consults. ``load_config`` (TOML →
|
|
4
|
+
VaultConfig) is intentionally left unimplemented; wiring config through the engine is afk Issue #3.
|
|
5
|
+
Fixture tests may construct ``VaultConfig`` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class VaultConfig:
|
|
17
|
+
"""All vault-specific behavior, parametrized."""
|
|
18
|
+
|
|
19
|
+
root: Path
|
|
20
|
+
scoped_folders: list[str] = field(default_factory=list)
|
|
21
|
+
excluded_dirs: list[str] = field(default_factory=list)
|
|
22
|
+
rules_files: list[str] = field(default_factory=lambda: ["CLAUDE.md", "CLAUDE.local.md"])
|
|
23
|
+
wikilink_pattern: str = r"\[\[(.+?)\]\]"
|
|
24
|
+
orphan_min_chars: int = 300
|
|
25
|
+
transient_prefixes: tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config(path: Path) -> VaultConfig:
|
|
29
|
+
"""Load a VaultConfig from a TOML file."""
|
|
30
|
+
with open(path, "rb") as f:
|
|
31
|
+
data = tomllib.load(f)
|
|
32
|
+
|
|
33
|
+
toml_dir = path.parent
|
|
34
|
+
root = toml_dir / data["root"]
|
|
35
|
+
|
|
36
|
+
return VaultConfig(
|
|
37
|
+
root=root,
|
|
38
|
+
scoped_folders=data.get("scoped_folders", []),
|
|
39
|
+
excluded_dirs=data.get("excluded_dirs", []),
|
|
40
|
+
rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
|
|
41
|
+
wikilink_pattern=data.get("wikilink_pattern", r"\[\[(.+?)\]\]"),
|
|
42
|
+
orphan_min_chars=data.get("orphan_min_chars", 300),
|
|
43
|
+
transient_prefixes=tuple(data.get("transient_prefixes", [])),
|
|
44
|
+
)
|
graphmark/dismiss.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Dismissal store with content-hash staleness for weaklink suggestions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_DEFAULT_PATH = ".claude/data/connect-dismissed.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def weaklink_sig(a: str, b: str) -> str:
|
|
13
|
+
return "weaklink|" + "|".join(sorted([a, b]))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def content_hash(path: Path) -> str:
|
|
17
|
+
return hashlib.sha1(path.read_bytes()).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def record_dismissal(root: Path, a: str, b: str, *, path: str = _DEFAULT_PATH) -> None:
|
|
21
|
+
dismissed_file = root / path
|
|
22
|
+
dismissed_file.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
existing = {}
|
|
24
|
+
if dismissed_file.exists():
|
|
25
|
+
try:
|
|
26
|
+
existing = json.loads(dismissed_file.read_text())
|
|
27
|
+
except (json.JSONDecodeError, OSError):
|
|
28
|
+
existing = {}
|
|
29
|
+
sig = weaklink_sig(a, b)
|
|
30
|
+
existing[sig] = {
|
|
31
|
+
"a": a,
|
|
32
|
+
"a_hash": content_hash(root / a),
|
|
33
|
+
"b": b,
|
|
34
|
+
"b_hash": content_hash(root / b),
|
|
35
|
+
}
|
|
36
|
+
dismissed_file.write_text(json.dumps(existing, indent=2))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_dismissed(root: Path, *, path: str = _DEFAULT_PATH) -> dict:
|
|
40
|
+
dismissed_file = root / path
|
|
41
|
+
if not dismissed_file.exists():
|
|
42
|
+
return {}
|
|
43
|
+
return json.loads(dismissed_file.read_text())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def active_dismissed_sigs(root: Path, *, path: str = _DEFAULT_PATH) -> set[str]:
|
|
47
|
+
dismissed = load_dismissed(root, path=path)
|
|
48
|
+
active: set[str] = set()
|
|
49
|
+
for sig, record in dismissed.items():
|
|
50
|
+
a_path = root / record["a"]
|
|
51
|
+
b_path = root / record["b"]
|
|
52
|
+
if (
|
|
53
|
+
a_path.exists()
|
|
54
|
+
and b_path.exists()
|
|
55
|
+
and content_hash(a_path) == record["a_hash"]
|
|
56
|
+
and content_hash(b_path) == record["b_hash"]
|
|
57
|
+
):
|
|
58
|
+
active.add(sig)
|
|
59
|
+
return active
|
graphmark/export.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Export helpers: JSON serialisation and Graphviz DOT output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from graphmark.graph import VaultGraph
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_json(obj) -> str:
|
|
11
|
+
"""Serialise any JSON-serialisable object to a string."""
|
|
12
|
+
return json.dumps(obj)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_dot(graph: VaultGraph) -> str:
|
|
16
|
+
"""Emit a Graphviz digraph containing every node and directed edge."""
|
|
17
|
+
lines = ["digraph G {"]
|
|
18
|
+
for node in sorted(graph.nodes):
|
|
19
|
+
lines.append(f' "{node}";')
|
|
20
|
+
for src in sorted(graph.out_links):
|
|
21
|
+
for dst in sorted(graph.out_links[src]):
|
|
22
|
+
lines.append(f' "{src}" -> "{dst}";')
|
|
23
|
+
lines.append("}")
|
|
24
|
+
return "\n".join(lines)
|
graphmark/graph.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Graph construction: catalog building, link resolution, and VaultGraph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import string
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from graphmark.config import VaultConfig
|
|
9
|
+
from graphmark.interfaces import LinkExtractor, Resolver
|
|
10
|
+
from graphmark.model import Document
|
|
11
|
+
from graphmark.parse import parse_document
|
|
12
|
+
|
|
13
|
+
_PUNCT_TABLE = str.maketrans(string.punctuation, " " * len(string.punctuation))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize(text: str) -> str:
|
|
17
|
+
"""Lowercase, replace punctuation with spaces, collapse whitespace."""
|
|
18
|
+
return " ".join(text.lower().translate(_PUNCT_TABLE).split())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
|
|
22
|
+
"""Map normalized stem → list of rel_paths (len > 1 means ambiguous)."""
|
|
23
|
+
catalog: dict[str, list[str]] = {}
|
|
24
|
+
for doc in docs:
|
|
25
|
+
key = _normalize(Path(doc.rel_path).stem)
|
|
26
|
+
catalog.setdefault(key, []).append(doc.rel_path)
|
|
27
|
+
return catalog
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NormalizeResolver:
|
|
31
|
+
"""Resolves wikilink displays via normalized basename, with path-suffix fallback."""
|
|
32
|
+
|
|
33
|
+
def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
|
|
34
|
+
# Strip alias: "Note|alias" → "Note"
|
|
35
|
+
display = display.split("|")[0]
|
|
36
|
+
# Strip anchor: "Note#Section" → "Note"
|
|
37
|
+
display = display.split("#")[0]
|
|
38
|
+
|
|
39
|
+
if "/" in display:
|
|
40
|
+
# Path-suffix resolution: find unique rel_path ending with "display.md"
|
|
41
|
+
suffix = display.lower() + ".md"
|
|
42
|
+
all_paths = [p for paths in catalog.values() for p in paths]
|
|
43
|
+
matches = [p for p in all_paths if p.lower().endswith(suffix)]
|
|
44
|
+
return matches[0] if len(matches) == 1 else None
|
|
45
|
+
|
|
46
|
+
# Bare-link resolution: normalize and look up in catalog
|
|
47
|
+
key = _normalize(display)
|
|
48
|
+
paths = catalog.get(key)
|
|
49
|
+
if paths is None or len(paths) != 1:
|
|
50
|
+
return None
|
|
51
|
+
return paths[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class VaultGraph:
|
|
55
|
+
"""Built graph: all nodes plus resolved out/back adjacency."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
nodes: dict[str, Document],
|
|
60
|
+
out_links: dict[str, set[str]],
|
|
61
|
+
back_links: dict[str, set[str]],
|
|
62
|
+
) -> None:
|
|
63
|
+
self.nodes = nodes
|
|
64
|
+
self.out_links = out_links
|
|
65
|
+
self.back_links = back_links
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def build(
|
|
69
|
+
cls,
|
|
70
|
+
config: VaultConfig,
|
|
71
|
+
extractor: LinkExtractor,
|
|
72
|
+
resolver: Resolver,
|
|
73
|
+
) -> VaultGraph:
|
|
74
|
+
root = config.root
|
|
75
|
+
excluded = set(config.excluded_dirs)
|
|
76
|
+
rules = set(config.rules_files)
|
|
77
|
+
|
|
78
|
+
scoped = set(config.scoped_folders)
|
|
79
|
+
md_files: list[Path] = []
|
|
80
|
+
for path in sorted(root.rglob("*.md")):
|
|
81
|
+
rel_parts = path.relative_to(root).parts
|
|
82
|
+
if scoped and rel_parts[0] not in scoped:
|
|
83
|
+
continue
|
|
84
|
+
if any(p in excluded for p in rel_parts[:-1]):
|
|
85
|
+
continue
|
|
86
|
+
if path.name in rules:
|
|
87
|
+
continue
|
|
88
|
+
md_files.append(path)
|
|
89
|
+
|
|
90
|
+
docs = [parse_document(p, root) for p in md_files]
|
|
91
|
+
nodes = {doc.rel_path: doc for doc in docs}
|
|
92
|
+
catalog = build_catalog(docs)
|
|
93
|
+
|
|
94
|
+
out_links: dict[str, set[str]] = {rel: set() for rel in nodes}
|
|
95
|
+
back_links: dict[str, set[str]] = {rel: set() for rel in nodes}
|
|
96
|
+
|
|
97
|
+
for doc in docs:
|
|
98
|
+
for display in extractor.extract(doc.text):
|
|
99
|
+
target = resolver.resolve(display, catalog)
|
|
100
|
+
if target is not None and target != doc.rel_path:
|
|
101
|
+
out_links[doc.rel_path].add(target)
|
|
102
|
+
|
|
103
|
+
for src, targets in out_links.items():
|
|
104
|
+
for dst in targets:
|
|
105
|
+
back_links[dst].add(src)
|
|
106
|
+
|
|
107
|
+
return cls(nodes, out_links, back_links)
|
graphmark/interfaces.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Pluggable interfaces — the generality seams.
|
|
2
|
+
|
|
3
|
+
Only the default wikilink/normalize implementations are required now; these Protocols exist so
|
|
4
|
+
alternate link syntaxes and resolution strategies can be added later without touching the engine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LinkExtractor(Protocol):
|
|
13
|
+
"""Extracts raw link displays from note text."""
|
|
14
|
+
|
|
15
|
+
def extract(self, text: str) -> list[str]:
|
|
16
|
+
"""Return raw link displays found in ``text``, excluding links inside code spans.
|
|
17
|
+
|
|
18
|
+
A "display" is the inner text of a link before resolution, e.g. ``[[Note|alias]]`` yields
|
|
19
|
+
``"Note|alias"`` (alias/anchor stripping happens in the Resolver).
|
|
20
|
+
"""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Resolver(Protocol):
|
|
25
|
+
"""Resolves a link display to a target note."""
|
|
26
|
+
|
|
27
|
+
def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
|
|
28
|
+
"""Resolve ``display`` to a target rel_path, or ``None`` if unresolved/ambiguous.
|
|
29
|
+
|
|
30
|
+
``catalog`` maps a normalized note key to the list of rel_paths sharing that key (length > 1
|
|
31
|
+
means a collision → a bare link to it is ambiguous and must not resolve).
|
|
32
|
+
"""
|
|
33
|
+
...
|
graphmark/metrics.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Structural metrics computed over a VaultGraph.
|
|
2
|
+
|
|
3
|
+
All outputs reproduce the reference engine (brain_map.py) exactly — shape,
|
|
4
|
+
ordering, and tie-breaking are pinned by tests/fixtures/simple/expected.json.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
|
|
11
|
+
from graphmark.config import VaultConfig
|
|
12
|
+
from graphmark.graph import VaultGraph
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _undirected(graph: VaultGraph) -> nx.Graph:
|
|
16
|
+
G: nx.Graph = nx.Graph()
|
|
17
|
+
G.add_nodes_from(graph.nodes.keys())
|
|
18
|
+
for src, targets in graph.out_links.items():
|
|
19
|
+
for dst in targets:
|
|
20
|
+
G.add_edge(src, dst)
|
|
21
|
+
return G
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def stats(graph: VaultGraph) -> dict:
|
|
25
|
+
"""Return aggregate vault stats matching the oracle schema."""
|
|
26
|
+
G = _undirected(graph)
|
|
27
|
+
notes = len(graph.nodes)
|
|
28
|
+
edges = sum(len(v) for v in graph.out_links.values())
|
|
29
|
+
n_orphans = sum(1 for n in G.nodes if G.degree(n) == 0)
|
|
30
|
+
n_clusters = sum(1 for c in nx.connected_components(G) if len(c) > 1)
|
|
31
|
+
density = round(edges / notes, 2) if notes > 0 else 0.0
|
|
32
|
+
return {
|
|
33
|
+
"notes": notes,
|
|
34
|
+
"edges": edges,
|
|
35
|
+
"orphans": n_orphans,
|
|
36
|
+
"clusters": n_clusters,
|
|
37
|
+
"density": density,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def orphans(graph: VaultGraph, config: VaultConfig) -> list[str]:
|
|
42
|
+
"""Return degree-0 nodes, excluding those matching transient_prefixes, sorted."""
|
|
43
|
+
G = _undirected(graph)
|
|
44
|
+
result = []
|
|
45
|
+
for node in G.nodes:
|
|
46
|
+
if G.degree(node) != 0:
|
|
47
|
+
continue
|
|
48
|
+
if any(node.startswith(prefix) for prefix in config.transient_prefixes):
|
|
49
|
+
continue
|
|
50
|
+
result.append(node)
|
|
51
|
+
return sorted(result)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hubs(graph: VaultGraph, n: int = 10) -> list[list]:
|
|
55
|
+
"""Return top-n nodes by undirected degree (degree > 0), ties broken by path order."""
|
|
56
|
+
G = _undirected(graph)
|
|
57
|
+
degree_pairs = [(node, G.degree(node)) for node in G.nodes if G.degree(node) > 0]
|
|
58
|
+
degree_pairs.sort(key=lambda x: (-x[1], x[0]))
|
|
59
|
+
return [[path, degree] for path, degree in degree_pairs[:n]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def clusters(graph: VaultGraph) -> list[list[str]]:
|
|
63
|
+
"""Return connected components with >1 node, size-desc, members sorted."""
|
|
64
|
+
G = _undirected(graph)
|
|
65
|
+
components = [sorted(c) for c in nx.connected_components(G) if len(c) > 1]
|
|
66
|
+
components.sort(key=lambda c: -len(c))
|
|
67
|
+
return components
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bridges(graph: VaultGraph) -> list[str]:
|
|
71
|
+
"""Return articulation points of the undirected graph, sorted."""
|
|
72
|
+
G = _undirected(graph)
|
|
73
|
+
return sorted(nx.articulation_points(G))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def siloed_notes(graph: VaultGraph) -> list[str]:
|
|
77
|
+
"""Return notes reachable from the mainland only through a single articulation point.
|
|
78
|
+
|
|
79
|
+
For each bridge b: remove b, find connected components sorted size-desc; every
|
|
80
|
+
component except the largest (mainland) is an island. Collect all island nodes
|
|
81
|
+
(including degree-0 orphans that become singleton islands). Return sorted unique list.
|
|
82
|
+
"""
|
|
83
|
+
G = _undirected(graph)
|
|
84
|
+
island_nodes: set[str] = set()
|
|
85
|
+
for bridge in nx.articulation_points(G):
|
|
86
|
+
H = G.copy()
|
|
87
|
+
H.remove_node(bridge)
|
|
88
|
+
components = sorted(nx.connected_components(H), key=lambda c: -len(c))
|
|
89
|
+
for component in components[1:]:
|
|
90
|
+
island_nodes.update(component)
|
|
91
|
+
return sorted(island_nodes)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pagerank(graph: VaultGraph, n: int = 10, alpha: float = 0.85) -> list[list]:
|
|
95
|
+
"""Pure-python power iteration PageRank (matches networkx _pagerank_python).
|
|
96
|
+
|
|
97
|
+
Dangling nodes (no out-edges) redistribute mass uniformly across all nodes.
|
|
98
|
+
Returns top-n [path, score] pairs sorted score-desc then path-asc.
|
|
99
|
+
"""
|
|
100
|
+
nodes = list(graph.nodes.keys())
|
|
101
|
+
N = len(nodes)
|
|
102
|
+
if N == 0:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
out_links = {node: graph.out_links.get(node, set()) for node in nodes}
|
|
106
|
+
dangling = [node for node in nodes if not out_links[node]]
|
|
107
|
+
dangling_weight = 1.0 / N
|
|
108
|
+
|
|
109
|
+
x: dict[str, float] = {node: 1.0 / N for node in nodes}
|
|
110
|
+
|
|
111
|
+
for _ in range(100):
|
|
112
|
+
xlast = x
|
|
113
|
+
x = dict.fromkeys(xlast, 0.0)
|
|
114
|
+
danglesum = alpha * sum(xlast[node] for node in dangling)
|
|
115
|
+
|
|
116
|
+
for src in nodes:
|
|
117
|
+
out = out_links[src]
|
|
118
|
+
if out:
|
|
119
|
+
share = alpha * xlast[src] / len(out)
|
|
120
|
+
for dst in out:
|
|
121
|
+
x[dst] += share
|
|
122
|
+
|
|
123
|
+
for node in nodes:
|
|
124
|
+
x[node] += danglesum * dangling_weight + (1.0 - alpha) * dangling_weight
|
|
125
|
+
|
|
126
|
+
err = sum(abs(x[node] - xlast[node]) for node in nodes)
|
|
127
|
+
if err < N * 1e-6:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
pairs = sorted(x.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
131
|
+
return [[path, score] for path, score in pairs[:n]]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def gaps(
|
|
135
|
+
graph: VaultGraph,
|
|
136
|
+
similar_fn,
|
|
137
|
+
threshold: float = 0.0,
|
|
138
|
+
k: int = 5,
|
|
139
|
+
note: str | None = None,
|
|
140
|
+
dismissed: set | frozenset = frozenset(),
|
|
141
|
+
exclude_prefixes: tuple = (),
|
|
142
|
+
max_score: float | None = None,
|
|
143
|
+
hub_degree: int | None = None,
|
|
144
|
+
targets: list | None = None,
|
|
145
|
+
) -> list[dict]:
|
|
146
|
+
"""Return link-gap suggestions ranked by novelty (non-hub, cross-folder, score-desc)."""
|
|
147
|
+
if note is not None:
|
|
148
|
+
target_list = [note]
|
|
149
|
+
elif targets is not None:
|
|
150
|
+
target_list = targets
|
|
151
|
+
else:
|
|
152
|
+
target_list = list(graph.nodes.keys())
|
|
153
|
+
|
|
154
|
+
G = _undirected(graph)
|
|
155
|
+
|
|
156
|
+
def _hub(r: str) -> bool:
|
|
157
|
+
return hub_degree is not None and G.degree(r) >= hub_degree
|
|
158
|
+
|
|
159
|
+
dedup_map: dict = {}
|
|
160
|
+
|
|
161
|
+
for rel in target_list:
|
|
162
|
+
if any(rel.startswith(p) for p in exclude_prefixes):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
linked = graph.out_links.get(rel, set()) | graph.back_links.get(rel, set())
|
|
166
|
+
|
|
167
|
+
for other, score in similar_fn(rel, k):
|
|
168
|
+
if other == rel:
|
|
169
|
+
continue
|
|
170
|
+
if other in linked:
|
|
171
|
+
continue
|
|
172
|
+
if score < threshold:
|
|
173
|
+
continue
|
|
174
|
+
if any(other.startswith(p) for p in exclude_prefixes):
|
|
175
|
+
continue
|
|
176
|
+
if max_score is not None and score > max_score:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
sig = "weaklink|" + "|".join(sorted([rel, other]))
|
|
180
|
+
if sig in dismissed:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
key = frozenset({rel, other})
|
|
184
|
+
if key not in dedup_map or score > dedup_map[key][2]:
|
|
185
|
+
dedup_map[key] = (rel, other, score, sig)
|
|
186
|
+
|
|
187
|
+
def _rank_key(item):
|
|
188
|
+
a, b, score, _sig = item
|
|
189
|
+
hubby = _hub(a) or _hub(b)
|
|
190
|
+
cross = a.split("/", 1)[0] != b.split("/", 1)[0]
|
|
191
|
+
return (hubby, not cross, -score, a, b)
|
|
192
|
+
|
|
193
|
+
candidates = sorted(dedup_map.values(), key=_rank_key)
|
|
194
|
+
return [{"a": a, "b": b, "score": round(s, 4), "sig": sig} for a, b, s, sig in candidates]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def neighborhood(graph: VaultGraph, note: str, depth: int = 1) -> dict:
|
|
198
|
+
"""Return out/back neighbors (and two_hop when depth>=2) for a note."""
|
|
199
|
+
out = sorted(graph.out_links.get(note, set()))
|
|
200
|
+
back = sorted(graph.back_links.get(note, set()))
|
|
201
|
+
result: dict = {"note": note, "out": out, "back": back}
|
|
202
|
+
if depth >= 2:
|
|
203
|
+
one_hop = set(out) | set(back)
|
|
204
|
+
two_hop: set[str] = set()
|
|
205
|
+
for neighbor in one_hop:
|
|
206
|
+
two_hop |= set(graph.out_links.get(neighbor, set()))
|
|
207
|
+
two_hop |= set(graph.back_links.get(neighbor, set()))
|
|
208
|
+
two_hop -= one_hop
|
|
209
|
+
two_hop.discard(note)
|
|
210
|
+
result["two_hop"] = sorted(two_hop)
|
|
211
|
+
return result
|
graphmark/model.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Core data model for graphmark.
|
|
2
|
+
|
|
3
|
+
These dataclasses are the seeded boundaries the engine is built against. Do not rename
|
|
4
|
+
their types or fields; implement the engine modules to produce and consume them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Document:
|
|
14
|
+
"""A single note in the vault."""
|
|
15
|
+
|
|
16
|
+
rel_path: str # posix rel-path from vault root, e.g. "brain/North Star.md"
|
|
17
|
+
text: str
|
|
18
|
+
frontmatter: dict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Edge:
|
|
23
|
+
"""A resolved directed link from one note to another."""
|
|
24
|
+
|
|
25
|
+
src: str # rel_path
|
|
26
|
+
dst: str # rel_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Graph:
|
|
31
|
+
"""The built graph: nodes plus resolved out/back adjacency."""
|
|
32
|
+
|
|
33
|
+
nodes: dict[str, Document] # rel_path -> Document
|
|
34
|
+
out_links: dict[str, set[str]] # rel_path -> resolved target rel_paths
|
|
35
|
+
back_links: dict[str, set[str]] # inverted out_links
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Finding:
|
|
40
|
+
"""A health finding (used by health checks)."""
|
|
41
|
+
|
|
42
|
+
kind: str # "orphan" | "broken_link" | "missing_frontmatter"
|
|
43
|
+
note: str # rel_path
|
|
44
|
+
detail: str = ""
|
graphmark/parse.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Note parsing: document splitting and wikilink extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from graphmark.model import Document
|
|
9
|
+
|
|
10
|
+
_FM_RE = re.compile(r"^---\n(.+?\n)---\n", re.DOTALL)
|
|
11
|
+
_WIKILINK_RE = re.compile(r"\[\[(.+?)\]\]")
|
|
12
|
+
_INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
|
|
13
|
+
_FENCE_OPEN_RE = re.compile(r"^(`{3,}|~{3,})")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _strip_fenced_blocks(text: str) -> str:
|
|
17
|
+
"""Remove fenced code block contents so wikilinks inside them are ignored."""
|
|
18
|
+
lines = text.splitlines(keepends=True)
|
|
19
|
+
out: list[str] = []
|
|
20
|
+
fence_char: str | None = None
|
|
21
|
+
for line in lines:
|
|
22
|
+
ls = line.lstrip()
|
|
23
|
+
if fence_char is None:
|
|
24
|
+
m = _FENCE_OPEN_RE.match(ls)
|
|
25
|
+
if m:
|
|
26
|
+
fence_char = ls[0]
|
|
27
|
+
else:
|
|
28
|
+
out.append(line)
|
|
29
|
+
else:
|
|
30
|
+
if _FENCE_OPEN_RE.match(ls) and ls[0] == fence_char:
|
|
31
|
+
fence_char = None
|
|
32
|
+
return "".join(out)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_frontmatter(raw: str) -> dict:
|
|
36
|
+
"""Minimal YAML-like frontmatter parser covering scalar, quoted-string, inline-list."""
|
|
37
|
+
result: dict = {}
|
|
38
|
+
for line in raw.splitlines():
|
|
39
|
+
if ":" not in line:
|
|
40
|
+
continue
|
|
41
|
+
key, _, value = line.partition(":")
|
|
42
|
+
key = key.strip()
|
|
43
|
+
value = value.strip()
|
|
44
|
+
if value.startswith("[") and value.endswith("]"):
|
|
45
|
+
items = [v.strip().strip("\"'") for v in value[1:-1].split(",")]
|
|
46
|
+
result[key] = [i for i in items if i]
|
|
47
|
+
else:
|
|
48
|
+
result[key] = value.strip("\"'")
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WikilinkExtractor:
|
|
53
|
+
"""Extracts raw wikilink displays from note text, excluding code spans."""
|
|
54
|
+
|
|
55
|
+
def extract(self, text: str) -> list[str]:
|
|
56
|
+
text = _strip_fenced_blocks(text)
|
|
57
|
+
text = _INLINE_CODE_RE.sub("", text)
|
|
58
|
+
return _WIKILINK_RE.findall(text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_document(path: Path, root: Path) -> Document:
|
|
62
|
+
"""Parse a markdown note into a Document, splitting YAML frontmatter from body."""
|
|
63
|
+
raw = path.read_text(encoding="utf-8")
|
|
64
|
+
rel_path = path.relative_to(root).as_posix()
|
|
65
|
+
m = _FM_RE.match(raw)
|
|
66
|
+
if m:
|
|
67
|
+
frontmatter = _parse_frontmatter(m.group(1))
|
|
68
|
+
body = raw[m.end() :]
|
|
69
|
+
else:
|
|
70
|
+
frontmatter = {}
|
|
71
|
+
body = raw
|
|
72
|
+
return Document(rel_path=rel_path, text=body, frontmatter=frontmatter)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: graphmark
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cdcoonce/graphmark
|
|
6
|
+
Project-URL: Repository, https://github.com/cdcoonce/graphmark
|
|
7
|
+
Project-URL: Issues, https://github.com/cdcoonce/graphmark/issues
|
|
8
|
+
Author-email: Charles Coonce <charlescoonce@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: graph,knowledge-graph,markdown,obsidian,pagerank,wikilink
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: networkx>=3.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# graphmark
|
|
28
|
+
|
|
29
|
+
Deterministic knowledge-graph analysis for markdown / `[[wikilink]]` vaults — orphans, hubs,
|
|
30
|
+
clusters, bridges, neighborhoods, and PageRank over your notes, driven by a small config so it works
|
|
31
|
+
on any Obsidian-family vault.
|
|
32
|
+
|
|
33
|
+
> Status: **early**. Engine modules are being built against a differential oracle (a proven
|
|
34
|
+
> reference implementation's frozen outputs). See `CLAUDE.md` for the architecture and the
|
|
35
|
+
> reference-parity contract.
|
|
36
|
+
|
|
37
|
+
## Install (dev)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv pip install -e ".[dev]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Test
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv run --extra dev ruff check . && uv run --extra dev ruff format --check . && uv run --extra dev pytest -q
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage (once built)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
graphmark stats --config configs/my-brain.toml --root /path/to/vault
|
|
53
|
+
graphmark orphans --config configs/my-brain.toml --root /path/to/vault
|
|
54
|
+
graphmark hubs 10 --config configs/my-brain.toml --root /path/to/vault
|
|
55
|
+
graphmark pagerank --config configs/my-brain.toml --root /path/to/vault
|
|
56
|
+
graphmark export dot --config configs/my-brain.toml --root /path/to/vault > graph.dot
|
|
57
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
graphmark/__init__.py,sha256=sXSYDxvThezeRGUxh83J9jcfEKSE_FHxX7zKq3cKylE,112
|
|
2
|
+
graphmark/cli.py,sha256=_hCi9T0DYIo8UNsW_8thDxMl9ZQgUtbq9r2Ka95OrHY,2998
|
|
3
|
+
graphmark/config.py,sha256=btPGhf2kp_8-J_DPnn2QysxIeem0mL5xa9LAgs9Qu_s,1542
|
|
4
|
+
graphmark/dismiss.py,sha256=wJUf5BaN5q72ySyxDNC33nq-eTn3gbr8a6nzwu92NV0,1752
|
|
5
|
+
graphmark/export.py,sha256=z3rluLRcRzQeKqM7ALABf0O3JOOpHgs412X7ypVpUZw,682
|
|
6
|
+
graphmark/graph.py,sha256=hrw4sbjD2gJlo6f3erdSEl0qDtSBDAqt-igLez3bY2o,3677
|
|
7
|
+
graphmark/interfaces.py,sha256=a4R_z1Jo15lSH-jSaoBH-kou_ovLS_BUuYOJLqVZZ7k,1198
|
|
8
|
+
graphmark/metrics.py,sha256=TYe9_YnIvC6T-1NEfZ9cU7sC3toI73hvr2VfSa0v3Fw,7229
|
|
9
|
+
graphmark/model.py,sha256=ylxXvnCn0gAu86aW4V3at-3FBhNWBrHdbmFzb8tlRNc,1115
|
|
10
|
+
graphmark/parse.py,sha256=W2EstjIUVD0itb-4CRj2aQICujEad6X16gVuj3a9mAA,2326
|
|
11
|
+
graphmark-0.1.0.dist-info/METADATA,sha256=U28g8NLrWkEAv9kHWbOt7cSmDSbPuHLfaLFA7-D0mro,2163
|
|
12
|
+
graphmark-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
graphmark-0.1.0.dist-info/entry_points.txt,sha256=sqDtlC7NtKyCUXw6Vn-147PHjhAK2V1hZQSbzP2qyEw,49
|
|
14
|
+
graphmark-0.1.0.dist-info/licenses/LICENSE,sha256=MZbypT-lLCt1Re77foXUp-nevCglz-FNlObDyoL_DXU,1071
|
|
15
|
+
graphmark-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Charles Coonce
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|