@gmickel/gno 1.4.2 → 1.5.0

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.
@@ -133,13 +133,21 @@ gno search "error handling" --json | jq -r '.results[].uri' | xargs gno multi-ge
133
133
 
134
134
  ## MCP Retrieval Strategy
135
135
 
136
- When using GNO through MCP, prefer `gno_query` first for normal questions. It returns snippets plus `uri`, `docid`, and often `line`; follow with `gno_get` using `fromLine`/`lineCount` for a bounded read, or `gno_multi_get` to batch top result refs.
136
+ When using GNO through MCP, prefer this retrieval order:
137
+
138
+ 1. Check `gno_status` first when freshness, missing vectors, or stale results are plausible.
139
+ 2. Use `gno_query` first for normal content questions. It returns snippets plus `uri`, `docid`, and often `line`; it also adds bounded one-hop graph neighbors from top seeds when graph data exists.
140
+ 3. Use graph/link expansion for relationship context: `gno_graph_neighbors` for nearby documents, `gno_graph_path` for "how are X and Y connected?", `gno_links`/`gno_backlinks` for one-document link expansion, and `gno_similar` for semantic neighbors. Prefer `explicit` graph edges over `inferred`, `ambiguous`, or `similarity` edges when confidence matters.
141
+ 4. Use `gno_get` with `fromLine`/`lineCount` for targeted reads, or `gno_multi_get` to batch top refs.
137
142
 
138
143
  Use narrower tools when the request tells you to:
139
144
 
140
145
  - `gno_search`: exact phrase, filename, identifier, stack trace, error text
141
146
  - `gno_vsearch`: conceptual similarity when exact wording differs
142
147
  - `gno_status`: stale results, missing embeddings, vector unavailable
148
+ - `gno_graph`: graph report/stats, hubs, isolates, unresolved links, edge confidence/audit, communities, unfamiliar corpus overview
149
+ - `gno_graph_neighbors`: relationship/corpus-navigation questions around a known document
150
+ - `gno_graph_path`: "how are X and Y connected?" questions
143
151
 
144
152
  For ambiguous terms, pass `intent` instead of bloating the query text. For typed retrieval, use `queryModes`: `term` for lexical anchors, `intent` for disambiguation, one `hyde` for a hypothetical answer/document.
145
153
 
@@ -164,6 +172,8 @@ gno similar gno://notes/auth.md --threshold 0.85
164
172
  # Knowledge graph
165
173
  gno graph --json
166
174
  gno graph -c notes --similar # Include similarity edges
175
+ gno graph --neighbors gno://notes/auth.md
176
+ gno graph --from gno://notes/a.md --to gno://notes/b.md
167
177
  ```
168
178
 
169
179
  ## Global Flags
@@ -154,7 +154,7 @@ gno query <query> [options]
154
154
 
155
155
  | Flag | Time | Description |
156
156
  | ------------ | ----- | ------------------------------ |
157
- | `--fast` | ~0.7s | Skip expansion and reranking |
157
+ | `--fast` | ~0.7s | Skip expansion, graph, rerank |
158
158
  | (default) | ~2-3s | Skip expansion, with reranking |
159
159
  | `--thorough` | ~5-8s | Full pipeline with expansion |
160
160
 
@@ -164,6 +164,7 @@ Additional options:
164
164
  | ------------- | --------------------------------- |
165
165
  | `--no-expand` | Disable query expansion |
166
166
  | `--no-rerank` | Disable reranking |
167
+ | `--no-graph` | Disable graph-neighbor candidates |
167
168
  | `--explain` | Print retrieval details to stderr |
168
169
 
169
170
  ### gno ask
@@ -304,6 +305,8 @@ Generate knowledge graph of document connections.
304
305
 
305
306
  ```bash
306
307
  gno graph [options]
308
+ gno graph --neighbors <ref> [--direction both|out|in]
309
+ gno graph --from <ref> --to <ref> [--max-depth 6]
307
310
  ```
308
311
 
309
312
  | Option | Default | Description |
@@ -315,9 +318,14 @@ gno graph [options]
315
318
  | `--threshold` | 0.7 | Similarity threshold (0-1) |
316
319
  | `--linked-only` | true | Exclude isolated nodes |
317
320
  | `--similar-top-k` | 5 | Similar docs per node (max 20) |
321
+ | `--neighbors` | - | Show graph neighbors for ref |
322
+ | `--direction` | both | Neighbor direction |
323
+ | `--from`, `--to` | - | Find shortest graph path |
324
+ | `--max-depth` | 6 | Max path hops |
318
325
  | `--json` | - | JSON output |
319
326
 
320
327
  **Edge types**: `wiki` (wiki links), `markdown` (md links), `similar` (vector similarity).
328
+ JSON edges include `confidence` (`explicit`, `inferred`, `ambiguous`, `similarity`) and `audit` metadata so agents can prefer exact links over fallback or collision-prone matches.
321
329
 
322
330
  **Web UI**: Access interactive graph at `http://localhost:3000/graph` via `gno serve`.
323
331
 
@@ -53,6 +53,22 @@ gno mcp install -t claude-code -s project # Project scope
53
53
  gno mcp status
54
54
  ```
55
55
 
56
+ ## Retrieval Order
57
+
58
+ For normal questions, start with `gno_query`, then read targeted snippets with
59
+ `gno_get` or batch refs with `gno_multi_get`. `gno_query` already does bounded
60
+ one-hop graph expansion from top seeds; pass `noGraph: true` only when comparing
61
+ pure BM25/vector retrieval. Check `gno_status` first when freshness or
62
+ embeddings may be stale.
63
+
64
+ Use graph tools for relationship context: `gno_graph` for corpus report/stats,
65
+ community summaries,
66
+ `gno_graph_neighbors` for nearby incoming/outgoing graph context, and
67
+ `gno_graph_path` for "how are X and Y connected?" questions. Use
68
+ `gno_links`, `gno_backlinks`, and `gno_similar` for one-document expansion.
69
+ Graph edges include confidence/audit metadata; prefer `explicit` edges when
70
+ answers depend on link certainty.
71
+
56
72
  ## Uninstall
57
73
 
58
74
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -32,12 +32,55 @@ export interface GraphOptions {
32
32
  similarTopK?: number;
33
33
  /** Output format: json (default), dot, mermaid */
34
34
  format?: "json" | "dot" | "mermaid";
35
+ /** Show neighbors for a graph node/ref */
36
+ neighbors?: string;
37
+ /** Neighbor direction */
38
+ direction?: "both" | "out" | "in";
39
+ /** Path start graph node/ref */
40
+ from?: string;
41
+ /** Path target graph node/ref */
42
+ to?: string;
43
+ /** Max path hops */
44
+ maxDepth?: number;
35
45
  }
36
46
 
37
47
  export type GraphCommandResult =
38
48
  | { success: true; data: GraphResult }
49
+ | { success: true; data: GraphNeighborsCliResult }
50
+ | { success: true; data: GraphPathCliResult }
39
51
  | { success: false; error: string; isValidation?: boolean };
40
52
 
53
+ type GraphNode = GraphResult["nodes"][number];
54
+ type GraphLink = GraphResult["links"][number];
55
+
56
+ interface GraphNeighborCliItem {
57
+ node: GraphNode;
58
+ direction: "out" | "in";
59
+ edge: GraphLink;
60
+ }
61
+
62
+ interface GraphNeighborsCliResult {
63
+ source: GraphNode;
64
+ neighbors: GraphNeighborCliItem[];
65
+ meta: GraphResult["meta"] & {
66
+ mode: "neighbors";
67
+ direction: "both" | "out" | "in";
68
+ totalNeighbors: number;
69
+ };
70
+ }
71
+
72
+ interface GraphPathCliResult {
73
+ from: GraphNode;
74
+ to: GraphNode;
75
+ path: { nodes: GraphNode[]; edges: GraphLink[] } | null;
76
+ meta: GraphResult["meta"] & {
77
+ mode: "path";
78
+ maxDepth: number;
79
+ found: boolean;
80
+ hops: number;
81
+ };
82
+ }
83
+
41
84
  // ─────────────────────────────────────────────────────────────────────────────
42
85
  // Command: graph
43
86
  // ─────────────────────────────────────────────────────────────────────────────
@@ -74,12 +117,162 @@ export async function graph(
74
117
  return { success: false, error: result.error.message };
75
118
  }
76
119
 
120
+ if (options.neighbors) {
121
+ const source = resolveGraphNode(result.value, options.neighbors);
122
+ if (!source) {
123
+ return {
124
+ success: false,
125
+ error: `Graph node not found: ${options.neighbors}`,
126
+ isValidation: true,
127
+ };
128
+ }
129
+ const direction = options.direction ?? "both";
130
+ const neighbors = getGraphNeighbors(result.value, source, direction);
131
+ return {
132
+ success: true,
133
+ data: {
134
+ source,
135
+ neighbors,
136
+ meta: {
137
+ ...result.value.meta,
138
+ mode: "neighbors",
139
+ direction,
140
+ totalNeighbors: neighbors.length,
141
+ },
142
+ },
143
+ };
144
+ }
145
+
146
+ if (options.from || options.to) {
147
+ if (!(options.from && options.to)) {
148
+ return {
149
+ success: false,
150
+ error: "--from and --to must be used together",
151
+ isValidation: true,
152
+ };
153
+ }
154
+ const from = resolveGraphNode(result.value, options.from);
155
+ const to = resolveGraphNode(result.value, options.to);
156
+ if (!from || !to) {
157
+ return {
158
+ success: false,
159
+ error: `Graph node not found: ${from ? options.to : options.from}`,
160
+ isValidation: true,
161
+ };
162
+ }
163
+ const maxDepth = options.maxDepth ?? 6;
164
+ const path = findShortestPath(result.value, from, to, maxDepth);
165
+ return {
166
+ success: true,
167
+ data: {
168
+ from,
169
+ to,
170
+ path,
171
+ meta: {
172
+ ...result.value.meta,
173
+ mode: "path",
174
+ maxDepth,
175
+ found: path !== null,
176
+ hops: path ? path.edges.length : 0,
177
+ },
178
+ },
179
+ };
180
+ }
181
+
77
182
  return { success: true, data: result.value };
78
183
  } finally {
79
184
  await store.close();
80
185
  }
81
186
  }
82
187
 
188
+ function resolveGraphNode(
189
+ graphResult: GraphResult,
190
+ ref: string
191
+ ): GraphNode | null {
192
+ const normalized = ref.trim().toLowerCase();
193
+ return (
194
+ graphResult.nodes.find((node) => {
195
+ const title = node.title?.toLowerCase();
196
+ return (
197
+ node.id.toLowerCase() === normalized ||
198
+ node.uri.toLowerCase() === normalized ||
199
+ node.relPath.toLowerCase() === normalized ||
200
+ `${node.collection}/${node.relPath}`.toLowerCase() === normalized ||
201
+ title === normalized
202
+ );
203
+ }) ?? null
204
+ );
205
+ }
206
+
207
+ function getGraphNeighbors(
208
+ graphResult: GraphResult,
209
+ source: GraphNode,
210
+ direction: "both" | "out" | "in"
211
+ ): GraphNeighborCliItem[] {
212
+ const nodesById = new Map(graphResult.nodes.map((node) => [node.id, node]));
213
+ const neighbors: GraphNeighborCliItem[] = [];
214
+ for (const edge of graphResult.links) {
215
+ if (direction !== "in" && edge.source === source.id) {
216
+ const node = nodesById.get(edge.target);
217
+ if (node) neighbors.push({ node, direction: "out", edge });
218
+ }
219
+ if (direction !== "out" && edge.target === source.id) {
220
+ const node = nodesById.get(edge.source);
221
+ if (node) neighbors.push({ node, direction: "in", edge });
222
+ }
223
+ }
224
+ return neighbors.sort((a, b) => a.node.uri.localeCompare(b.node.uri));
225
+ }
226
+
227
+ function findShortestPath(
228
+ graphResult: GraphResult,
229
+ from: GraphNode,
230
+ to: GraphNode,
231
+ maxDepth: number
232
+ ): { nodes: GraphNode[]; edges: GraphLink[] } | null {
233
+ const nodesById = new Map(graphResult.nodes.map((node) => [node.id, node]));
234
+ const adjacency = new Map<string, Array<{ next: string; edge: GraphLink }>>();
235
+ for (const edge of graphResult.links) {
236
+ const sourceEdges = adjacency.get(edge.source) ?? [];
237
+ sourceEdges.push({ next: edge.target, edge });
238
+ adjacency.set(edge.source, sourceEdges);
239
+
240
+ const targetEdges = adjacency.get(edge.target) ?? [];
241
+ targetEdges.push({ next: edge.source, edge });
242
+ adjacency.set(edge.target, targetEdges);
243
+ }
244
+
245
+ const queue: Array<{ id: string; edges: GraphLink[] }> = [
246
+ { id: from.id, edges: [] },
247
+ ];
248
+ const visited = new Set([from.id]);
249
+ while (queue.length > 0) {
250
+ const current = queue.shift();
251
+ if (!current) break;
252
+ if (current.id === to.id) {
253
+ const nodeIds = [from.id];
254
+ let cursor = from.id;
255
+ for (const edge of current.edges) {
256
+ cursor = edge.source === cursor ? edge.target : edge.source;
257
+ nodeIds.push(cursor);
258
+ }
259
+ return {
260
+ nodes: nodeIds
261
+ .map((id) => nodesById.get(id))
262
+ .filter((node): node is GraphNode => node !== undefined),
263
+ edges: current.edges,
264
+ };
265
+ }
266
+ if (current.edges.length >= maxDepth) continue;
267
+ for (const { next, edge } of adjacency.get(current.id) ?? []) {
268
+ if (visited.has(next)) continue;
269
+ visited.add(next);
270
+ queue.push({ id: next, edges: [...current.edges, edge] });
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+
83
276
  // ─────────────────────────────────────────────────────────────────────────────
84
277
  // Formatters
85
278
  // ─────────────────────────────────────────────────────────────────────────────
@@ -119,10 +312,18 @@ export function formatDot(result: GraphResult): string {
119
312
  for (const link of result.links) {
120
313
  const style =
121
314
  link.type === "similar"
122
- ? ' [style=dashed, color="#888888", dir=none]'
123
- : "";
315
+ ? 'style=dashed, color="#888888", dir=none'
316
+ : link.confidence === "ambiguous"
317
+ ? 'style=dotted, color="#d97706"'
318
+ : link.confidence === "inferred"
319
+ ? 'style=dashed, color="#64748b"'
320
+ : "";
321
+ const attrs = [
322
+ style,
323
+ `label="${escapeDot(`${link.type}/${link.confidence}`)}"`,
324
+ ].filter(Boolean);
124
325
  lines.push(
125
- ` "${escapeDot(link.source)}" -> "${escapeDot(link.target)}"${style};`
326
+ ` "${escapeDot(link.source)}" -> "${escapeDot(link.target)}" [${attrs.join(", ")}];`
126
327
  );
127
328
  }
128
329
 
@@ -154,7 +355,9 @@ export function formatMermaid(result: GraphResult): string {
154
355
  const sourceId = nodeIds.get(link.source) ?? link.source;
155
356
  const targetId = nodeIds.get(link.target) ?? link.target;
156
357
  const arrow = link.type === "similar" ? "---" : "-->";
157
- lines.push(` ${sourceId} ${arrow} ${targetId}`);
358
+ lines.push(
359
+ ` ${sourceId} ${arrow}|${escapeMermaid(`${link.type}/${link.confidence}`)}| ${targetId}`
360
+ );
158
361
  }
159
362
 
160
363
  return lines.join("\n");
@@ -178,6 +381,36 @@ export function formatGraph(
178
381
 
179
382
  const { data } = result;
180
383
 
384
+ if ("neighbors" in data) {
385
+ if (options.format === "json" || !options.format) {
386
+ return JSON.stringify(data, null, 2);
387
+ }
388
+ if (data.neighbors.length === 0) {
389
+ return `No graph neighbors found for ${data.source.uri} (direction=${data.meta.direction})`;
390
+ }
391
+ const lines = [
392
+ `Found ${data.neighbors.length} graph neighbors for ${data.source.uri}:`,
393
+ "",
394
+ ];
395
+ for (const item of data.neighbors) {
396
+ const title = item.node.title ? ` "${item.node.title}"` : "";
397
+ lines.push(
398
+ ` [${item.direction}] ${item.node.uri}${title} (${item.edge.type}, ${item.edge.confidence}, weight: ${item.edge.weight})`
399
+ );
400
+ }
401
+ return lines.join("\n");
402
+ }
403
+
404
+ if ("path" in data) {
405
+ if (options.format === "json" || !options.format) {
406
+ return JSON.stringify(data, null, 2);
407
+ }
408
+ if (!data.path) {
409
+ return `No graph path found from ${data.from.uri} to ${data.to.uri} within ${data.meta.maxDepth} hops`;
410
+ }
411
+ return `Graph path (${data.meta.hops} hops):\n${data.path.nodes.map((node) => node.uri).join(" -> ")}`;
412
+ }
413
+
181
414
  switch (options.format) {
182
415
  case "dot":
183
416
  return formatDot(data);
@@ -539,6 +539,7 @@ function wireSearchCommands(program: Command): void {
539
539
  )
540
540
  .option("--no-expand", "disable query expansion")
541
541
  .option("--no-rerank", "disable reranking")
542
+ .option("--no-graph", "disable graph neighbor expansion")
542
543
  .option(
543
544
  "--query-mode <mode:text>",
544
545
  "structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
@@ -655,6 +656,7 @@ function wireSearchCommands(program: Command): void {
655
656
  lineNumbers: Boolean(cmdOpts.lineNumbers),
656
657
  noExpand: depthPolicy.noExpand,
657
658
  noRerank: depthPolicy.noRerank,
659
+ noGraph: Boolean(cmdOpts.fast) || cmdOpts.graph === false,
658
660
  candidateLimit: depthPolicy.candidateLimit,
659
661
  queryModes,
660
662
  explain: Boolean(cmdOpts.explain),
@@ -2223,6 +2225,11 @@ function wireGraphCommand(program: Command): void {
2223
2225
  .option("--threshold <n>", "similarity threshold (default 0.7)")
2224
2226
  .option("--include-isolated", "include nodes with no links")
2225
2227
  .option("--similar-top-k <n>", "similar docs per node (default 5)")
2228
+ .option("--neighbors <ref>", "show graph neighbors for document/node ref")
2229
+ .option("--direction <dir>", "neighbor direction: both, out, in", "both")
2230
+ .option("--from <ref>", "path start document/node ref")
2231
+ .option("--to <ref>", "path target document/node ref")
2232
+ .option("--max-depth <n>", "max path hops (default 6)")
2226
2233
  .option("--json", "JSON output (default)")
2227
2234
  .option("--dot", "Graphviz DOT output")
2228
2235
  .option("--mermaid", "Mermaid diagram output")
@@ -2259,6 +2266,25 @@ function wireGraphCommand(program: Command): void {
2259
2266
  const similarTopK = cmdOpts.similarTopK
2260
2267
  ? parsePositiveInt("similar-top-k", cmdOpts.similarTopK)
2261
2268
  : undefined;
2269
+ const maxDepth = cmdOpts.maxDepth
2270
+ ? parsePositiveInt("max-depth", cmdOpts.maxDepth)
2271
+ : undefined;
2272
+ if (
2273
+ cmdOpts.direction !== "both" &&
2274
+ cmdOpts.direction !== "out" &&
2275
+ cmdOpts.direction !== "in"
2276
+ ) {
2277
+ throw new CliError(
2278
+ "VALIDATION",
2279
+ "--direction must be one of: both, out, in"
2280
+ );
2281
+ }
2282
+ if (cmdOpts.neighbors && (cmdOpts.from || cmdOpts.to)) {
2283
+ throw new CliError(
2284
+ "VALIDATION",
2285
+ "--neighbors cannot be combined with --from/--to"
2286
+ );
2287
+ }
2262
2288
 
2263
2289
  const { graph, formatGraph } = await import("./commands/graph.js");
2264
2290
  const result = await graph({
@@ -2271,6 +2297,11 @@ function wireGraphCommand(program: Command): void {
2271
2297
  includeIsolated: Boolean(cmdOpts.includeIsolated),
2272
2298
  similarTopK,
2273
2299
  format,
2300
+ neighbors: cmdOpts.neighbors as string | undefined,
2301
+ direction: cmdOpts.direction as "both" | "out" | "in",
2302
+ from: cmdOpts.from as string | undefined,
2303
+ to: cmdOpts.to as string | undefined,
2304
+ maxDepth,
2274
2305
  });
2275
2306
 
2276
2307
  if (!result.success) {
@@ -0,0 +1,201 @@
1
+ import type { GraphLink, GraphNode } from "../store/types";
2
+
3
+ export interface GraphCommunity {
4
+ id: string;
5
+ label: string;
6
+ size: number;
7
+ edgeCount: number;
8
+ density: number;
9
+ topNodes: Array<
10
+ Pick<
11
+ GraphNode,
12
+ "id" | "uri" | "title" | "collection" | "relPath" | "degree"
13
+ >
14
+ >;
15
+ }
16
+
17
+ export interface GraphCommunityAnalysis {
18
+ total: number;
19
+ algorithm: "deterministic-label-propagation";
20
+ skipped: boolean;
21
+ assignments: Record<string, string>;
22
+ communities: GraphCommunity[];
23
+ warnings: string[];
24
+ }
25
+
26
+ const DEFAULT_COMMUNITY_NODE_CAP = 2000;
27
+ const MAX_ITERATIONS = 8;
28
+
29
+ const confidenceFactor = (confidence: GraphLink["confidence"]): number => {
30
+ switch (confidence) {
31
+ case "explicit":
32
+ return 1;
33
+ case "inferred":
34
+ return 0.75;
35
+ case "ambiguous":
36
+ return 0.5;
37
+ case "similarity":
38
+ return 0.35;
39
+ }
40
+ };
41
+
42
+ const edgeStrength = (edge: GraphLink): number =>
43
+ Math.max(0.01, edge.weight) * confidenceFactor(edge.confidence);
44
+
45
+ export function analyzeGraphCommunities(
46
+ nodes: GraphNode[],
47
+ links: GraphLink[],
48
+ options: { nodeCap?: number } = {}
49
+ ): GraphCommunityAnalysis {
50
+ const nodeCap = options.nodeCap ?? DEFAULT_COMMUNITY_NODE_CAP;
51
+ if (nodes.length === 0) {
52
+ return {
53
+ total: 0,
54
+ algorithm: "deterministic-label-propagation",
55
+ skipped: false,
56
+ assignments: {},
57
+ communities: [],
58
+ warnings: [],
59
+ };
60
+ }
61
+
62
+ if (nodes.length > nodeCap) {
63
+ return {
64
+ total: 0,
65
+ algorithm: "deterministic-label-propagation",
66
+ skipped: true,
67
+ assignments: {},
68
+ communities: [],
69
+ warnings: [
70
+ `Community detection skipped: graph has ${nodes.length} nodes (cap ${nodeCap})`,
71
+ ],
72
+ };
73
+ }
74
+
75
+ const sortedNodes = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
76
+ const nodeIds = new Set(sortedNodes.map((node) => node.id));
77
+ const adjacency = new Map<string, Map<string, number>>();
78
+ for (const node of sortedNodes) {
79
+ adjacency.set(node.id, new Map());
80
+ }
81
+
82
+ for (const edge of links) {
83
+ if (!(nodeIds.has(edge.source) && nodeIds.has(edge.target))) continue;
84
+ if (edge.source === edge.target) continue;
85
+ const strength = edgeStrength(edge);
86
+ const sourceNeighbors = adjacency.get(edge.source);
87
+ const targetNeighbors = adjacency.get(edge.target);
88
+ if (!(sourceNeighbors && targetNeighbors)) continue;
89
+ sourceNeighbors.set(
90
+ edge.target,
91
+ (sourceNeighbors.get(edge.target) ?? 0) + strength
92
+ );
93
+ targetNeighbors.set(
94
+ edge.source,
95
+ (targetNeighbors.get(edge.source) ?? 0) + strength
96
+ );
97
+ }
98
+
99
+ const labels = new Map(sortedNodes.map((node) => [node.id, node.id]));
100
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
101
+ let changed = false;
102
+ for (const node of sortedNodes) {
103
+ const neighbors = adjacency.get(node.id);
104
+ if (!neighbors || neighbors.size === 0) continue;
105
+
106
+ const scores = new Map<string, number>();
107
+ for (const [neighborId, strength] of neighbors) {
108
+ const label = labels.get(neighborId) ?? neighborId;
109
+ scores.set(label, (scores.get(label) ?? 0) + strength);
110
+ }
111
+
112
+ const currentLabel = labels.get(node.id) ?? node.id;
113
+ let bestLabel = currentLabel;
114
+ let bestScore = scores.get(currentLabel) ?? 0;
115
+ for (const [candidate, score] of [...scores.entries()].sort((a, b) =>
116
+ a[0].localeCompare(b[0])
117
+ )) {
118
+ if (
119
+ score > bestScore ||
120
+ (score === bestScore && candidate < bestLabel)
121
+ ) {
122
+ bestLabel = candidate;
123
+ bestScore = score;
124
+ }
125
+ }
126
+ if (bestLabel !== currentLabel) {
127
+ labels.set(node.id, bestLabel);
128
+ changed = true;
129
+ }
130
+ }
131
+ if (!changed) break;
132
+ }
133
+
134
+ const groups = new Map<string, GraphNode[]>();
135
+ for (const node of sortedNodes) {
136
+ const label = labels.get(node.id) ?? node.id;
137
+ const group = groups.get(label) ?? [];
138
+ group.push(node);
139
+ groups.set(label, group);
140
+ }
141
+
142
+ const edgeCountByLabel = new Map<string, number>();
143
+ for (const edge of links) {
144
+ const sourceLabel = labels.get(edge.source);
145
+ const targetLabel = labels.get(edge.target);
146
+ if (!sourceLabel || sourceLabel !== targetLabel) continue;
147
+ edgeCountByLabel.set(
148
+ sourceLabel,
149
+ (edgeCountByLabel.get(sourceLabel) ?? 0) + 1
150
+ );
151
+ }
152
+
153
+ const orderedGroups = [...groups.entries()].sort((a, b) => {
154
+ const sizeDelta = b[1].length - a[1].length;
155
+ if (sizeDelta !== 0) return sizeDelta;
156
+ const aUri = a[1][0]?.uri ?? a[0];
157
+ const bUri = b[1][0]?.uri ?? b[0];
158
+ return aUri.localeCompare(bUri);
159
+ });
160
+
161
+ const labelToCommunityId = new Map<string, string>();
162
+ const communities = orderedGroups.map(([label, group], index) => {
163
+ const communityId = `c${index + 1}`;
164
+ labelToCommunityId.set(label, communityId);
165
+ const sortedGroup = [...group].sort(
166
+ (a, b) => b.degree - a.degree || a.uri.localeCompare(b.uri)
167
+ );
168
+ const edgeCount = edgeCountByLabel.get(label) ?? 0;
169
+ const maxUndirectedEdges = (group.length * (group.length - 1)) / 2;
170
+ return {
171
+ id: communityId,
172
+ label: sortedGroup[0]?.title ?? sortedGroup[0]?.relPath ?? communityId,
173
+ size: group.length,
174
+ edgeCount,
175
+ density: maxUndirectedEdges > 0 ? edgeCount / maxUndirectedEdges : 0,
176
+ topNodes: sortedGroup.slice(0, 5).map((node) => ({
177
+ id: node.id,
178
+ uri: node.uri,
179
+ title: node.title,
180
+ collection: node.collection,
181
+ relPath: node.relPath,
182
+ degree: node.degree,
183
+ })),
184
+ };
185
+ });
186
+
187
+ const assignments: Record<string, string> = {};
188
+ for (const node of sortedNodes) {
189
+ const label = labels.get(node.id) ?? node.id;
190
+ assignments[node.id] = labelToCommunityId.get(label) ?? "c0";
191
+ }
192
+
193
+ return {
194
+ total: communities.length,
195
+ algorithm: "deterministic-label-propagation",
196
+ skipped: false,
197
+ assignments,
198
+ communities: communities.slice(0, 10),
199
+ warnings: [],
200
+ };
201
+ }