@kontourai/flow-agents 0.3.0 → 1.0.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 (62) hide show
  1. package/.github/workflows/kit-gates-demo.yml +171 -0
  2. package/.github/workflows/release-please.yml +13 -1
  3. package/AGENTS.md +8 -1
  4. package/CHANGELOG.md +53 -0
  5. package/CONTEXT.md +1 -1
  6. package/README.md +13 -2
  7. package/build/src/cli/flow-kit.js +41 -2
  8. package/build/src/flow-kit/validate.js +98 -0
  9. package/build/src/tools/validate-source-tree.js +2 -1
  10. package/context/scripts/hooks/config-protection.js +217 -15
  11. package/docs/fixture-ownership.md +1 -0
  12. package/docs/index.md +9 -1
  13. package/docs/kit-authoring-guide.md +126 -0
  14. package/docs/knowledge-kit.md +69 -0
  15. package/docs/vision.md +22 -0
  16. package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
  17. package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
  18. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
  19. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
  20. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
  21. package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
  22. package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
  23. package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
  24. package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
  25. package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
  26. package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
  27. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  28. package/evals/integration/test_hook_category_behaviors.sh +51 -0
  29. package/evals/integration/test_kit_conformance_levels.sh +209 -0
  30. package/evals/run.sh +2 -0
  31. package/evals/static/test_universal_bundles.sh +10 -0
  32. package/kits/catalog.json +6 -0
  33. package/kits/knowledge/adapters/default-store/index.js +95 -14
  34. package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
  35. package/kits/knowledge/adapters/flow-runner/index.js +639 -0
  36. package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
  37. package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
  38. package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
  39. package/kits/knowledge/adapters/shared/codec.js +325 -0
  40. package/kits/knowledge/adapters/similarity-vector/index.js +284 -0
  41. package/kits/knowledge/docs/README.md +193 -0
  42. package/kits/knowledge/docs/store-contract.md +196 -0
  43. package/kits/knowledge/evals/contract-suite/suite.test.js +10 -5
  44. package/kits/knowledge/evals/entities/demo-acme.js +125 -0
  45. package/kits/knowledge/evals/entities/suite.test.js +722 -0
  46. package/kits/knowledge/evals/retirement/suite.test.js +1173 -0
  47. package/kits/knowledge/evals/similarity-vector/suite.test.js +685 -0
  48. package/kits/knowledge/evals/synthesis/suite.test.js +10 -3
  49. package/kits/knowledge/flows/retire.flow.json +77 -0
  50. package/kits/knowledge/kit.json +31 -1
  51. package/kits/release-evidence/fixtures/claims/README.md +14 -0
  52. package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
  53. package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
  54. package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
  55. package/kits/release-evidence/kit.json +13 -0
  56. package/package.json +1 -1
  57. package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
  58. package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
  59. package/scripts/hooks/config-protection.js +217 -15
  60. package/src/cli/flow-kit.ts +40 -2
  61. package/src/flow-kit/validate.ts +127 -0
  62. package/src/tools/validate-source-tree.ts +2 -1
@@ -0,0 +1,685 @@
1
+ /**
2
+ * Knowledge Kit — Vector Similarity Adapter Eval Suite
3
+ *
4
+ * Covers:
5
+ * Unit (no network):
6
+ * - Injectable embed fn: similar texts (high cosine via crafted vectors) included,
7
+ * dissimilar excluded.
8
+ * - Threshold respected: score exactly at threshold included; just below excluded.
9
+ * - cosineSimilarity math edge cases: zero vectors, empty, orthogonal, identical.
10
+ * - Infrastructure failure throws EMBED_FAILURE (fail-closed — not silent []).
11
+ *
12
+ * Drop-in proof:
13
+ * - Run runner.synthesize() with the vector detector (injected embed fn).
14
+ * - Assert it produces the same proposal shape as the default detector path.
15
+ * - No changes to KnowledgeFlowRunner call-site.
16
+ *
17
+ * Live (gated on ollama availability + nomic-embed-text model):
18
+ * - Real embedding round-trip via ollama.
19
+ * - Semantically similar fixture texts cluster together.
20
+ * - An unrelated text does NOT cluster.
21
+ * - Reports the empirical cosine scores.
22
+ *
23
+ * Run:
24
+ * node --test kits/knowledge/evals/similarity-vector/suite.test.js
25
+ */
26
+
27
+ import { test, describe, before, after } from "node:test";
28
+ import assert from "node:assert/strict";
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ import * as os from "node:os";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
+ const KIT_ROOT = path.resolve(__dirname, "../..");
36
+
37
+ const adapterPath = path.join(KIT_ROOT, "adapters/default-store/index.js");
38
+ const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
39
+ const vectorPath = path.join(KIT_ROOT, "adapters/similarity-vector/index.js");
40
+
41
+ const { DefaultKnowledgeStore } = await import(adapterPath);
42
+ const { KnowledgeFlowRunner } = await import(runnerPath);
43
+ const { createVectorSimilarityDetector, cosineSimilarity } = await import(vectorPath);
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function makeTempDir() {
50
+ return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-vector-"));
51
+ }
52
+
53
+ function makeStore(dir) {
54
+ return new DefaultKnowledgeStore({ storeRoot: dir });
55
+ }
56
+
57
+ function makeRunner(store, dir) {
58
+ return new KnowledgeFlowRunner({
59
+ store,
60
+ workspace: dir,
61
+ agent: "vector-test-runner",
62
+ sessionId: "vector-session-001",
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Build a fixture store with concept + compiled records.
68
+ * The compiled records all share category "engineering.api" with the concept
69
+ * so that the default detector (used in drop-in proof) will find them too.
70
+ */
71
+ async function buildFixture(dir) {
72
+ const store = makeStore(dir);
73
+
74
+ const conceptId = await store.create({
75
+ type: "concept",
76
+ title: "API Design Principles",
77
+ body: "REST APIs should use versioning, consistent naming, and proper HTTP verbs.",
78
+ category: "engineering.api",
79
+ provenance: { agent: "fixture" },
80
+ });
81
+
82
+ const compiledId1 = await store.create({
83
+ type: "compiled",
84
+ title: "REST API Best Practices",
85
+ body: "Use versioning, consistent naming, and proper HTTP verbs for REST APIs.",
86
+ category: "engineering.api",
87
+ provenance: { agent: "fixture", source_ids: [] },
88
+ });
89
+
90
+ const compiledId2 = await store.create({
91
+ type: "compiled",
92
+ title: "API Versioning Strategies",
93
+ body: "Version REST APIs via URL path (v1, v2) or request headers.",
94
+ category: "engineering.api",
95
+ provenance: { agent: "fixture", source_ids: [] },
96
+ });
97
+
98
+ return { store, conceptId, compiledId1, compiledId2 };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Crafted embed vectors for unit tests (no network)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ // We craft orthogonal-ish and identical vectors to drive precise inclusion /
106
+ // exclusion through cosineSimilarity without depending on real model output.
107
+
108
+ /** A unit vector in direction [1, 0, 0]. */
109
+ const VEC_A = [1, 0, 0];
110
+ /** A unit vector in direction [0, 1, 0] — orthogonal to VEC_A. */
111
+ const VEC_B = [0, 1, 0];
112
+ /** Near-identical to VEC_A: cos ≈ 0.9997. */
113
+ const VEC_A_NEAR = [0.9998, 0.02, 0];
114
+ /** 45-degree from VEC_A: cos ≈ 0.707. */
115
+ const VEC_A_MID = [1, 1, 0]; // normalised cosine to VEC_A = 1/sqrt(2) ≈ 0.707
116
+
117
+ // We'll use 3-dim vectors throughout for simplicity.
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // UNIT: cosineSimilarity math
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe("cosineSimilarity — pure function math", () => {
124
+ test("identical vectors return 1.0", () => {
125
+ const v = [0.5, 0.5, 0.707];
126
+ const result = cosineSimilarity(v, v);
127
+ assert.ok(Math.abs(result - 1.0) < 1e-9, `expected ~1.0, got ${result}`);
128
+ });
129
+
130
+ test("orthogonal unit vectors return 0.0", () => {
131
+ const result = cosineSimilarity(VEC_A, VEC_B);
132
+ assert.ok(Math.abs(result - 0.0) < 1e-9, `expected 0.0, got ${result}`);
133
+ });
134
+
135
+ test("opposite vectors return -1.0", () => {
136
+ const result = cosineSimilarity([1, 0], [-1, 0]);
137
+ assert.ok(Math.abs(result - (-1.0)) < 1e-9, `expected -1.0, got ${result}`);
138
+ });
139
+
140
+ test("45-degree vectors return ~0.707", () => {
141
+ const result = cosineSimilarity(VEC_A, VEC_A_MID);
142
+ const expected = 1 / Math.sqrt(2);
143
+ assert.ok(
144
+ Math.abs(result - expected) < 1e-9,
145
+ `expected ~${expected}, got ${result}`
146
+ );
147
+ });
148
+
149
+ test("zero vector returns 0 (no division by zero)", () => {
150
+ const result = cosineSimilarity([0, 0, 0], VEC_A);
151
+ assert.equal(result, 0);
152
+ });
153
+
154
+ test("both zero vectors return 0", () => {
155
+ const result = cosineSimilarity([0, 0], [0, 0]);
156
+ assert.equal(result, 0);
157
+ });
158
+
159
+ test("empty arrays return 0", () => {
160
+ const result = cosineSimilarity([], []);
161
+ assert.equal(result, 0);
162
+ });
163
+
164
+ test("mismatched lengths return 0", () => {
165
+ const result = cosineSimilarity([1, 2, 3], [1, 2]);
166
+ assert.equal(result, 0);
167
+ });
168
+
169
+ test("non-array inputs return 0", () => {
170
+ assert.equal(cosineSimilarity(null, [1, 2]), 0);
171
+ assert.equal(cosineSimilarity([1, 2], undefined), 0);
172
+ });
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // UNIT: createVectorSimilarityDetector — injectable embed
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe("createVectorSimilarityDetector — injectable embed (no network)", () => {
180
+ test("similar candidates (high cosine score) are included", async () => {
181
+ // concept vector = VEC_A; candidate vectors: VEC_A_NEAR (cos ~0.9997), VEC_B (cos 0)
182
+ // threshold = 0.60 → VEC_A_NEAR included, VEC_B excluded
183
+ const embed = async (texts) => {
184
+ return texts.map((_, i) => {
185
+ if (i === 0) return VEC_A; // concept
186
+ if (i === 1) return VEC_A_NEAR; // similar candidate
187
+ return VEC_B; // dissimilar candidate
188
+ });
189
+ };
190
+
191
+ const detector = createVectorSimilarityDetector({ embed, threshold: 0.60 });
192
+
193
+ const concept = { id: "c1", title: "Concept", body: "concept body" };
194
+ const candidates = [
195
+ { id: "cand1", title: "Similar", body: "similar body" },
196
+ { id: "cand2", title: "Different", body: "different body" },
197
+ ];
198
+
199
+ const result = await detector(concept, candidates, null);
200
+
201
+ assert.ok(result.includes("cand1"), "similar candidate included");
202
+ assert.ok(!result.includes("cand2"), "dissimilar candidate excluded");
203
+ });
204
+
205
+ test("threshold respected: score exactly at threshold is included", async () => {
206
+ const THRESHOLD = 0.60;
207
+ // Craft a vector with cosine exactly THRESHOLD relative to [1,0,0]:
208
+ // cos(θ) = dot([1,0,0], [x,y,0]) / (1 * sqrt(x²+y²)) = x / sqrt(x²+y²) = THRESHOLD
209
+ // Let x = THRESHOLD, y = sqrt(1 - THRESHOLD²) (normalised)
210
+ const x = THRESHOLD;
211
+ const y = Math.sqrt(1 - x * x);
212
+ const VEC_EXACT = [x, y, 0]; // cosine to VEC_A = THRESHOLD exactly
213
+
214
+ const embed = async (texts) => {
215
+ return texts.map((_, i) => (i === 0 ? VEC_A : VEC_EXACT));
216
+ };
217
+
218
+ const detector = createVectorSimilarityDetector({ embed, threshold: THRESHOLD });
219
+ const concept = { id: "c1", title: "T", body: "B" };
220
+ const candidates = [{ id: "cand1", title: "T2", body: "B2" }];
221
+
222
+ const result = await detector(concept, candidates, null);
223
+ assert.ok(result.includes("cand1"), "score at exact threshold is included");
224
+ });
225
+
226
+ test("threshold respected: score just below threshold is excluded", async () => {
227
+ const THRESHOLD = 0.60;
228
+ const x = THRESHOLD - 0.01; // slightly below
229
+ const y = Math.sqrt(1 - x * x);
230
+ const VEC_BELOW = [x, y, 0];
231
+
232
+ const embed = async (texts) => {
233
+ return texts.map((_, i) => (i === 0 ? VEC_A : VEC_BELOW));
234
+ };
235
+
236
+ const detector = createVectorSimilarityDetector({ embed, threshold: THRESHOLD });
237
+ const concept = { id: "c1", title: "T", body: "B" };
238
+ const candidates = [{ id: "cand1", title: "T2", body: "B2" }];
239
+
240
+ const result = await detector(concept, candidates, null);
241
+ assert.ok(!result.includes("cand1"), "score below threshold is excluded");
242
+ });
243
+
244
+ test("empty candidates array returns []", async () => {
245
+ const embed = async (texts) => texts.map(() => VEC_A);
246
+ const detector = createVectorSimilarityDetector({ embed });
247
+ const concept = { id: "c1", title: "T", body: "B" };
248
+ const result = await detector(concept, [], null);
249
+ assert.deepEqual(result, []);
250
+ });
251
+
252
+ test("all similar candidates can be included", async () => {
253
+ // All candidates return a near-identical vector
254
+ const embed = async (texts) => texts.map(() => [0.6, 0.8, 0]);
255
+ const detector = createVectorSimilarityDetector({ embed, threshold: 0.50 });
256
+ const concept = { id: "c1", title: "T", body: "B" };
257
+ const candidates = [
258
+ { id: "a", title: "A", body: "a" },
259
+ { id: "b", title: "B", body: "b" },
260
+ { id: "c", title: "C", body: "c" },
261
+ ];
262
+ const result = await detector(concept, candidates, null);
263
+ assert.equal(result.length, 3, "all candidates included when all exceed threshold");
264
+ });
265
+
266
+ test("custom text extractor is used", async () => {
267
+ const seenTexts = [];
268
+ const embed = async (texts) => {
269
+ seenTexts.push(...texts);
270
+ return texts.map(() => VEC_A);
271
+ };
272
+
273
+ const text = (record) => `CUSTOM:${record.title}`;
274
+ const detector = createVectorSimilarityDetector({ embed, text, threshold: 0.50 });
275
+ const concept = { id: "c1", title: "Concept", body: "B" };
276
+ const candidates = [{ id: "cand1", title: "Cand", body: "B2" }];
277
+
278
+ await detector(concept, candidates, null);
279
+
280
+ assert.ok(seenTexts.some((t) => t.startsWith("CUSTOM:")), "custom extractor used");
281
+ assert.ok(seenTexts[0] === "CUSTOM:Concept", "concept text extracted correctly");
282
+ assert.ok(seenTexts[1] === "CUSTOM:Cand", "candidate text extracted correctly");
283
+ });
284
+
285
+ test("store parameter is not required (interface compat with null store)", async () => {
286
+ const embed = async (texts) => texts.map(() => VEC_A);
287
+ const detector = createVectorSimilarityDetector({ embed, threshold: 0.50 });
288
+ const concept = { id: "c1", title: "T", body: "B" };
289
+ const candidates = [{ id: "cand1", title: "T2", body: "B2" }];
290
+ // Should not throw even with store=null
291
+ const result = await detector(concept, candidates, null);
292
+ assert.ok(Array.isArray(result), "returns array with null store");
293
+ });
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // UNIT: fail-closed — infrastructure failure throws EMBED_FAILURE
298
+ // ---------------------------------------------------------------------------
299
+
300
+ describe("createVectorSimilarityDetector — fail-closed on embed failure", () => {
301
+ test("embed function that throws causes detector to throw (not return [])", async () => {
302
+ const embed = async (_texts) => {
303
+ throw new Error("connection refused");
304
+ };
305
+
306
+ const detector = createVectorSimilarityDetector({ embed });
307
+ const concept = { id: "c1", title: "T", body: "B" };
308
+ const candidates = [{ id: "cand1", title: "T2", body: "B2" }];
309
+
310
+ await assert.rejects(
311
+ () => detector(concept, candidates, null),
312
+ (err) => {
313
+ assert.ok(err instanceof Error, "throws an Error");
314
+ assert.match(err.message, /connection refused/, "original message propagated");
315
+ return true;
316
+ },
317
+ "detector must throw on embed failure, not return []"
318
+ );
319
+ });
320
+
321
+ test("embed function that returns wrong count throws EMBED_FAILURE", async () => {
322
+ // Returns 1 vector but we need n+1 (concept + 2 candidates = 3)
323
+ const embed = async (_texts) => [[0.1, 0.2, 0.3]];
324
+
325
+ const detector = createVectorSimilarityDetector({ embed });
326
+ const concept = { id: "c1", title: "T", body: "B" };
327
+ const candidates = [
328
+ { id: "cand1", title: "T2", body: "B2" },
329
+ { id: "cand2", title: "T3", body: "B3" },
330
+ ];
331
+
332
+ await assert.rejects(
333
+ () => detector(concept, candidates, null),
334
+ (err) => {
335
+ assert.ok(err instanceof Error, "throws an Error");
336
+ return true;
337
+ },
338
+ "wrong embedding count must cause a throw, not silent wrong results"
339
+ );
340
+ });
341
+
342
+ test("EMBED_FAILURE from ollamaEmbed has code='EMBED_FAILURE'", async () => {
343
+ // Simulate what ollamaEmbed throws when server is down
344
+ const embed = async (_texts) => {
345
+ const err = new Error("EMBED_FAILURE: embedding call failed — ECONNREFUSED");
346
+ err.code = "EMBED_FAILURE";
347
+ throw err;
348
+ };
349
+
350
+ const detector = createVectorSimilarityDetector({ embed });
351
+ const concept = { id: "c1", title: "T", body: "B" };
352
+ const candidates = [{ id: "cand1", title: "T2", body: "B2" }];
353
+
354
+ await assert.rejects(
355
+ () => detector(concept, candidates, null),
356
+ (err) => {
357
+ assert.equal(err.code, "EMBED_FAILURE", "error code is EMBED_FAILURE");
358
+ return true;
359
+ }
360
+ );
361
+ });
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // DROP-IN PROOF: runner.synthesize() works with the vector detector unchanged
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("Drop-in proof — runner.synthesize() with vector detector", () => {
369
+ test("synthesize with injected vector detector produces a valid proposal (apply path)", async () => {
370
+ const dir = makeTempDir();
371
+ try {
372
+ const { store, conceptId, compiledId1, compiledId2 } = await buildFixture(dir);
373
+ const runner = makeRunner(store, dir);
374
+
375
+ // The injected embed always returns vectors where concept is similar to
376
+ // compiledId1 (cos ≈ 0.9997) and dissimilar to compiledId2 (cos = 0).
377
+ // We map text position to vector deterministically.
378
+ let callCount = 0;
379
+ const embed = async (texts) => {
380
+ callCount++;
381
+ // Position 0 = concept, 1 = first candidate, 2 = second candidate (etc.)
382
+ // All candidates get similar vectors so both compiledId1 and compiledId2 match.
383
+ return texts.map(() => [0.6, 0.8, 0]); // all near-identical → cos=1.0
384
+ };
385
+
386
+ const detector = createVectorSimilarityDetector({ embed, threshold: 0.50 });
387
+
388
+ const result = await runner.synthesize(conceptId, {
389
+ proposedBody: "Updated: REST APIs require versioning and consistent naming.",
390
+ rationale: "Vector detector drop-in proof.",
391
+ decision: "apply",
392
+ similarityDetector: detector,
393
+ });
394
+
395
+ // Interface guarantees
396
+ assert.ok(result.conceptId, "result has conceptId");
397
+ assert.equal(result.conceptId, conceptId, "conceptId matches");
398
+ assert.ok(result.proposerId, "result has proposerId");
399
+ assert.ok(Array.isArray(result.cluster), "result has cluster array");
400
+ assert.ok(result.cluster.length >= 1, "cluster has at least one member");
401
+ assert.equal(result.decision, "apply", "decision is apply");
402
+
403
+ // Concept body was updated (apply path)
404
+ const updated = await store.get(conceptId);
405
+ assert.equal(
406
+ updated.body,
407
+ "Updated: REST APIs require versioning and consistent naming.",
408
+ "concept body updated to proposedBody"
409
+ );
410
+
411
+ // Embed was actually called (detector ran)
412
+ assert.ok(callCount >= 1, "embed fn was called at least once");
413
+ } finally {
414
+ fs.rmSync(dir, { recursive: true, force: true });
415
+ }
416
+ });
417
+
418
+ test("synthesize with vector detector: rejection path leaves concept byte-identical", async () => {
419
+ const dir = makeTempDir();
420
+ try {
421
+ const { store, conceptId } = await buildFixture(dir);
422
+ const runner = makeRunner(store, dir);
423
+
424
+ const originalConcept = await store.get(conceptId);
425
+ const originalBody = originalConcept.body;
426
+
427
+ const embed = async (texts) => texts.map(() => [0.6, 0.8, 0]);
428
+ const detector = createVectorSimilarityDetector({ embed, threshold: 0.50 });
429
+
430
+ await runner.synthesize(conceptId, {
431
+ proposedBody: "Rejected body — must not appear.",
432
+ decision: "reject",
433
+ rejectReason: "Drop-in proof: rejection leaves concept unchanged.",
434
+ similarityDetector: detector,
435
+ });
436
+
437
+ const afterConcept = await store.get(conceptId);
438
+ assert.equal(afterConcept.body, originalBody, "concept body unchanged after rejection");
439
+ } finally {
440
+ fs.rmSync(dir, { recursive: true, force: true });
441
+ }
442
+ });
443
+
444
+ test("detector returning empty array triggers MISSING_EVIDENCE at detect-cluster-gate", async () => {
445
+ const dir = makeTempDir();
446
+ try {
447
+ const { store, conceptId } = await buildFixture(dir);
448
+ const runner = makeRunner(store, dir);
449
+
450
+ // Threshold above 1.0 means nothing will ever match
451
+ const embed = async (texts) => texts.map(() => [0.6, 0.8, 0]);
452
+ const detector = createVectorSimilarityDetector({ embed, threshold: 2.0 });
453
+
454
+ await assert.rejects(
455
+ () => runner.synthesize(conceptId, {
456
+ proposedBody: "Should fail.",
457
+ rationale: "n/a",
458
+ decision: "apply",
459
+ similarityDetector: detector,
460
+ }),
461
+ (err) => {
462
+ assert.equal(err.code, "MISSING_EVIDENCE", "MISSING_EVIDENCE when cluster empty");
463
+ assert.match(err.message, /no similar compiled records found/);
464
+ return true;
465
+ }
466
+ );
467
+ } finally {
468
+ fs.rmSync(dir, { recursive: true, force: true });
469
+ }
470
+ });
471
+
472
+ test("no call-site changes required: runner.synthesize signature unchanged", () => {
473
+ // This is a static check: the KnowledgeFlowRunner.synthesize API still
474
+ // accepts similarityDetector in options exactly as the spec describes.
475
+ // We verify by checking the function is callable (already proven by the
476
+ // tests above) and that the detector is passed through options.similarityDetector.
477
+ assert.ok(
478
+ typeof KnowledgeFlowRunner.prototype.synthesize === "function",
479
+ "synthesize is a method on KnowledgeFlowRunner"
480
+ );
481
+ });
482
+ });
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // LIVE (gated): real ollama embedding round-trip
486
+ // ---------------------------------------------------------------------------
487
+
488
+ // Check ollama availability once before the describe block runs.
489
+ let ollamaAvailable = false;
490
+ let ollamaModelAvailable = false;
491
+ const LIVE_HOST = "http://localhost:11434";
492
+ const LIVE_MODEL = "nomic-embed-text";
493
+
494
+ try {
495
+ const tagsResponse = await fetch(`${LIVE_HOST}/api/tags`, { signal: AbortSignal.timeout(2000) });
496
+ if (tagsResponse.ok) {
497
+ const tagsData = await tagsResponse.json();
498
+ const models = (tagsData.models || []).map((m) => m.name);
499
+ ollamaAvailable = true;
500
+ ollamaModelAvailable = models.some(
501
+ (name) => name === LIVE_MODEL || name.startsWith(`${LIVE_MODEL}:`)
502
+ );
503
+ }
504
+ } catch {
505
+ // Server not reachable — skip live tests
506
+ }
507
+
508
+ describe("LIVE — real ollama embedding round-trip", { skip: !ollamaAvailable || !ollamaModelAvailable }, () => {
509
+ // Fixtures designed to test semantic similarity, not keyword/category matching.
510
+ // We intentionally use a different category from the default detector (engineering.api)
511
+ // so the only matching mechanism is the vector detector.
512
+
513
+ const SIMILAR_TEXTS = [
514
+ {
515
+ title: "REST API Design",
516
+ body: "REST APIs should follow uniform interface constraints, use HTTP verbs correctly, return appropriate status codes, and version via path or header.",
517
+ },
518
+ {
519
+ title: "Web Service Interface Design",
520
+ body: "Designing HTTP web services requires consistent use of verbs, versioning strategies, status codes, and predictable URL structure.",
521
+ },
522
+ ];
523
+
524
+ const UNRELATED_TEXT = {
525
+ title: "Sourdough Bread Baking",
526
+ body: "Sourdough bread uses a live starter culture of wild yeast and bacteria. The dough requires long fermentation to develop flavor and gluten structure.",
527
+ };
528
+
529
+ test("similar texts have cosine similarity above 0.60 with nomic-embed-text", async () => {
530
+ const { default: createDetector, cosineSimilarity: cosine } = await import(vectorPath);
531
+
532
+ // Embed both similar texts and compute cosine
533
+ const embedResp = await fetch(`${LIVE_HOST}/api/embed`, {
534
+ method: "POST",
535
+ headers: { "Content-Type": "application/json" },
536
+ body: JSON.stringify({ model: LIVE_MODEL, input: [
537
+ `${SIMILAR_TEXTS[0].title}\n${SIMILAR_TEXTS[0].body}`,
538
+ `${SIMILAR_TEXTS[1].title}\n${SIMILAR_TEXTS[1].body}`,
539
+ ]}),
540
+ });
541
+ const embedData = await embedResp.json();
542
+ const [vec0, vec1] = embedData.embeddings;
543
+ const score = cosine(vec0, vec1);
544
+
545
+ console.log(` [LIVE] cosine(similar_0, similar_1) = ${score.toFixed(4)}`);
546
+ assert.ok(score >= 0.60, `similar texts score ${score.toFixed(4)} should be >= 0.60`);
547
+ });
548
+
549
+ test("unrelated text has cosine similarity below 0.60 vs both similar texts", async () => {
550
+ const { cosineSimilarity: cosine } = await import(vectorPath);
551
+
552
+ const embedResp = await fetch(`${LIVE_HOST}/api/embed`, {
553
+ method: "POST",
554
+ headers: { "Content-Type": "application/json" },
555
+ body: JSON.stringify({ model: LIVE_MODEL, input: [
556
+ `${SIMILAR_TEXTS[0].title}\n${SIMILAR_TEXTS[0].body}`,
557
+ `${UNRELATED_TEXT.title}\n${UNRELATED_TEXT.body}`,
558
+ ]}),
559
+ });
560
+ const embedData = await embedResp.json();
561
+ const [vec0, vecUnrelated] = embedData.embeddings;
562
+ const score = cosine(vec0, vecUnrelated);
563
+
564
+ console.log(` [LIVE] cosine(similar_0, unrelated) = ${score.toFixed(4)}`);
565
+ assert.ok(score < 0.60, `unrelated text score ${score.toFixed(4)} should be < 0.60`);
566
+ });
567
+
568
+ test("detector includes similar candidate and excludes unrelated candidate", async () => {
569
+ const dir = makeTempDir();
570
+ try {
571
+ // Build a fresh store with records using the live texts
572
+ const store = makeStore(dir);
573
+
574
+ const conceptId = await store.create({
575
+ type: "concept",
576
+ title: SIMILAR_TEXTS[0].title,
577
+ body: SIMILAR_TEXTS[0].body,
578
+ category: "live.api-design",
579
+ provenance: { agent: "live-fixture" },
580
+ });
581
+
582
+ const similarId = await store.create({
583
+ type: "compiled",
584
+ title: SIMILAR_TEXTS[1].title,
585
+ body: SIMILAR_TEXTS[1].body,
586
+ category: "live.api-design",
587
+ provenance: { agent: "live-fixture", source_ids: [] },
588
+ });
589
+
590
+ const unrelatedId = await store.create({
591
+ type: "compiled",
592
+ title: UNRELATED_TEXT.title,
593
+ body: UNRELATED_TEXT.body,
594
+ category: "live.api-design", // same category to make it harder for default detector
595
+ provenance: { agent: "live-fixture", source_ids: [] },
596
+ });
597
+
598
+ const concept = await store.get(conceptId);
599
+ const candidates = await store.listByType("compiled");
600
+
601
+ const detector = createVectorSimilarityDetector({
602
+ host: LIVE_HOST,
603
+ model: LIVE_MODEL,
604
+ threshold: 0.60,
605
+ });
606
+
607
+ const cluster = await detector(concept, candidates, store);
608
+
609
+ console.log(` [LIVE] cluster = ${JSON.stringify(cluster)}`);
610
+ assert.ok(cluster.includes(similarId), "similar candidate included in cluster");
611
+ assert.ok(!cluster.includes(unrelatedId), "unrelated candidate excluded from cluster");
612
+ } finally {
613
+ fs.rmSync(dir, { recursive: true, force: true });
614
+ }
615
+ });
616
+
617
+ test("live detector integrates with runner.synthesize end-to-end", async () => {
618
+ const dir = makeTempDir();
619
+ try {
620
+ const store = makeStore(dir);
621
+
622
+ const conceptId = await store.create({
623
+ type: "concept",
624
+ title: SIMILAR_TEXTS[0].title,
625
+ body: SIMILAR_TEXTS[0].body,
626
+ category: "live.api-design",
627
+ provenance: { agent: "live-fixture" },
628
+ });
629
+
630
+ await store.create({
631
+ type: "compiled",
632
+ title: SIMILAR_TEXTS[1].title,
633
+ body: SIMILAR_TEXTS[1].body,
634
+ category: "live.api-design",
635
+ provenance: { agent: "live-fixture", source_ids: [] },
636
+ });
637
+
638
+ // Add unrelated record in same category — should NOT appear in cluster
639
+ await store.create({
640
+ type: "compiled",
641
+ title: UNRELATED_TEXT.title,
642
+ body: UNRELATED_TEXT.body,
643
+ category: "live.api-design",
644
+ provenance: { agent: "live-fixture", source_ids: [] },
645
+ });
646
+
647
+ const runner = makeRunner(store, dir);
648
+ const detector = createVectorSimilarityDetector({
649
+ host: LIVE_HOST,
650
+ model: LIVE_MODEL,
651
+ threshold: 0.60,
652
+ });
653
+
654
+ const result = await runner.synthesize(conceptId, {
655
+ proposedBody: "Live: REST APIs need versioning, consistent naming, and proper HTTP semantics.",
656
+ rationale: "Live vector detector integration test.",
657
+ decision: "apply",
658
+ similarityDetector: detector,
659
+ });
660
+
661
+ assert.ok(result.conceptId === conceptId, "conceptId matches");
662
+ assert.ok(result.cluster.length >= 1, "cluster has at least one member");
663
+ assert.equal(result.decision, "apply", "decision is apply");
664
+
665
+ const updated = await store.get(conceptId);
666
+ assert.ok(
667
+ updated.body.includes("Live:"),
668
+ "concept body updated by live vector detector synthesis"
669
+ );
670
+ } finally {
671
+ fs.rmSync(dir, { recursive: true, force: true });
672
+ }
673
+ });
674
+ });
675
+
676
+ // Fallback notice when live tests are skipped
677
+ if (!ollamaAvailable) {
678
+ describe("LIVE — real ollama embedding round-trip", { skip: "ollama not reachable at localhost:11434" }, () => {
679
+ test("skipped", () => {});
680
+ });
681
+ } else if (!ollamaModelAvailable) {
682
+ describe("LIVE — real ollama embedding round-trip", { skip: `model '${LIVE_MODEL}' not available in ollama (run: ollama pull ${LIVE_MODEL})` }, () => {
683
+ test("skipped", () => {});
684
+ });
685
+ }