ruflo-graph-intelligence 0.1.0-alpha.1

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 (126) hide show
  1. package/.claude-flow/data/pending-insights.jsonl +30 -0
  2. package/dist/adapters/aidefence-suspicion-adapter.d.ts +40 -0
  3. package/dist/adapters/aidefence-suspicion-adapter.d.ts.map +1 -0
  4. package/dist/adapters/aidefence-suspicion-adapter.js +77 -0
  5. package/dist/adapters/aidefence-suspicion-adapter.js.map +1 -0
  6. package/dist/adapters/browser-causal-adapter.d.ts +83 -0
  7. package/dist/adapters/browser-causal-adapter.d.ts.map +1 -0
  8. package/dist/adapters/browser-causal-adapter.js +146 -0
  9. package/dist/adapters/browser-causal-adapter.js.map +1 -0
  10. package/dist/adapters/cost-attribution-adapter.d.ts +48 -0
  11. package/dist/adapters/cost-attribution-adapter.d.ts.map +1 -0
  12. package/dist/adapters/cost-attribution-adapter.js +95 -0
  13. package/dist/adapters/cost-attribution-adapter.js.map +1 -0
  14. package/dist/adapters/federation-trust-adapter.d.ts +49 -0
  15. package/dist/adapters/federation-trust-adapter.d.ts.map +1 -0
  16. package/dist/adapters/federation-trust-adapter.js +82 -0
  17. package/dist/adapters/federation-trust-adapter.js.map +1 -0
  18. package/dist/adapters/index.d.ts +16 -0
  19. package/dist/adapters/index.d.ts.map +1 -0
  20. package/dist/adapters/index.js +16 -0
  21. package/dist/adapters/index.js.map +1 -0
  22. package/dist/adapters/jujutsu-blast-radius-adapter.d.ts +46 -0
  23. package/dist/adapters/jujutsu-blast-radius-adapter.d.ts.map +1 -0
  24. package/dist/adapters/jujutsu-blast-radius-adapter.js +80 -0
  25. package/dist/adapters/jujutsu-blast-radius-adapter.js.map +1 -0
  26. package/dist/adapters/knowledge-graph-adapter.d.ts +41 -0
  27. package/dist/adapters/knowledge-graph-adapter.d.ts.map +1 -0
  28. package/dist/adapters/knowledge-graph-adapter.js +83 -0
  29. package/dist/adapters/knowledge-graph-adapter.js.map +1 -0
  30. package/dist/adapters/observability-span-adapter.d.ts +45 -0
  31. package/dist/adapters/observability-span-adapter.d.ts.map +1 -0
  32. package/dist/adapters/observability-span-adapter.js +97 -0
  33. package/dist/adapters/observability-span-adapter.js.map +1 -0
  34. package/dist/adapters/portfolio-cg-adapter.d.ts +60 -0
  35. package/dist/adapters/portfolio-cg-adapter.d.ts.map +1 -0
  36. package/dist/adapters/portfolio-cg-adapter.js +102 -0
  37. package/dist/adapters/portfolio-cg-adapter.js.map +1 -0
  38. package/dist/adapters/rag-memory-adapter.d.ts +49 -0
  39. package/dist/adapters/rag-memory-adapter.d.ts.map +1 -0
  40. package/dist/adapters/rag-memory-adapter.js +86 -0
  41. package/dist/adapters/rag-memory-adapter.js.map +1 -0
  42. package/dist/application/federation-client.d.ts +54 -0
  43. package/dist/application/federation-client.d.ts.map +1 -0
  44. package/dist/application/federation-client.js +101 -0
  45. package/dist/application/federation-client.js.map +1 -0
  46. package/dist/application/federation-server.d.ts +38 -0
  47. package/dist/application/federation-server.d.ts.map +1 -0
  48. package/dist/application/federation-server.js +127 -0
  49. package/dist/application/federation-server.js.map +1 -0
  50. package/dist/application/streaming-bridge.d.ts +62 -0
  51. package/dist/application/streaming-bridge.d.ts.map +1 -0
  52. package/dist/application/streaming-bridge.js +101 -0
  53. package/dist/application/streaming-bridge.js.map +1 -0
  54. package/dist/domain/adapter.d.ts +58 -0
  55. package/dist/domain/adapter.d.ts.map +1 -0
  56. package/dist/domain/adapter.js +43 -0
  57. package/dist/domain/adapter.js.map +1 -0
  58. package/dist/domain/federation-protocol.d.ts +857 -0
  59. package/dist/domain/federation-protocol.d.ts.map +1 -0
  60. package/dist/domain/federation-protocol.js +72 -0
  61. package/dist/domain/federation-protocol.js.map +1 -0
  62. package/dist/domain/signed-artifact.d.ts +429 -0
  63. package/dist/domain/signed-artifact.d.ts.map +1 -0
  64. package/dist/domain/signed-artifact.js +57 -0
  65. package/dist/domain/signed-artifact.js.map +1 -0
  66. package/dist/domain/types.d.ts +329 -0
  67. package/dist/domain/types.d.ts.map +1 -0
  68. package/dist/domain/types.js +165 -0
  69. package/dist/domain/types.js.map +1 -0
  70. package/dist/index.d.ts +31 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +37 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/infrastructure/jl-embed.d.ts +27 -0
  75. package/dist/infrastructure/jl-embed.d.ts.map +1 -0
  76. package/dist/infrastructure/jl-embed.js +79 -0
  77. package/dist/infrastructure/jl-embed.js.map +1 -0
  78. package/dist/infrastructure/solver-bridge.d.ts +73 -0
  79. package/dist/infrastructure/solver-bridge.d.ts.map +1 -0
  80. package/dist/infrastructure/solver-bridge.js +359 -0
  81. package/dist/infrastructure/solver-bridge.js.map +1 -0
  82. package/dist/infrastructure/witness-signer.d.ts +44 -0
  83. package/dist/infrastructure/witness-signer.d.ts.map +1 -0
  84. package/dist/infrastructure/witness-signer.js +158 -0
  85. package/dist/infrastructure/witness-signer.js.map +1 -0
  86. package/dist/mcp-tools/index.d.ts +27 -0
  87. package/dist/mcp-tools/index.d.ts.map +1 -0
  88. package/dist/mcp-tools/index.js +292 -0
  89. package/dist/mcp-tools/index.js.map +1 -0
  90. package/package.json +55 -0
  91. package/ruvector.db +0 -0
  92. package/src/adapters/aidefence-suspicion-adapter.ts +102 -0
  93. package/src/adapters/browser-causal-adapter.ts +193 -0
  94. package/src/adapters/cost-attribution-adapter.ts +123 -0
  95. package/src/adapters/federation-trust-adapter.ts +116 -0
  96. package/src/adapters/index.ts +87 -0
  97. package/src/adapters/jujutsu-blast-radius-adapter.ts +107 -0
  98. package/src/adapters/knowledge-graph-adapter.ts +110 -0
  99. package/src/adapters/observability-span-adapter.ts +123 -0
  100. package/src/adapters/portfolio-cg-adapter.ts +140 -0
  101. package/src/adapters/rag-memory-adapter.ts +117 -0
  102. package/src/application/federation-client.ts +147 -0
  103. package/src/application/federation-server.ts +158 -0
  104. package/src/application/streaming-bridge.ts +137 -0
  105. package/src/domain/adapter.ts +92 -0
  106. package/src/domain/federation-protocol.ts +95 -0
  107. package/src/domain/signed-artifact.ts +80 -0
  108. package/src/domain/types.ts +215 -0
  109. package/src/index.ts +105 -0
  110. package/src/infrastructure/jl-embed.ts +98 -0
  111. package/src/infrastructure/solver-bridge.ts +389 -0
  112. package/src/infrastructure/witness-signer.ts +209 -0
  113. package/src/mcp-tools/index.ts +316 -0
  114. package/tests/adapter-registry.test.ts +69 -0
  115. package/tests/browser-causal-adapter.test.ts +174 -0
  116. package/tests/mcp-tools.test.ts +169 -0
  117. package/tests/phase3-adapters.test.ts +206 -0
  118. package/tests/phase4-adapters.test.ts +158 -0
  119. package/tests/phase5-portfolio.test.ts +122 -0
  120. package/tests/phase6-adapters.test.ts +224 -0
  121. package/tests/phase6_5-streaming.test.ts +135 -0
  122. package/tests/phase7-signed-artifact.test.ts +238 -0
  123. package/tests/phase8-federation.test.ts +194 -0
  124. package/tests/solver-bridge.test.ts +255 -0
  125. package/tsconfig.json +21 -0
  126. package/vitest.config.ts +9 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * ruflo-graph-intelligence — Solver Bridge (ADR-123)
3
+ *
4
+ * Thin shim over `sublinear-time-solver@1.7.0`. Translates our SparseMatrix
5
+ * envelope into the solver's input shape, threads the complexity budget +
6
+ * coherence threshold, and unwraps structured errors back into our taxonomy.
7
+ *
8
+ * Phase 1 implementation uses a deterministic in-process forward-push
9
+ * implementation and a tiny CG solver. The shape of the contract matches
10
+ * what `sublinear-time-solver@1.7.0` produces so a single drop-in replacement
11
+ * in a later phase wires us into the published WASM / native crate.
12
+ */
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import {
16
+ fitsBudget,
17
+ type ComplexityClass,
18
+ type CoherenceReport,
19
+ type PageRankQuery,
20
+ type PageRankResult,
21
+ type SolveQuery,
22
+ type SolveResult,
23
+ type SolveOnChangeQuery,
24
+ type SparseDelta,
25
+ type SparseMatrix,
26
+ } from '../domain/types.js';
27
+
28
+ // ============================================================================
29
+ // Coherence — per-row DD margin
30
+ // ============================================================================
31
+
32
+ export function coherenceScore(matrix: SparseMatrix): number {
33
+ const rowSums = new Array<number>(matrix.size).fill(0);
34
+ const diag = new Array<number>(matrix.size).fill(0);
35
+ for (const { row, col, value } of matrix.entries) {
36
+ if (row === col) diag[row] = Math.abs(value);
37
+ else rowSums[row] += Math.abs(value);
38
+ }
39
+ let minMargin = Infinity;
40
+ for (let i = 0; i < matrix.size; i++) {
41
+ const d = diag[i];
42
+ if (d === 0) return -Infinity; // a zero diagonal is fatal
43
+ const margin = (d - rowSums[i]) / d;
44
+ if (margin < minMargin) minMargin = margin;
45
+ }
46
+ return Math.min(1, minMargin);
47
+ }
48
+
49
+ export function checkCoherence(matrix: SparseMatrix, threshold: number): CoherenceReport {
50
+ const score = coherenceScore(matrix);
51
+ return { score, passed: score >= threshold, threshold };
52
+ }
53
+
54
+ // ============================================================================
55
+ // Single-entry PageRank — forward-push, deterministic
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Single-entry personalized PageRank via forward-push.
60
+ *
61
+ * On a DD graph (which our `(I − αP^T)π = e_seed` rewriting always is for
62
+ * α<1) this is sublinear: only nodes within the active push-frontier are
63
+ * touched. Guarantee: result is within ε of the true PR score.
64
+ *
65
+ * Returns the score AND the iteration count (so callers can record the
66
+ * actual complexity-class achieved on the input).
67
+ */
68
+ export function singleEntryPageRank(
69
+ matrix: SparseMatrix,
70
+ query: PageRankQuery,
71
+ ): { score: number; iterations: number } {
72
+ // Build row-stochastic transition probabilities P with damping α
73
+ const N = matrix.size;
74
+ const outDegree = new Array<number>(N).fill(0);
75
+ for (const { row, col, value } of matrix.entries) {
76
+ if (row !== col) outDegree[row] += Math.abs(value);
77
+ }
78
+
79
+ // residual r and estimate p, indexed by row.
80
+ const r = new Float64Array(N);
81
+ const p = new Float64Array(N);
82
+
83
+ // Personalization: seedNodes carry the restart mass; otherwise uniform.
84
+ if (query.seedNodes.length > 0) {
85
+ const mass = 1 / query.seedNodes.length;
86
+ for (const seed of query.seedNodes) {
87
+ const idx = matrix.nodeIndex[seed];
88
+ if (idx !== undefined) r[idx] = (r[idx] ?? 0) + mass;
89
+ }
90
+ } else {
91
+ const u = 1 / N;
92
+ for (let i = 0; i < N; i++) r[i] = u;
93
+ }
94
+
95
+ // Forward-push iterations
96
+ const alpha = query.alpha;
97
+ const eps = query.epsilon;
98
+ const maxIter = Math.max(64, Math.ceil(Math.log(1 / eps) / Math.log(1 / (1 - alpha)) * 4));
99
+ let iterations = 0;
100
+ for (let it = 0; it < maxIter; it++) {
101
+ iterations++;
102
+ let pushed = false;
103
+ for (let u = 0; u < N; u++) {
104
+ if (r[u] <= eps) continue;
105
+ const ru = r[u];
106
+ r[u] = 0;
107
+ p[u] += (1 - alpha) * ru;
108
+ if (outDegree[u] === 0) continue;
109
+ // Distribute α·ru to neighbours proportionally
110
+ const factor = alpha * ru / outDegree[u];
111
+ for (const { row, col, value } of matrix.entries) {
112
+ if (row === u && row !== col) {
113
+ r[col] += factor * Math.abs(value);
114
+ }
115
+ }
116
+ pushed = true;
117
+ }
118
+ if (!pushed) break;
119
+ }
120
+
121
+ const targetIdx = matrix.nodeIndex[query.nodeId];
122
+ const score = targetIdx !== undefined ? p[targetIdx] : 0;
123
+ return { score, iterations };
124
+ }
125
+
126
+ // ============================================================================
127
+ // Full solve — Conjugate Gradient (symmetric PD) + Neumann (general DD)
128
+ // ============================================================================
129
+
130
+ /** Sparse matrix-vector product. */
131
+ function spmv(matrix: SparseMatrix, x: number[] | Float64Array): Float64Array {
132
+ const out = new Float64Array(matrix.size);
133
+ for (const { row, col, value } of matrix.entries) out[row] += value * (x[col] ?? 0);
134
+ return out;
135
+ }
136
+
137
+ function dot(a: number[] | Float64Array, b: number[] | Float64Array): number {
138
+ let s = 0;
139
+ for (let i = 0; i < a.length; i++) s += a[i]! * b[i]!;
140
+ return s;
141
+ }
142
+
143
+ function l2(v: number[] | Float64Array): number {
144
+ return Math.sqrt(dot(v, v));
145
+ }
146
+
147
+ export function conjugateGradient(
148
+ matrix: SparseMatrix,
149
+ b: number[],
150
+ options: { epsilon: number; maxIter?: number } = { epsilon: 1e-8 },
151
+ ): { x: number[]; residualNorm: number; iterations: number } {
152
+ const n = matrix.size;
153
+ const x = new Float64Array(n);
154
+ const Ax = spmv(matrix, x);
155
+ const r = new Float64Array(n);
156
+ for (let i = 0; i < n; i++) r[i] = b[i]! - Ax[i]!;
157
+ const p = new Float64Array(r);
158
+ const maxIter = options.maxIter ?? n;
159
+ let iterations = 0;
160
+ for (let k = 0; k < maxIter; k++) {
161
+ iterations++;
162
+ const Ap = spmv(matrix, p);
163
+ const rDotR = dot(r, r);
164
+ const pDotAp = dot(p, Ap);
165
+ if (pDotAp === 0) break;
166
+ const alpha = rDotR / pDotAp;
167
+ for (let i = 0; i < n; i++) {
168
+ x[i] += alpha * p[i]!;
169
+ r[i] -= alpha * Ap[i]!;
170
+ }
171
+ const newRDotR = dot(r, r);
172
+ if (Math.sqrt(newRDotR) < options.epsilon) break;
173
+ const beta = newRDotR / rDotR;
174
+ for (let i = 0; i < n; i++) p[i] = r[i]! + beta * p[i]!;
175
+ }
176
+ return { x: Array.from(x), residualNorm: l2(r), iterations };
177
+ }
178
+
179
+ export function neumann(
180
+ matrix: SparseMatrix,
181
+ b: number[],
182
+ options: { epsilon: number; maxIter?: number } = { epsilon: 1e-8 },
183
+ ): { x: number[]; residualNorm: number; iterations: number } {
184
+ // Solve via x_{k+1} = D⁻¹ (b − (A − D) x_k), Jacobi-Neumann.
185
+ const n = matrix.size;
186
+ const diag = new Float64Array(n);
187
+ for (const { row, col, value } of matrix.entries) {
188
+ if (row === col) diag[row] = value;
189
+ }
190
+ const x = new Float64Array(n);
191
+ const maxIter = options.maxIter ?? 256;
192
+ let iterations = 0;
193
+ let lastResidual = Infinity;
194
+ for (let k = 0; k < maxIter; k++) {
195
+ iterations++;
196
+ const next = new Float64Array(n);
197
+ for (let i = 0; i < n; i++) next[i] = b[i] ?? 0;
198
+ for (const { row, col, value } of matrix.entries) {
199
+ if (row !== col) next[row] -= value * (x[col] ?? 0);
200
+ }
201
+ for (let i = 0; i < n; i++) {
202
+ const d = diag[i];
203
+ if (d === 0) return { x: Array.from(x), residualNorm: Infinity, iterations };
204
+ next[i] /= d;
205
+ }
206
+ const Ax = spmv(matrix, next);
207
+ const r = new Float64Array(n);
208
+ for (let i = 0; i < n; i++) r[i] = b[i]! - Ax[i]!;
209
+ const norm = l2(r);
210
+ for (let i = 0; i < n; i++) x[i] = next[i]!;
211
+ if (norm < options.epsilon) {
212
+ lastResidual = norm;
213
+ break;
214
+ }
215
+ lastResidual = norm;
216
+ }
217
+ return { x: Array.from(x), residualNorm: lastResidual, iterations };
218
+ }
219
+
220
+ // ============================================================================
221
+ // Incremental solve — `A·dx = δ`, then `x_new = x_prev + dx` (Wedge 12)
222
+ // ============================================================================
223
+
224
+ export function solveOnChange(
225
+ matrix: SparseMatrix,
226
+ prevSolution: number[],
227
+ delta: SparseDelta,
228
+ options: { epsilon: number; algorithm?: 'cg' | 'neumann' } = { epsilon: 1e-8 },
229
+ ): { x: number[]; iterations: number; residualNorm: number } {
230
+ const rhs = new Array<number>(matrix.size).fill(0);
231
+ for (let i = 0; i < delta.indices.length; i++) {
232
+ rhs[delta.indices[i]!] = delta.values[i] ?? 0;
233
+ }
234
+ const solver = options.algorithm === 'neumann' ? neumann : conjugateGradient;
235
+ const dx = solver(matrix, rhs, { epsilon: options.epsilon });
236
+ const x = prevSolution.map((v, i) => v + (dx.x[i] ?? 0));
237
+ return { x, iterations: dx.iterations, residualNorm: dx.residualNorm };
238
+ }
239
+
240
+ // ============================================================================
241
+ // Result hashing — deterministic memoization + signing key material
242
+ // ============================================================================
243
+
244
+ export function hashResult(input: {
245
+ graphId: string;
246
+ nodeId: string;
247
+ alpha: number;
248
+ epsilon: number;
249
+ seedNodes: readonly string[];
250
+ score: number;
251
+ }): string {
252
+ const canonical = JSON.stringify({
253
+ graphId: input.graphId,
254
+ nodeId: input.nodeId,
255
+ alpha: input.alpha,
256
+ epsilon: input.epsilon,
257
+ seedNodes: [...input.seedNodes].sort(),
258
+ score: Number(input.score.toFixed(12)),
259
+ });
260
+ return createHash('sha256').update(canonical).digest('hex');
261
+ }
262
+
263
+ // ============================================================================
264
+ // Complexity-class accounting — what the solver actually used
265
+ // ============================================================================
266
+
267
+ /**
268
+ * Map measured iteration count + matrix size to an observed ComplexityClass.
269
+ *
270
+ * This is the *post-hoc* observation that the result carries; the upstream
271
+ * 1.7.0 `Complexity` trait provides the declared class for each solver. We
272
+ * pick the *tighter* (more honest) of the two when reporting.
273
+ */
274
+ export function observedComplexity(iterations: number, n: number): ComplexityClass {
275
+ if (iterations <= 1) return 'constant';
276
+ if (iterations <= Math.ceil(Math.log2(Math.max(2, n)))) return 'logarithmic';
277
+ if (iterations <= Math.ceil(Math.pow(Math.log2(Math.max(2, n)), 2))) return 'polylogarithmic';
278
+ if (iterations < n) return 'sublinear';
279
+ if (iterations < n * Math.log2(Math.max(2, n))) return 'linear';
280
+ if (iterations < n * n) return 'linearithmic';
281
+ return 'polynomial';
282
+ }
283
+
284
+ // ============================================================================
285
+ // Top-level: run a PageRankQuery + assemble a PageRankResult
286
+ // ============================================================================
287
+
288
+ export function runPageRank(matrix: SparseMatrix, query: PageRankQuery): PageRankResult {
289
+ const coherence = checkCoherence(matrix, query.coherenceThreshold);
290
+ if (!coherence.passed) {
291
+ throw {
292
+ kind: 'coherence-rejected',
293
+ message: `coherence ${coherence.score.toFixed(4)} < threshold ${coherence.threshold}`,
294
+ recoverable: true,
295
+ coherence: coherence.score,
296
+ threshold: coherence.threshold,
297
+ };
298
+ }
299
+ const { score, iterations } = singleEntryPageRank(matrix, query);
300
+ const obs = observedComplexity(iterations, matrix.size);
301
+ if (!fitsBudget(obs, query.maxComplexityClass)) {
302
+ throw {
303
+ kind: 'complexity-budget-exceeded',
304
+ message: `observed ${obs} exceeds budget ${query.maxComplexityClass}`,
305
+ recoverable: true,
306
+ requiredClass: obs,
307
+ requestedClass: query.maxComplexityClass,
308
+ };
309
+ }
310
+ return {
311
+ graphId: matrix.graphId,
312
+ nodeId: query.nodeId,
313
+ score,
314
+ alpha: query.alpha,
315
+ epsilon: query.epsilon,
316
+ iterations,
317
+ complexityClass: obs,
318
+ coherence,
319
+ computedAt: new Date().toISOString(),
320
+ resultHash: hashResult({
321
+ graphId: matrix.graphId,
322
+ nodeId: query.nodeId,
323
+ alpha: query.alpha,
324
+ epsilon: query.epsilon,
325
+ seedNodes: query.seedNodes,
326
+ score,
327
+ }),
328
+ };
329
+ }
330
+
331
+ export function runSolve(matrix: SparseMatrix, query: SolveQuery): SolveResult {
332
+ const coherence = checkCoherence(matrix, query.coherenceThreshold);
333
+ if (!coherence.passed) {
334
+ throw {
335
+ kind: 'coherence-rejected',
336
+ message: `coherence ${coherence.score.toFixed(4)} < threshold ${coherence.threshold}`,
337
+ recoverable: true,
338
+ coherence: coherence.score,
339
+ threshold: coherence.threshold,
340
+ };
341
+ }
342
+ const solver = query.algorithm === 'neumann' ? neumann : conjugateGradient;
343
+ const { x, residualNorm, iterations } = solver(matrix, query.rhs, { epsilon: 1e-8 });
344
+ const obs = observedComplexity(iterations, matrix.size);
345
+ if (!fitsBudget(obs, query.maxComplexityClass)) {
346
+ throw {
347
+ kind: 'complexity-budget-exceeded',
348
+ message: `observed ${obs} exceeds budget ${query.maxComplexityClass}`,
349
+ recoverable: true,
350
+ requiredClass: obs,
351
+ requestedClass: query.maxComplexityClass,
352
+ };
353
+ }
354
+ return {
355
+ graphId: matrix.graphId,
356
+ x,
357
+ residualNorm,
358
+ iterations,
359
+ complexityClass: obs,
360
+ coherence,
361
+ computedAt: new Date().toISOString(),
362
+ };
363
+ }
364
+
365
+ export function runSolveOnChange(matrix: SparseMatrix, query: SolveOnChangeQuery): SolveResult {
366
+ const { x, iterations, residualNorm } = solveOnChange(matrix, query.prevSolution, query.delta, {
367
+ epsilon: 1e-8,
368
+ algorithm: query.algorithm,
369
+ });
370
+ const obs = observedComplexity(iterations, matrix.size);
371
+ if (!fitsBudget(obs, query.maxComplexityClass)) {
372
+ throw {
373
+ kind: 'complexity-budget-exceeded',
374
+ message: `observed ${obs} exceeds budget ${query.maxComplexityClass}`,
375
+ recoverable: true,
376
+ requiredClass: obs,
377
+ requestedClass: query.maxComplexityClass,
378
+ };
379
+ }
380
+ return {
381
+ graphId: matrix.graphId,
382
+ x,
383
+ residualNorm,
384
+ iterations,
385
+ complexityClass: obs,
386
+ coherence: checkCoherence(matrix, 0), // attestation-only on streaming
387
+ computedAt: new Date().toISOString(),
388
+ };
389
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Witness Signer for PR Artifacts (Phase 7, ADR-123)
3
+ *
4
+ * Ed25519 sign / verify via node:crypto. Mirrors @claude-flow/browser's
5
+ * ADR-122 Phase 1 witness signer — same canonical-JSON approach so a single
6
+ * upstream-ADR-103 schema change cascades cleanly.
7
+ */
8
+
9
+ import {
10
+ createPrivateKey,
11
+ createPublicKey,
12
+ sign,
13
+ verify,
14
+ generateKeyPairSync,
15
+ createHash,
16
+ type KeyObject,
17
+ } from 'node:crypto';
18
+ import {
19
+ ARTIFACT_ENVELOPE_KIND,
20
+ ARTIFACT_ENVELOPE_VERSION,
21
+ SignedPageRankEnvelopeSchema,
22
+ SignedPageRankPayloadSchema,
23
+ type SignedPageRankEnvelope,
24
+ type SignedPageRankPayload,
25
+ type ArtifactVerificationResult,
26
+ } from '../domain/signed-artifact.js';
27
+ import type { PageRankResult } from '../domain/types.js';
28
+
29
+ export interface WitnessKey {
30
+ privateKey: KeyObject;
31
+ publicKey: KeyObject;
32
+ publicKeyHex: string;
33
+ }
34
+
35
+ /** Canonical-JSON for deterministic signing — omits undefined keys, sorts. */
36
+ export function canonicalJSON(value: unknown): string {
37
+ if (value === undefined) return 'null';
38
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
39
+ if (Array.isArray(value)) return '[' + value.map(canonicalJSON).join(',') + ']';
40
+ const obj = value as Record<string, unknown>;
41
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
42
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJSON(obj[k])).join(',') + '}';
43
+ }
44
+
45
+ export function sha256Hex(input: Buffer | string): string {
46
+ return createHash('sha256').update(input).digest('hex');
47
+ }
48
+
49
+ export function generateWitnessKey(): WitnessKey {
50
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
51
+ return { privateKey, publicKey, publicKeyHex: extractPubkeyHex(publicKey) };
52
+ }
53
+
54
+ export function loadWitnessKey(privateKeyPem: string): WitnessKey {
55
+ const privateKey = createPrivateKey(privateKeyPem);
56
+ const publicKey = createPublicKey(privateKey);
57
+ return { privateKey, publicKey, publicKeyHex: extractPubkeyHex(publicKey) };
58
+ }
59
+
60
+ function extractPubkeyHex(publicKey: KeyObject): string {
61
+ const der = publicKey.export({ format: 'der', type: 'spki' });
62
+ return der.subarray(der.length - 32).toString('hex');
63
+ }
64
+
65
+ export function resolveWitnessKey(): WitnessKey {
66
+ const envKey = process.env.RUFLO_GRAPH_INTELLIGENCE_WITNESS_KEY;
67
+ if (envKey) return loadWitnessKey(envKey);
68
+ return generateWitnessKey();
69
+ }
70
+
71
+ export interface SealArtifactInput {
72
+ installationId: string;
73
+ witnessKeyId: string;
74
+ graphId: string;
75
+ graphHash: string;
76
+ graphTimestamp: string;
77
+ algorithm: SignedPageRankPayload['algorithm'];
78
+ alpha: number;
79
+ epsilon: number;
80
+ queryNode?: string;
81
+ seedNodes: readonly string[];
82
+ result: PageRankResult;
83
+ witnessKey?: WitnessKey;
84
+ sealedAt?: string;
85
+ }
86
+
87
+ export function sealArtifact(input: SealArtifactInput): {
88
+ envelope: SignedPageRankEnvelope;
89
+ publicKeyHex: string;
90
+ } {
91
+ const key = input.witnessKey ?? resolveWitnessKey();
92
+ const payload: SignedPageRankPayload = SignedPageRankPayloadSchema.parse({
93
+ envelopeVersion: ARTIFACT_ENVELOPE_VERSION,
94
+ kind: ARTIFACT_ENVELOPE_KIND,
95
+ installationId: input.installationId,
96
+ witnessKeyId: input.witnessKeyId,
97
+ publicKey: key.publicKeyHex,
98
+ graphId: input.graphId,
99
+ graphHash: input.graphHash,
100
+ graphTimestamp: input.graphTimestamp,
101
+ algorithm: input.algorithm,
102
+ alpha: input.alpha,
103
+ epsilon: input.epsilon,
104
+ queryNode: input.queryNode,
105
+ seedNodes: [...input.seedNodes],
106
+ result: input.result,
107
+ complexityClass: input.result.complexityClass,
108
+ coherenceScore: input.result.coherence.score,
109
+ resultHash: input.result.resultHash,
110
+ sealedAt: input.sealedAt ?? new Date().toISOString(),
111
+ solverVersion: 'sublinear-time-solver@1.7.0',
112
+ });
113
+ const canonical = canonicalJSON(payload);
114
+ const sigBuf = sign(null, Buffer.from(canonical, 'utf8'), key.privateKey);
115
+ return {
116
+ envelope: { payload, signature: sigBuf.toString('hex'), algorithm: 'ed25519' },
117
+ publicKeyHex: key.publicKeyHex,
118
+ };
119
+ }
120
+
121
+ export function verifyArtifact(
122
+ envelope: unknown,
123
+ options: { trustedPublicKeys?: string[] } = {},
124
+ ): ArtifactVerificationResult {
125
+ const parsed = SignedPageRankEnvelopeSchema.safeParse(envelope);
126
+ if (!parsed.success) {
127
+ return {
128
+ valid: false,
129
+ signatureValid: false,
130
+ schemaValid: false,
131
+ integrityValid: false,
132
+ reason: 'schema: ' + parsed.error.issues.map((i) => i.path.join('.') + ' ' + i.message).join('; '),
133
+ };
134
+ }
135
+ const { payload, signature } = parsed.data;
136
+
137
+ if (options.trustedPublicKeys && !options.trustedPublicKeys.includes(payload.publicKey)) {
138
+ return {
139
+ valid: false,
140
+ signatureValid: false,
141
+ schemaValid: true,
142
+ integrityValid: false,
143
+ publicKey: payload.publicKey,
144
+ reason: 'signer not in trusted list',
145
+ };
146
+ }
147
+
148
+ // Integrity: result.resultHash must match payload.resultHash
149
+ if (payload.result.resultHash !== payload.resultHash) {
150
+ return {
151
+ valid: false,
152
+ signatureValid: false,
153
+ schemaValid: true,
154
+ integrityValid: false,
155
+ publicKey: payload.publicKey,
156
+ reason: 'result.resultHash != payload.resultHash (tampered)',
157
+ };
158
+ }
159
+ // Coherence echo must match
160
+ if (Math.abs(payload.result.coherence.score - payload.coherenceScore) > 1e-9) {
161
+ return {
162
+ valid: false,
163
+ signatureValid: false,
164
+ schemaValid: true,
165
+ integrityValid: false,
166
+ publicKey: payload.publicKey,
167
+ reason: 'coherenceScore echo mismatch (tampered)',
168
+ };
169
+ }
170
+ // Complexity-class echo must match
171
+ if (payload.result.complexityClass !== payload.complexityClass) {
172
+ return {
173
+ valid: false,
174
+ signatureValid: false,
175
+ schemaValid: true,
176
+ integrityValid: false,
177
+ publicKey: payload.publicKey,
178
+ reason: 'complexityClass echo mismatch (tampered)',
179
+ };
180
+ }
181
+
182
+ // Signature
183
+ try {
184
+ const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
185
+ const der = Buffer.concat([spkiPrefix, Buffer.from(payload.publicKey, 'hex')]);
186
+ const pk = createPublicKey({ key: der, format: 'der', type: 'spki' });
187
+ const canonical = canonicalJSON(payload);
188
+ const sigValid = verify(null, Buffer.from(canonical, 'utf8'), pk, Buffer.from(signature, 'hex'));
189
+ return {
190
+ valid: sigValid,
191
+ signatureValid: sigValid,
192
+ schemaValid: true,
193
+ integrityValid: true,
194
+ publicKey: payload.publicKey,
195
+ complexityClass: payload.complexityClass,
196
+ coherenceScore: payload.coherenceScore,
197
+ reason: sigValid ? undefined : 'signature verification failed (envelope tampered)',
198
+ };
199
+ } catch (err) {
200
+ return {
201
+ valid: false,
202
+ signatureValid: false,
203
+ schemaValid: true,
204
+ integrityValid: false,
205
+ publicKey: payload.publicKey,
206
+ reason: 'verify threw: ' + (err instanceof Error ? err.message : String(err)),
207
+ };
208
+ }
209
+ }