chainlesschain 0.49.0 → 0.66.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/{AppLayout-Rvi759IS.js → AppLayout-6SPt_8Y_.js} +1 -1
  4. package/src/assets/web-panel/assets/{Dashboard-DBhFxXYQ.js → Dashboard-Br7kCwKJ.js} +2 -2
  5. package/src/assets/web-panel/assets/Dashboard-CKeMmCoT.css +1 -0
  6. package/src/assets/web-panel/assets/{index-uL0cZ8N_.js → index-tN-8TosE.js} +2 -2
  7. package/src/assets/web-panel/index.html +2 -2
  8. package/src/commands/agent-network.js +785 -0
  9. package/src/commands/automation.js +654 -0
  10. package/src/commands/dao.js +565 -0
  11. package/src/commands/did-v2.js +620 -0
  12. package/src/commands/economy.js +578 -0
  13. package/src/commands/evolution.js +391 -0
  14. package/src/commands/hmemory.js +442 -0
  15. package/src/commands/ipfs.js +392 -0
  16. package/src/commands/multimodal.js +404 -0
  17. package/src/commands/perf.js +433 -0
  18. package/src/commands/pipeline.js +449 -0
  19. package/src/commands/plugin-ecosystem.js +517 -0
  20. package/src/commands/sandbox.js +401 -0
  21. package/src/commands/social.js +311 -0
  22. package/src/commands/sso.js +798 -0
  23. package/src/commands/workflow.js +320 -0
  24. package/src/commands/zkp.js +227 -1
  25. package/src/index.js +27 -0
  26. package/src/lib/agent-economy.js +479 -0
  27. package/src/lib/agent-network.js +1121 -0
  28. package/src/lib/automation-engine.js +948 -0
  29. package/src/lib/dao-governance.js +569 -0
  30. package/src/lib/did-v2-manager.js +1127 -0
  31. package/src/lib/evolution-system.js +453 -0
  32. package/src/lib/hierarchical-memory.js +481 -0
  33. package/src/lib/ipfs-storage.js +575 -0
  34. package/src/lib/multimodal.js +39 -12
  35. package/src/lib/perf-tuning.js +734 -0
  36. package/src/lib/pipeline-orchestrator.js +928 -0
  37. package/src/lib/plugin-ecosystem.js +1109 -0
  38. package/src/lib/sandbox-v2.js +306 -0
  39. package/src/lib/social-graph-analytics.js +707 -0
  40. package/src/lib/sso-manager.js +841 -0
  41. package/src/lib/workflow-engine.js +454 -1
  42. package/src/lib/zkp-engine.js +249 -20
  43. package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +0 -1
@@ -0,0 +1,707 @@
1
+ /**
2
+ * Social Graph Analytics — centrality, community detection, shortest path,
3
+ * and composite influence scoring on snapshots produced by `social-graph.js`.
4
+ *
5
+ * Scope (CLI-first):
6
+ * - Pure functions over snapshots `{nodes, edges}` — no DB, no state
7
+ * - Callers feed a snapshot from `getGraphSnapshot()` (or any source with
8
+ * the same shape) and receive deterministic numeric scores keyed by DID
9
+ * - All metrics support optional edge-type filtering so analytics can be
10
+ * computed per relationship kind (e.g. only `follow` edges)
11
+ *
12
+ * What this module is NOT:
13
+ * - A visualization layer. Callers drive rendering from the numeric results.
14
+ * - A real-time stream. It operates on point-in-time snapshots. Callers that
15
+ * need live updates should subscribe to `social-graph.subscribe()` and
16
+ * re-run the relevant metric after each mutation batch.
17
+ * - An approximation service. Metrics are exact for the input; there is no
18
+ * sampling or Monte Carlo. Costs are O(n+m) to O(n*m) depending on the
19
+ * metric — fine for typical CLI-scale graphs (low thousands of nodes).
20
+ *
21
+ * Algorithms:
22
+ * - Degree: simple in/out/total counts, optionally normalized by (n-1)
23
+ * - Closeness: BFS from each node, harmonic mean of reciprocal distances
24
+ * (handles disconnected graphs without producing Infinity)
25
+ * - Betweenness: Brandes' algorithm (O(nm) for unweighted graphs)
26
+ * - Eigenvector: power iteration with L2 normalization, converges on
27
+ * strongly-connected components; otherwise converges to dominant one
28
+ * - Communities: label propagation with deterministic tie-breaks (sort
29
+ * by DID lexicographically); cheaper than full Louvain and matches
30
+ * the existing CLI's preference for deterministic output
31
+ * - Shortest path: BFS (unweighted); returns path of DIDs or `{found: false}`
32
+ * - Influence: weighted linear combination of normalized centralities
33
+ */
34
+
35
+ /* ── Public catalog ───────────────────────────────────────────── */
36
+
37
+ export const METRICS = Object.freeze([
38
+ "degree",
39
+ "closeness",
40
+ "betweenness",
41
+ "eigenvector",
42
+ "influence",
43
+ ]);
44
+
45
+ export const DEFAULT_INFLUENCE_WEIGHTS = Object.freeze({
46
+ degree: 0.25,
47
+ closeness: 0.25,
48
+ betweenness: 0.25,
49
+ eigenvector: 0.25,
50
+ });
51
+
52
+ /* ── Snapshot helpers ─────────────────────────────────────────── */
53
+
54
+ function _assertSnapshot(snapshot) {
55
+ if (!snapshot || typeof snapshot !== "object") {
56
+ throw new Error("snapshot must be an object with {nodes, edges}");
57
+ }
58
+ if (!Array.isArray(snapshot.nodes)) {
59
+ throw new Error("snapshot.nodes must be an array");
60
+ }
61
+ if (!Array.isArray(snapshot.edges)) {
62
+ throw new Error("snapshot.edges must be an array");
63
+ }
64
+ }
65
+
66
+ function _filterEdges(edges, edgeTypes) {
67
+ if (!edgeTypes || edgeTypes.length === 0) return edges;
68
+ const set = new Set(edgeTypes);
69
+ return edges.filter((e) => set.has(e.edgeType));
70
+ }
71
+
72
+ /**
73
+ * Collect unique DIDs from nodes AND edges (some edges may reference DIDs
74
+ * that never appeared as standalone nodes if the caller curated nodes[]).
75
+ */
76
+ function _allDids(snapshot, edges) {
77
+ const dids = new Set();
78
+ for (const n of snapshot.nodes) if (n && n.did) dids.add(n.did);
79
+ for (const e of edges) {
80
+ if (e.sourceDid) dids.add(e.sourceDid);
81
+ if (e.targetDid) dids.add(e.targetDid);
82
+ }
83
+ return [...dids].sort();
84
+ }
85
+
86
+ /**
87
+ * Build a directed adjacency map: Map<did, Map<neighborDid, totalWeight>>.
88
+ * Parallel edges of different types between the same pair collapse by
89
+ * summing weights (default 1 if edge.weight is missing).
90
+ */
91
+ function _directedAdjacency(edges) {
92
+ const adj = new Map();
93
+ for (const e of edges) {
94
+ if (!e.sourceDid || !e.targetDid) continue;
95
+ if (!adj.has(e.sourceDid)) adj.set(e.sourceDid, new Map());
96
+ const bucket = adj.get(e.sourceDid);
97
+ const w = typeof e.weight === "number" ? e.weight : 1;
98
+ bucket.set(e.targetDid, (bucket.get(e.targetDid) || 0) + w);
99
+ }
100
+ return adj;
101
+ }
102
+
103
+ /**
104
+ * Build an undirected adjacency map by summing weights in both directions.
105
+ */
106
+ function _undirectedAdjacency(edges) {
107
+ const adj = new Map();
108
+ const add = (a, b, w) => {
109
+ if (!adj.has(a)) adj.set(a, new Map());
110
+ const bucket = adj.get(a);
111
+ bucket.set(b, (bucket.get(b) || 0) + w);
112
+ };
113
+ for (const e of edges) {
114
+ if (!e.sourceDid || !e.targetDid) continue;
115
+ if (e.sourceDid === e.targetDid) continue;
116
+ const w = typeof e.weight === "number" ? e.weight : 1;
117
+ add(e.sourceDid, e.targetDid, w);
118
+ add(e.targetDid, e.sourceDid, w);
119
+ }
120
+ return adj;
121
+ }
122
+
123
+ /* ── Degree centrality ─────────────────────────────────────── */
124
+
125
+ /**
126
+ * Degree centrality per DID.
127
+ *
128
+ * @param {object} snapshot — {nodes, edges}
129
+ * @param {object} [opts]
130
+ * @param {"in"|"out"|"both"} [opts.direction="both"]
131
+ * @param {boolean} [opts.normalize=true] — divide by (n-1)
132
+ * @param {string[]} [opts.edgeTypes] — restrict to these edge types
133
+ * @returns {object} dict keyed by DID → score
134
+ */
135
+ export function degreeCentrality(snapshot, opts = {}) {
136
+ _assertSnapshot(snapshot);
137
+ const { direction = "both", normalize = true, edgeTypes } = opts;
138
+ const edges = _filterEdges(snapshot.edges, edgeTypes);
139
+ const dids = _allDids(snapshot, edges);
140
+ const scores = {};
141
+ for (const d of dids) scores[d] = 0;
142
+
143
+ for (const e of edges) {
144
+ if (!e.sourceDid || !e.targetDid) continue;
145
+ if (direction === "out" || direction === "both") {
146
+ scores[e.sourceDid] = (scores[e.sourceDid] || 0) + 1;
147
+ }
148
+ if (direction === "in" || direction === "both") {
149
+ scores[e.targetDid] = (scores[e.targetDid] || 0) + 1;
150
+ }
151
+ }
152
+
153
+ if (normalize && dids.length > 1) {
154
+ const denom = dids.length - 1;
155
+ for (const d of dids) scores[d] = scores[d] / denom;
156
+ }
157
+ return scores;
158
+ }
159
+
160
+ /* ── Closeness centrality ─────────────────────────────────── */
161
+
162
+ /**
163
+ * BFS from `source` returning Map<did, distance>. Unreachable nodes are
164
+ * excluded from the map.
165
+ */
166
+ function _bfsDistances(source, adj) {
167
+ const dist = new Map();
168
+ dist.set(source, 0);
169
+ const queue = [source];
170
+ let head = 0;
171
+ while (head < queue.length) {
172
+ const u = queue[head++];
173
+ const neighbors = adj.get(u);
174
+ if (!neighbors) continue;
175
+ for (const v of neighbors.keys()) {
176
+ if (!dist.has(v)) {
177
+ dist.set(v, dist.get(u) + 1);
178
+ queue.push(v);
179
+ }
180
+ }
181
+ }
182
+ return dist;
183
+ }
184
+
185
+ /**
186
+ * Closeness centrality (harmonic variant — handles disconnected graphs).
187
+ * C(v) = (1 / (n-1)) * sum_{u != v, reachable} 1/d(v, u)
188
+ *
189
+ * @param {object} snapshot
190
+ * @param {object} [opts]
191
+ * @param {boolean} [opts.directed=false] — if false, collapse to undirected
192
+ * @param {string[]} [opts.edgeTypes]
193
+ * @param {boolean} [opts.normalize=true] — divide by (n-1)
194
+ * @returns {object} dict DID → score
195
+ */
196
+ export function closenessCentrality(snapshot, opts = {}) {
197
+ _assertSnapshot(snapshot);
198
+ const { directed = false, edgeTypes, normalize = true } = opts;
199
+ const edges = _filterEdges(snapshot.edges, edgeTypes);
200
+ const dids = _allDids(snapshot, edges);
201
+ const adj = directed
202
+ ? _directedAdjacency(edges)
203
+ : _undirectedAdjacency(edges);
204
+
205
+ const scores = {};
206
+ for (const d of dids) scores[d] = 0;
207
+
208
+ for (const source of dids) {
209
+ const dist = _bfsDistances(source, adj);
210
+ let sum = 0;
211
+ for (const [target, dd] of dist) {
212
+ if (target === source || dd === 0) continue;
213
+ sum += 1 / dd;
214
+ }
215
+ scores[source] = sum;
216
+ }
217
+
218
+ if (normalize && dids.length > 1) {
219
+ const denom = dids.length - 1;
220
+ for (const d of dids) scores[d] = scores[d] / denom;
221
+ }
222
+ return scores;
223
+ }
224
+
225
+ /* ── Betweenness centrality (Brandes' algorithm) ──────────── */
226
+
227
+ /**
228
+ * Betweenness centrality via Brandes' algorithm for unweighted graphs.
229
+ * O(V * E) time.
230
+ *
231
+ * @param {object} snapshot
232
+ * @param {object} [opts]
233
+ * @param {boolean} [opts.directed=false]
234
+ * @param {string[]} [opts.edgeTypes]
235
+ * @param {boolean} [opts.normalize=true] — for undirected: 2/((n-1)(n-2))
236
+ * @returns {object} dict DID → score
237
+ */
238
+ export function betweennessCentrality(snapshot, opts = {}) {
239
+ _assertSnapshot(snapshot);
240
+ const { directed = false, edgeTypes, normalize = true } = opts;
241
+ const edges = _filterEdges(snapshot.edges, edgeTypes);
242
+ const dids = _allDids(snapshot, edges);
243
+ const adj = directed
244
+ ? _directedAdjacency(edges)
245
+ : _undirectedAdjacency(edges);
246
+
247
+ const scores = {};
248
+ for (const d of dids) scores[d] = 0;
249
+
250
+ for (const s of dids) {
251
+ const stack = [];
252
+ const pred = new Map();
253
+ const sigma = new Map();
254
+ const dist = new Map();
255
+ for (const v of dids) {
256
+ pred.set(v, []);
257
+ sigma.set(v, 0);
258
+ dist.set(v, -1);
259
+ }
260
+ sigma.set(s, 1);
261
+ dist.set(s, 0);
262
+
263
+ const queue = [s];
264
+ let head = 0;
265
+ while (head < queue.length) {
266
+ const v = queue[head++];
267
+ stack.push(v);
268
+ const neighbors = adj.get(v);
269
+ if (!neighbors) continue;
270
+ for (const w of neighbors.keys()) {
271
+ if (dist.get(w) < 0) {
272
+ queue.push(w);
273
+ dist.set(w, dist.get(v) + 1);
274
+ }
275
+ if (dist.get(w) === dist.get(v) + 1) {
276
+ sigma.set(w, sigma.get(w) + sigma.get(v));
277
+ pred.get(w).push(v);
278
+ }
279
+ }
280
+ }
281
+
282
+ const delta = new Map();
283
+ for (const v of dids) delta.set(v, 0);
284
+ while (stack.length > 0) {
285
+ const w = stack.pop();
286
+ for (const v of pred.get(w)) {
287
+ const contrib = (sigma.get(v) / sigma.get(w)) * (1 + delta.get(w));
288
+ delta.set(v, delta.get(v) + contrib);
289
+ }
290
+ if (w !== s) scores[w] += delta.get(w);
291
+ }
292
+ }
293
+
294
+ // For undirected graphs, Brandes' formulation double-counts each pair.
295
+ if (!directed) {
296
+ for (const d of dids) scores[d] = scores[d] / 2;
297
+ }
298
+
299
+ if (normalize && dids.length > 2) {
300
+ const n = dids.length;
301
+ const denom = directed ? (n - 1) * (n - 2) : ((n - 1) * (n - 2)) / 2;
302
+ if (denom > 0) {
303
+ for (const d of dids) scores[d] = scores[d] / denom;
304
+ }
305
+ }
306
+ return scores;
307
+ }
308
+
309
+ /* ── Eigenvector centrality ───────────────────────────────── */
310
+
311
+ /**
312
+ * Eigenvector centrality via power iteration.
313
+ *
314
+ * @param {object} snapshot
315
+ * @param {object} [opts]
316
+ * @param {boolean} [opts.directed=false]
317
+ * @param {string[]} [opts.edgeTypes]
318
+ * @param {number} [opts.iterations=100]
319
+ * @param {number} [opts.tolerance=1e-6]
320
+ * @returns {object} dict DID → score (L2-normalized, non-negative)
321
+ */
322
+ export function eigenvectorCentrality(snapshot, opts = {}) {
323
+ _assertSnapshot(snapshot);
324
+ const {
325
+ directed = false,
326
+ edgeTypes,
327
+ iterations = 100,
328
+ tolerance = 1e-6,
329
+ } = opts;
330
+ const edges = _filterEdges(snapshot.edges, edgeTypes);
331
+ const dids = _allDids(snapshot, edges);
332
+ const adj = directed
333
+ ? _directedAdjacency(edges)
334
+ : _undirectedAdjacency(edges);
335
+
336
+ if (dids.length === 0) return {};
337
+
338
+ // Initialize uniformly; converges to principal eigenvector.
339
+ let x = new Map();
340
+ const seed = 1 / Math.sqrt(dids.length);
341
+ for (const d of dids) x.set(d, seed);
342
+
343
+ for (let iter = 0; iter < iterations; iter++) {
344
+ const xNew = new Map();
345
+ for (const d of dids) xNew.set(d, 0);
346
+
347
+ for (const [u, neighbors] of adj) {
348
+ for (const [v, w] of neighbors) {
349
+ xNew.set(v, (xNew.get(v) || 0) + w * x.get(u));
350
+ }
351
+ }
352
+
353
+ // Shift by I (add x to xNew) to break bipartite oscillation. Mathematically
354
+ // this yields the principal eigenvector of (I + A), which shares the same
355
+ // principal eigenvector direction as A for connected non-negative graphs
356
+ // but eliminates the +λ/-λ sign flip that causes power iteration to stall
357
+ // on bipartite structures (e.g. star graphs).
358
+ for (const d of dids) {
359
+ xNew.set(d, xNew.get(d) + x.get(d));
360
+ }
361
+
362
+ // L2 normalize; if zero vector, seed back uniformly.
363
+ let norm = 0;
364
+ for (const d of dids) norm += xNew.get(d) * xNew.get(d);
365
+ norm = Math.sqrt(norm);
366
+ if (norm === 0) {
367
+ for (const d of dids) xNew.set(d, seed);
368
+ } else {
369
+ for (const d of dids) xNew.set(d, xNew.get(d) / norm);
370
+ }
371
+
372
+ // Convergence check
373
+ let diff = 0;
374
+ for (const d of dids) diff += Math.abs(xNew.get(d) - x.get(d));
375
+ x = xNew;
376
+ if (diff < tolerance) break;
377
+ }
378
+
379
+ const scores = {};
380
+ for (const d of dids) scores[d] = Math.abs(x.get(d));
381
+ return scores;
382
+ }
383
+
384
+ /* ── Influence score (composite) ──────────────────────────── */
385
+
386
+ /**
387
+ * Composite influence score: weighted linear combination of the four
388
+ * centrality metrics, each normalized to [0, 1] before combining.
389
+ *
390
+ * @param {object} snapshot
391
+ * @param {object} [opts]
392
+ * @param {object} [opts.weights] — {degree, closeness, betweenness, eigenvector}
393
+ * @param {string[]} [opts.edgeTypes]
394
+ * @param {boolean} [opts.directed=false]
395
+ * @returns {object} dict DID → score in [0, 1]
396
+ */
397
+ export function influenceScore(snapshot, opts = {}) {
398
+ _assertSnapshot(snapshot);
399
+ const { weights: userWeights, edgeTypes, directed = false } = opts;
400
+ const weights = { ...DEFAULT_INFLUENCE_WEIGHTS, ...(userWeights || {}) };
401
+
402
+ // Normalize weights so they sum to 1 (allows callers to pass raw ratios).
403
+ const wSum =
404
+ (weights.degree || 0) +
405
+ (weights.closeness || 0) +
406
+ (weights.betweenness || 0) +
407
+ (weights.eigenvector || 0);
408
+ if (wSum <= 0) {
409
+ throw new Error("influence weights must sum to a positive value");
410
+ }
411
+ const w = {
412
+ degree: (weights.degree || 0) / wSum,
413
+ closeness: (weights.closeness || 0) / wSum,
414
+ betweenness: (weights.betweenness || 0) / wSum,
415
+ eigenvector: (weights.eigenvector || 0) / wSum,
416
+ };
417
+
418
+ const passOpts = { edgeTypes, directed };
419
+ const deg = degreeCentrality(snapshot, { ...passOpts, normalize: true });
420
+ const close = closenessCentrality(snapshot, { ...passOpts, normalize: true });
421
+ const btw = betweennessCentrality(snapshot, { ...passOpts, normalize: true });
422
+ const eig = eigenvectorCentrality(snapshot, passOpts);
423
+
424
+ const dids = Object.keys(deg);
425
+ if (dids.length === 0) return {};
426
+
427
+ // Normalize each metric vector by its max so all land in [0, 1].
428
+ const _normalize = (m) => {
429
+ const values = dids.map((d) => m[d] || 0);
430
+ const max = Math.max(...values, 0);
431
+ if (max === 0) return dids.reduce((acc, d) => ((acc[d] = 0), acc), {});
432
+ const out = {};
433
+ for (const d of dids) out[d] = (m[d] || 0) / max;
434
+ return out;
435
+ };
436
+ const dN = _normalize(deg);
437
+ const cN = _normalize(close);
438
+ const bN = _normalize(btw);
439
+ const eN = _normalize(eig);
440
+
441
+ const out = {};
442
+ for (const d of dids) {
443
+ out[d] =
444
+ w.degree * dN[d] +
445
+ w.closeness * cN[d] +
446
+ w.betweenness * bN[d] +
447
+ w.eigenvector * eN[d];
448
+ }
449
+ return out;
450
+ }
451
+
452
+ /* ── Community detection (label propagation) ──────────────── */
453
+
454
+ /**
455
+ * Label propagation community detection. Deterministic via
456
+ * lexicographic DID ordering + tie-breaking by smallest label.
457
+ *
458
+ * @param {object} snapshot
459
+ * @param {object} [opts]
460
+ * @param {string[]} [opts.edgeTypes]
461
+ * @param {number} [opts.maxIterations=20]
462
+ * @param {number} [opts.minSize=1] — filter out communities below this size
463
+ * @returns {{communities: Array, modularity: number}}
464
+ */
465
+ export function detectCommunities(snapshot, opts = {}) {
466
+ _assertSnapshot(snapshot);
467
+ const { edgeTypes, maxIterations = 20, minSize = 1 } = opts;
468
+ const edges = _filterEdges(snapshot.edges, edgeTypes);
469
+ const dids = _allDids(snapshot, edges);
470
+ const adj = _undirectedAdjacency(edges);
471
+
472
+ if (dids.length === 0) {
473
+ return { communities: [], modularity: 0 };
474
+ }
475
+
476
+ // Initialize: each node is its own community, labeled by its DID.
477
+ const labels = new Map();
478
+ for (const d of dids) labels.set(d, d);
479
+
480
+ // Stable sort ensures deterministic traversal.
481
+ const sortedDids = [...dids].sort();
482
+
483
+ for (let iter = 0; iter < maxIterations; iter++) {
484
+ let changed = false;
485
+ for (const v of sortedDids) {
486
+ const neighbors = adj.get(v);
487
+ if (!neighbors || neighbors.size === 0) continue;
488
+
489
+ // Tally label frequencies (weighted).
490
+ const counts = new Map();
491
+ for (const [u, w] of neighbors) {
492
+ const lbl = labels.get(u);
493
+ counts.set(lbl, (counts.get(lbl) || 0) + w);
494
+ }
495
+
496
+ // Pick the max-weight label; tie-break by smallest lexicographic label.
497
+ let best = labels.get(v);
498
+ let bestWeight = counts.get(best) || 0;
499
+ const sortedLabels = [...counts.keys()].sort();
500
+ for (const lbl of sortedLabels) {
501
+ const w = counts.get(lbl);
502
+ if (w > bestWeight) {
503
+ best = lbl;
504
+ bestWeight = w;
505
+ }
506
+ }
507
+ if (best !== labels.get(v)) {
508
+ labels.set(v, best);
509
+ changed = true;
510
+ }
511
+ }
512
+ if (!changed) break;
513
+ }
514
+
515
+ // Group by label.
516
+ const groups = new Map();
517
+ for (const d of sortedDids) {
518
+ const lbl = labels.get(d);
519
+ if (!groups.has(lbl)) groups.set(lbl, []);
520
+ groups.get(lbl).push(d);
521
+ }
522
+
523
+ // Assign stable community IDs (c0, c1, ...) sorted by first member DID.
524
+ const labelList = [...groups.keys()].sort((a, b) => {
525
+ const aFirst = groups.get(a)[0];
526
+ const bFirst = groups.get(b)[0];
527
+ return aFirst < bFirst ? -1 : aFirst > bFirst ? 1 : 0;
528
+ });
529
+
530
+ const communities = [];
531
+ let idx = 0;
532
+ for (const lbl of labelList) {
533
+ const members = groups.get(lbl);
534
+ if (members.length < minSize) continue;
535
+ communities.push({
536
+ id: `c${idx++}`,
537
+ label: lbl,
538
+ members: [...members].sort(),
539
+ size: members.length,
540
+ });
541
+ }
542
+
543
+ // Modularity: Q = sum_c [L_c / m - (k_c / 2m)^2]
544
+ let m = 0;
545
+ for (const [, neighbors] of adj) {
546
+ for (const [, w] of neighbors) m += w;
547
+ }
548
+ m = m / 2; // undirected
549
+ let Q = 0;
550
+ if (m > 0) {
551
+ for (const c of communities) {
552
+ const set = new Set(c.members);
553
+ let Lc = 0;
554
+ let kc = 0;
555
+ for (const v of c.members) {
556
+ const neighbors = adj.get(v);
557
+ if (!neighbors) continue;
558
+ for (const [u, w] of neighbors) {
559
+ kc += w;
560
+ if (set.has(u)) Lc += w;
561
+ }
562
+ }
563
+ Lc = Lc / 2;
564
+ Q += Lc / m - Math.pow(kc / (2 * m), 2);
565
+ }
566
+ }
567
+
568
+ return { communities, modularity: Q };
569
+ }
570
+
571
+ /* ── Shortest path (BFS) ──────────────────────────────────── */
572
+
573
+ /**
574
+ * Unweighted shortest path between two DIDs.
575
+ *
576
+ * @param {object} snapshot
577
+ * @param {string} source
578
+ * @param {string} target
579
+ * @param {object} [opts]
580
+ * @param {boolean} [opts.directed=true]
581
+ * @param {string[]} [opts.edgeTypes]
582
+ * @returns {{found: boolean, distance?: number, path?: string[]}}
583
+ */
584
+ export function shortestPath(snapshot, source, target, opts = {}) {
585
+ _assertSnapshot(snapshot);
586
+ if (!source || !target) {
587
+ return { found: false, reason: "missing source or target" };
588
+ }
589
+ if (source === target) {
590
+ return { found: true, distance: 0, path: [source] };
591
+ }
592
+ const { directed = true, edgeTypes } = opts;
593
+ const edges = _filterEdges(snapshot.edges, edgeTypes);
594
+ const adj = directed
595
+ ? _directedAdjacency(edges)
596
+ : _undirectedAdjacency(edges);
597
+
598
+ const parent = new Map();
599
+ parent.set(source, null);
600
+ const queue = [source];
601
+ let head = 0;
602
+ let found = false;
603
+ while (head < queue.length) {
604
+ const u = queue[head++];
605
+ if (u === target) {
606
+ found = true;
607
+ break;
608
+ }
609
+ const neighbors = adj.get(u);
610
+ if (!neighbors) continue;
611
+ for (const v of neighbors.keys()) {
612
+ if (!parent.has(v)) {
613
+ parent.set(v, u);
614
+ queue.push(v);
615
+ }
616
+ }
617
+ }
618
+ if (!found) return { found: false };
619
+
620
+ // Reconstruct path.
621
+ const path = [];
622
+ let cur = target;
623
+ while (cur !== null && cur !== undefined) {
624
+ path.unshift(cur);
625
+ cur = parent.get(cur);
626
+ }
627
+ return { found: true, distance: path.length - 1, path };
628
+ }
629
+
630
+ /* ── Top-N ranking ────────────────────────────────────────── */
631
+
632
+ /**
633
+ * Rank DIDs by a named metric. Returns up to `limit` entries sorted
634
+ * descending by score, ties broken by lexicographic DID order.
635
+ *
636
+ * @param {object} snapshot
637
+ * @param {"degree"|"closeness"|"betweenness"|"eigenvector"|"influence"} metric
638
+ * @param {object} [opts]
639
+ * @param {number} [opts.limit=10]
640
+ * @param {string[]} [opts.edgeTypes]
641
+ * @param {boolean} [opts.directed=false]
642
+ * @param {object} [opts.weights] — only used when metric="influence"
643
+ * @returns {Array<{did: string, score: number}>}
644
+ */
645
+ export function topByMetric(snapshot, metric, opts = {}) {
646
+ if (!METRICS.includes(metric)) {
647
+ throw new Error(
648
+ `Unknown metric "${metric}"; expected one of ${METRICS.join(", ")}`,
649
+ );
650
+ }
651
+ const { limit = 10 } = opts;
652
+ let scores;
653
+ switch (metric) {
654
+ case "degree":
655
+ scores = degreeCentrality(snapshot, opts);
656
+ break;
657
+ case "closeness":
658
+ scores = closenessCentrality(snapshot, opts);
659
+ break;
660
+ case "betweenness":
661
+ scores = betweennessCentrality(snapshot, opts);
662
+ break;
663
+ case "eigenvector":
664
+ scores = eigenvectorCentrality(snapshot, opts);
665
+ break;
666
+ case "influence":
667
+ scores = influenceScore(snapshot, opts);
668
+ break;
669
+ }
670
+ const entries = Object.entries(scores || {}).map(([did, score]) => ({
671
+ did,
672
+ score,
673
+ }));
674
+ entries.sort((a, b) => {
675
+ if (b.score !== a.score) return b.score - a.score;
676
+ return a.did < b.did ? -1 : a.did > b.did ? 1 : 0;
677
+ });
678
+ return entries.slice(0, Math.max(0, limit));
679
+ }
680
+
681
+ /* ── Summary stats ────────────────────────────────────────── */
682
+
683
+ /**
684
+ * Convenience snapshot-level rollup: node/edge counts, density, and
685
+ * the top-5 scorer by influence.
686
+ */
687
+ export function analyticsStats(snapshot, opts = {}) {
688
+ _assertSnapshot(snapshot);
689
+ const { edgeTypes } = opts;
690
+ const edges = _filterEdges(snapshot.edges, edgeTypes);
691
+ const dids = _allDids(snapshot, edges);
692
+ const n = dids.length;
693
+ const maxPossibleDirected = n * (n - 1);
694
+ const density =
695
+ maxPossibleDirected > 0 ? edges.length / maxPossibleDirected : 0;
696
+
697
+ const topInfluence =
698
+ n > 0 ? topByMetric(snapshot, "influence", { ...opts, limit: 5 }) : [];
699
+
700
+ return {
701
+ nodeCount: n,
702
+ edgeCount: edges.length,
703
+ density,
704
+ topInfluence,
705
+ generatedAt: new Date().toISOString(),
706
+ };
707
+ }