sidegraph 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.
@@ -0,0 +1,7 @@
1
+ """Engine seam — the ONLY package where Graphify specifics may live.
2
+
3
+ The portable core (``sidegraph.schema`` / ``store`` / ``retrieval`` / ``server``) depends
4
+ on the :class:`~sidegraph.engine.reader.GraphifyReader` protocol here and nothing else about
5
+ the engine. A Graphify release breaks at most this package. Do not add a second engine
6
+ adapter — keep the seam clean so one is *possible* later without reshaping the core.
7
+ """
@@ -0,0 +1,353 @@
1
+ """``GraphifyReader`` — the engine seam: the ONLY module that parses Graphify's graph.json.
2
+
3
+ graph.json-only (no Graphify MCP): resolve/neighbors/communities/subgraph are computed
4
+ locally from the node-link graph. Read-only — never writes graph.json. Version is never
5
+ mtime: it's the embedded ``built_at_commit`` field with a content hash always folded in
6
+ (``f"{commit}:{hash}"``), or just the content hash (``f"content:{hash}"``) when
7
+ ``built_at_commit`` is absent — folding the hash in even when a commit is present is what
8
+ catches dirty-tree rebuilds that change graph.json without changing the commit. See
9
+ ``docs/integrations/graphify.md``.
10
+
11
+ ``community_labels()`` additionally reads the engine's optional ``.graphify_labels.json``
12
+ sidecar (written by Graphify's cluster-only/label commands, sibling to ``graph.json``) —
13
+ this module is the ONLY place allowed to know that file's name/shape (see
14
+ ``docs/concepts/mind-model.md#domain-lifecycle``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import subprocess
22
+ import time
23
+ from collections import deque
24
+ from pathlib import Path
25
+
26
+ from pydantic import BaseModel
27
+
28
+ from ..schema import Descriptor, canonicalize
29
+
30
+ # Node file types that can carry anchors. Concepts (LLM-extracted thematic entities) and
31
+ # rationale nodes (recorded reasoning, code AST or LLM prose) anchor exactly like code
32
+ # symbols / doc headings: name+file descriptor, Tier-2 leaf + Tier-1 community. `image`
33
+ # stays out — not name-anchorable — never-guess extends to file types.
34
+ ANCHORABLE_FILE_TYPES = frozenset({"code", "document", "concept", "rationale"})
35
+
36
+ # Sidecar written by Graphify's cluster-only/label commands, sibling to graph.json:
37
+ # {"<community_id>": "<human label>", ...}. Optional — absent on graphs built without a
38
+ # labeling pass (verified empirically: present on code/adr/doc corpora rebuilt with
39
+ # `graphify update --cluster-only`/label commands, absent on a plain semantic-only build).
40
+ _LABELS_FILENAME = ".graphify_labels.json"
41
+
42
+
43
+ class NodeRef(BaseModel):
44
+ node_id: str
45
+ name: str
46
+ norm_name: str
47
+ file_type: str
48
+ file_path: str | None = None
49
+ line: str | None = None
50
+ community: str | None = None
51
+
52
+
53
+ class Community(BaseModel):
54
+ community_id: str
55
+ members: list[str]
56
+ god_node: str | None = None
57
+
58
+
59
+ class Subgraph(BaseModel):
60
+ nodes: list[NodeRef]
61
+ edges: list[tuple[str, str, str]]
62
+
63
+
64
+ class ResolveResult(BaseModel):
65
+ status: str # "resolved" | "ambiguous" | "unresolved"
66
+ node_id: str | None = None
67
+ candidates: list[str] = []
68
+ community: str | None = None
69
+
70
+
71
+ class RationaleNode(BaseModel):
72
+ node_id: str
73
+ text: str # the node's label — engine truncates ~80 chars, that's fine
74
+ file_path: str | None = None
75
+ community: str | None = None
76
+ targets: list[NodeRef] = [] # what this rationale explains
77
+
78
+
79
+ def _to_node(raw: dict) -> NodeRef:
80
+ community = raw.get("community")
81
+ return NodeRef(
82
+ node_id=str(raw.get("id")),
83
+ name=str(raw.get("label", "")),
84
+ norm_name=str(raw.get("norm_label", raw.get("label", ""))),
85
+ file_type=str(raw.get("file_type", "")),
86
+ file_path=raw.get("source_file"),
87
+ line=raw.get("source_location"),
88
+ community=None if community is None else str(community),
89
+ )
90
+
91
+
92
+ class GraphifyReader:
93
+ """Reads ``graphify-out/graph.json`` read-only and answers graph queries locally."""
94
+
95
+ def __init__(self, path: str | Path) -> None:
96
+ self.path = Path(path)
97
+ self._raw = self._load()
98
+ self._nodes = [_to_node(n) for n in self._raw.get("nodes", [])]
99
+ self._by_id = {n.node_id: n for n in self._nodes}
100
+ self._links = self._raw.get("links", [])
101
+ # Lazy, memoized indices — each is built at most once, from a single O(V)/O(E) pass,
102
+ # the first time its owning method is called (not in __init__: a reader that never
103
+ # calls e.g. resolve() shouldn't pay for an index it never needs). See neighbors(),
104
+ # resolve(), nodes_in_file() below — each used to re-scan self._links/self._nodes in
105
+ # full on EVERY call, which is fine for a single lookup but catastrophic for callers
106
+ # that loop over many nodes (e.g. rationale_nodes() calling neighbors() once per
107
+ # rationale node, or import_rationales() calling resolve() per anchor candidate): on
108
+ # a 113K-node/232K-edge graph with 23K rationale nodes this was billions of
109
+ # comparisons in pure Python and never returned. Building the index once turns each
110
+ # of those into O(degree)/O(matches) per call instead of O(E)/O(V).
111
+ self._adjacency: dict[str, list[tuple[str, str]]] | None = None
112
+ self._resolve_idx: dict[str, list[NodeRef]] | None = None
113
+ self._file_idx: dict[str, list[NodeRef]] | None = None
114
+
115
+ def _load(self, retries: int = 3) -> dict:
116
+ last: Exception | None = None
117
+ for _ in range(max(1, retries)):
118
+ text = self.path.read_text()
119
+ try:
120
+ raw = json.loads(text)
121
+ except json.JSONDecodeError as e: # graph.json write is non-atomic
122
+ last = e
123
+ time.sleep(0.2)
124
+ continue
125
+ self._content_hash = hashlib.sha256(text.encode()).hexdigest()[:12]
126
+ return raw
127
+ raise last # type: ignore[misc]
128
+
129
+ def graph_version(self) -> str:
130
+ """``f"{commit}:{content_hash}"`` when the engine stamped a ``built_at_commit``,
131
+ else ``f"content:{content_hash}"``. The content hash is folded in even when a commit
132
+ is present: Graphify rewrites graph.json without bumping built_at_commit whenever the
133
+ working tree is dirty (e.g. an uncommitted rename), and a bare commit would then miss
134
+ that the content changed and let sync report "up to date" while serving stale anchors.
135
+ One-time upgrade note: stores written before this change stamped
136
+ ``last_synced_graph_version`` in the old bare-commit format, so the first sync after
137
+ upgrading sees a differing version string and reruns once — self-healing, and intended.
138
+ """
139
+ commit = self._raw.get("built_at_commit")
140
+ if commit:
141
+ return f"{commit}:{self._content_hash}"
142
+ return f"content:{self._content_hash}"
143
+
144
+ def list_nodes(self) -> list[NodeRef]:
145
+ return list(self._nodes)
146
+
147
+ def get_node(self, node_id: str) -> NodeRef | None:
148
+ return self._by_id.get(node_id)
149
+
150
+ def _file_index(self) -> dict[str, list[NodeRef]]:
151
+ """``file_path -> anchorable nodes`` in that file, built once from a single pass
152
+ over ``self._nodes`` (memoized). Backs ``nodes_in_file()``."""
153
+ if self._file_idx is None:
154
+ idx: dict[str, list[NodeRef]] = {}
155
+ for n in self._nodes:
156
+ if n.file_type in ANCHORABLE_FILE_TYPES and n.file_path is not None:
157
+ idx.setdefault(n.file_path, []).append(n)
158
+ self._file_idx = idx
159
+ return self._file_idx
160
+
161
+ def nodes_in_file(self, file_path: str) -> list[NodeRef]:
162
+ """All anchorable nodes (code symbols / doc headings) whose source_file matches."""
163
+ return list(self._file_index().get(file_path, []))
164
+
165
+ def _resolve_index(self) -> dict[str, list[NodeRef]]:
166
+ """``canonicalize(name) -> anchorable nodes`` with that canonical name, built once
167
+ from a single pass over ``self._nodes`` (memoized). Backs ``resolve()``."""
168
+ if self._resolve_idx is None:
169
+ idx: dict[str, list[NodeRef]] = {}
170
+ for n in self._nodes:
171
+ if n.file_type in ANCHORABLE_FILE_TYPES:
172
+ idx.setdefault(canonicalize(n.name), []).append(n)
173
+ self._resolve_idx = idx
174
+ return self._resolve_idx
175
+
176
+ def resolve(self, desc: Descriptor) -> ResolveResult:
177
+ target = canonicalize(desc.name)
178
+ matches = list(self._resolve_index().get(target, []))
179
+ if desc.file_path is not None:
180
+ matches = [n for n in matches if n.file_path == desc.file_path]
181
+ if len(matches) == 1:
182
+ m = matches[0]
183
+ return ResolveResult(status="resolved", node_id=m.node_id, community=m.community)
184
+ if not matches:
185
+ return ResolveResult(status="unresolved")
186
+ comms = {m.community for m in matches if m.community is not None}
187
+ shared = comms.pop() if len(comms) == 1 else None
188
+ return ResolveResult(
189
+ status="ambiguous",
190
+ candidates=[m.node_id for m in matches],
191
+ community=shared,
192
+ )
193
+
194
+ def _relation(self, link: dict) -> str:
195
+ return str(link.get("relation") or link.get("type") or "")
196
+
197
+ def _adjacency_index(self) -> dict[str, list[tuple[str, str]]]:
198
+ """``node_id -> [(other_node_id, relation), ...]`` across both edge endpoints,
199
+ built once from a single pass over ``self._links`` (memoized). Backs ``neighbors()``.
200
+
201
+ Mirrors the per-node-id "which end is the other one" derivation the old per-call
202
+ scan used, so that iterating ``adjacency[node_id]`` yields exactly the (other,
203
+ relation) pairs a full scan filtered to ``node_id`` would have yielded, in the same
204
+ link order — including a self-loop edge (source == target) contributing exactly one
205
+ entry, same as before.
206
+ """
207
+ if self._adjacency is None:
208
+ adj: dict[str, list[tuple[str, str]]] = {}
209
+ for link in self._links:
210
+ rel = self._relation(link)
211
+ s, t = str(link.get("source")), str(link.get("target"))
212
+ adj.setdefault(s, []).append((t, rel))
213
+ if t != s:
214
+ adj.setdefault(t, []).append((s, rel))
215
+ self._adjacency = adj
216
+ return self._adjacency
217
+
218
+ def neighbors(self, node_id: str, relations: list[str] | None = None) -> list[NodeRef]:
219
+ out: list[NodeRef] = []
220
+ seen: set[str] = set()
221
+ for other, rel in self._adjacency_index().get(node_id, []):
222
+ if relations is not None and rel not in relations:
223
+ continue
224
+ if other in seen:
225
+ continue
226
+ node = self._by_id.get(other)
227
+ if node is not None:
228
+ seen.add(other)
229
+ out.append(node)
230
+ return out
231
+
232
+ def rationale_nodes(self) -> list[RationaleNode]:
233
+ """All ``rationale`` nodes (recorded reasoning), with targets resolved.
234
+
235
+ This is the ONLY place that knows the ``rationale_for`` (code AST pass: rationale
236
+ --rationale_for--> code, verified empirically 492/492 edges on a real corpus) and
237
+ ``references`` (LLM prose pass, same direction) relation names. ``neighbors()``
238
+ already resolves "the other end of the edge" regardless of source/target order, so
239
+ this stays defensive to either orientation without extra bookkeeping. Falls back to
240
+ ``references`` only when a node has zero ``rationale_for`` edges; non-anchorable
241
+ targets (e.g. ``image``) are dropped from the result either way.
242
+ """
243
+ out: list[RationaleNode] = []
244
+ for n in self._nodes:
245
+ if n.file_type != "rationale":
246
+ continue
247
+ targets = self.neighbors(n.node_id, relations=["rationale_for"])
248
+ if not targets:
249
+ targets = self.neighbors(n.node_id, relations=["references"])
250
+ targets = [t for t in targets if t.file_type in ANCHORABLE_FILE_TYPES]
251
+ out.append(
252
+ RationaleNode(
253
+ node_id=n.node_id,
254
+ text=n.name,
255
+ file_path=n.file_path,
256
+ community=n.community,
257
+ targets=targets,
258
+ )
259
+ )
260
+ return out
261
+
262
+ def containing(self, node_id: str) -> str | None:
263
+ node = self._by_id.get(node_id)
264
+ return node.community if node is not None else None
265
+
266
+ def communities(self) -> list[Community]:
267
+ degree: dict[str, int] = {}
268
+ for link in self._links:
269
+ for end in (str(link.get("source")), str(link.get("target"))):
270
+ degree[end] = degree.get(end, 0) + 1
271
+ groups: dict[str, list[str]] = {}
272
+ for n in self._nodes:
273
+ if n.community is not None:
274
+ groups.setdefault(n.community, []).append(n.node_id)
275
+ out: list[Community] = []
276
+ for cid, members in groups.items():
277
+ god = max(members, key=lambda m: degree.get(m, 0)) if members else None
278
+ out.append(Community(community_id=cid, members=members, god_node=god))
279
+ return out
280
+
281
+ def community_labels(self) -> dict[str, str]:
282
+ """Community id -> human label/summary, from the optional ``.graphify_labels.json``
283
+ sidecar next to ``graph.json`` (see module docstring). Purely additive: bootstrap
284
+ falls back to a god-node-derived name when a community has no entry here, or when
285
+ this method returns ``{}`` outright (no labeling pass run on this corpus).
286
+
287
+ Defensive by construction — a missing file, unreadable/malformed JSON, or a
288
+ non-dict payload all yield ``{}`` rather than raising, since this is best-effort
289
+ engine metadata, not a required input (unlike ``graph.json`` itself, whose read
290
+ failures ARE fatal in ``__init__``). Non-string values are dropped rather than
291
+ coerced, since a label is display text, not just anything JSON allows here.
292
+ Whitespace-only strings are dropped too: a blank label is worthless as a title
293
+ source and, left in, crashes a caller like ``bootstrap_domains`` downstream
294
+ (``Domain.title`` rejects blank text) — better every caller sees "no label here"
295
+ and falls back cleanly.
296
+ """
297
+ label_path = self.path.parent / _LABELS_FILENAME
298
+ if not label_path.exists():
299
+ return {}
300
+ try:
301
+ raw = json.loads(label_path.read_text())
302
+ except (OSError, json.JSONDecodeError):
303
+ return {}
304
+ if not isinstance(raw, dict):
305
+ return {}
306
+ return {str(k): v for k, v in raw.items() if isinstance(v, str) and v.strip()}
307
+
308
+ def subgraph(self, seed_ids: list[str], budget: int) -> Subgraph:
309
+ adj: dict[str, list[tuple[str, str]]] = {}
310
+ for link in self._links:
311
+ s, t = str(link.get("source")), str(link.get("target"))
312
+ rel = self._relation(link)
313
+ adj.setdefault(s, []).append((t, rel))
314
+ adj.setdefault(t, []).append((s, rel))
315
+ picked: dict[str, NodeRef] = {}
316
+ q: deque[str] = deque()
317
+ for sid in seed_ids:
318
+ if sid in self._by_id and sid not in picked:
319
+ picked[sid] = self._by_id[sid]
320
+ q.append(sid)
321
+ if len(picked) >= budget:
322
+ break
323
+ while q and len(picked) < budget:
324
+ cur = q.popleft()
325
+ for other, _rel in adj.get(cur, []):
326
+ if other not in picked and other in self._by_id:
327
+ picked[other] = self._by_id[other]
328
+ q.append(other)
329
+ if len(picked) >= budget:
330
+ break
331
+ keep = set(picked)
332
+ edges = [
333
+ (str(link.get("source")), str(link.get("target")), self._relation(link))
334
+ for link in self._links
335
+ if str(link.get("source")) in keep and str(link.get("target")) in keep
336
+ ]
337
+ return Subgraph(nodes=list(picked.values()), edges=edges)
338
+
339
+ def changed_files(self, since: str | None = None) -> list[str]:
340
+ """Repo-relative paths changed in the last commit (git diff HEAD~1 HEAD).
341
+
342
+ Uses the repo containing graph.json. Best-effort: returns [] on any git error.
343
+ """
344
+ repo = self.path.parent.parent
345
+ try:
346
+ out = subprocess.run(
347
+ ["git", "-C", str(repo), "diff", "--name-only", "HEAD~1", "HEAD"],
348
+ capture_output=True,
349
+ text=True,
350
+ )
351
+ return [p for p in out.stdout.split("\n") if p] if out.returncode == 0 else []
352
+ except (OSError, subprocess.SubprocessError):
353
+ return []