pi-mega-compact 0.4.5 → 0.4.7

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 (70) hide show
  1. package/dist/extensions/dashboard-server.js +450 -0
  2. package/dist/extensions/dashboard-server.test.js +111 -0
  3. package/dist/extensions/error-patterns.js +115 -0
  4. package/dist/extensions/mega-compact.js +821 -0
  5. package/dist/extensions/mega-compact.test.js +328 -0
  6. package/dist/extensions/openclaw-mega-compact.js +291 -0
  7. package/dist/src/adapt.js +106 -0
  8. package/dist/src/boundary.js +88 -0
  9. package/dist/src/boundary.test.js +53 -0
  10. package/dist/src/canary.js +118 -0
  11. package/dist/src/compact.js +250 -0
  12. package/dist/src/compact.test.js +78 -0
  13. package/dist/src/config/dedup.js +81 -0
  14. package/dist/src/config.js +12 -0
  15. package/dist/src/dedup/dedup.test.js +41 -0
  16. package/dist/src/dedup/digest.js +30 -0
  17. package/dist/src/dedup/l1-lsh.js +52 -0
  18. package/dist/src/dedup/l1-minhash.js +91 -0
  19. package/dist/src/dedup/l1-verify.js +54 -0
  20. package/dist/src/dedup/l1.test.js +50 -0
  21. package/dist/src/dedup/mmr.js +45 -0
  22. package/dist/src/dedup/normalize.js +39 -0
  23. package/dist/src/dedup/raptor/guardrails.js +83 -0
  24. package/dist/src/dedup/raptor/index.js +94 -0
  25. package/dist/src/dedup/raptor/kmeans.js +152 -0
  26. package/dist/src/dedup/raptor/raptor.test.js +205 -0
  27. package/dist/src/dedup/raptor/retrieval.js +81 -0
  28. package/dist/src/dedup/raptor/summarizer.js +85 -0
  29. package/dist/src/dedup/raptor/tree.js +177 -0
  30. package/dist/src/dedup/sprint12.test.js +219 -0
  31. package/dist/src/dedup/topk.js +60 -0
  32. package/dist/src/dedup-engine.test.js +447 -0
  33. package/dist/src/e2e.test.js +698 -0
  34. package/dist/src/embedder.js +102 -0
  35. package/dist/src/engine.js +139 -0
  36. package/dist/src/engine.test.js +111 -0
  37. package/dist/src/extractive.js +209 -0
  38. package/dist/src/extractive.test.js +130 -0
  39. package/dist/src/httpEmbedder.js +143 -0
  40. package/dist/src/log.js +47 -0
  41. package/dist/src/log.test.js +42 -0
  42. package/dist/src/minilm.js +92 -0
  43. package/dist/src/monitoring.js +131 -0
  44. package/dist/src/ratio.bench.test.js +897 -0
  45. package/dist/src/recall.integration.test.js +77 -0
  46. package/dist/src/recall.js +60 -0
  47. package/dist/src/recall.test.js +50 -0
  48. package/dist/src/sprint14.test.js +219 -0
  49. package/dist/src/store/backfill.js +189 -0
  50. package/dist/src/store/bloom.js +114 -0
  51. package/dist/src/store/compression.js +177 -0
  52. package/dist/src/store/compression.test.js +67 -0
  53. package/dist/src/store/integrity.js +44 -0
  54. package/dist/src/store/migrate.js +79 -0
  55. package/dist/src/store/migrate.test.js +139 -0
  56. package/dist/src/store/sprint10.test.js +186 -0
  57. package/dist/src/store/sqlite.js +574 -0
  58. package/dist/src/store.js +115 -0
  59. package/dist/src/store.test.js +142 -0
  60. package/dist/src/supersede.js +68 -0
  61. package/dist/src/supersede.test.js +36 -0
  62. package/dist/src/tokens.js +31 -0
  63. package/dist/src/types.js +8 -0
  64. package/dist/src/types.test.js +9 -0
  65. package/dist/src/vectorStore.js +465 -0
  66. package/dist/src/vectorStore.test.js +479 -0
  67. package/dist/src/wordpiece.js +129 -0
  68. package/extensions/mega-compact.ts +47 -11
  69. package/package.json +4 -2
  70. package/src/engine.ts +5 -0
@@ -0,0 +1,78 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { formatCompactSummary, summarizeMessages, mergeCompactSummaries, shouldCompact, autoCompactCheck, collectKeyFiles, inferPendingWork, extractFileCandidates, } from "./compact.js";
4
+ import { estimateSessionTokens } from "./tokens.js";
5
+ function user(text) { return { role: "user", text }; }
6
+ function assistant(text) { return { role: "assistant", text }; }
7
+ function toolUse(name, input) { return { role: "assistant", text: "", toolName: name, input }; }
8
+ function toolResult(name, output) { return { role: "tool", text: "", toolName: name, output }; }
9
+ test("formatCompactSummary strips analysis and formats summary block (claw-code parity)", () => {
10
+ const summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
11
+ assert.equal(formatCompactSummary(summary), "Summary:\nKept work");
12
+ });
13
+ test("leaves small sessions unchanged (shouldCompact false)", () => {
14
+ const messages = [user("hello")];
15
+ assert.equal(shouldCompact(messages, 1, 4), false);
16
+ });
17
+ test("compacts older messages into a summary with Scope + timeline", () => {
18
+ const messages = [
19
+ user("one ".repeat(200)),
20
+ assistant("two ".repeat(200)),
21
+ toolResult("bash", "ok ".repeat(200)),
22
+ assistant("recent"),
23
+ ];
24
+ assert.equal(shouldCompact(messages, 1, 2), true);
25
+ const summary = summarizeMessages(messages.slice(0, 2));
26
+ const formatted = formatCompactSummary(summary);
27
+ assert.ok(formatted.includes("Scope:"));
28
+ assert.ok(formatted.includes("Key timeline:"));
29
+ });
30
+ test("merge keeps previous compacted context when compacting again", () => {
31
+ const first = summarizeMessages([
32
+ user("Investigate src/compact.ts"),
33
+ assistant("I will inspect the compact flow."),
34
+ ]);
35
+ const second = summarizeMessages([
36
+ user("Also update src/boundary.ts"),
37
+ assistant("Next: preserve prior summary context."),
38
+ ]);
39
+ const merged = mergeCompactSummaries(first, second);
40
+ assert.ok(merged.includes("Previously compacted context:"));
41
+ assert.ok(merged.includes("Newly compacted context:"));
42
+ assert.ok(merged.includes("src/boundary.ts"));
43
+ });
44
+ test("infers pending work from recent messages", () => {
45
+ const pending = inferPendingWork([
46
+ user("done"),
47
+ assistant("Next: update tests and follow up on remaining CLI polish."),
48
+ ]);
49
+ assert.equal(pending.length, 1);
50
+ assert.ok(pending[0].includes("Next: update tests"));
51
+ });
52
+ test("extracts key files from message content", () => {
53
+ const files = collectKeyFiles([
54
+ user("Update src/compact.ts and extensions/mega-compact.ts next."),
55
+ ]);
56
+ assert.ok(files.includes("src/compact.ts"));
57
+ assert.ok(files.includes("extensions/mega-compact.ts"));
58
+ });
59
+ test("extractFileCandidates ignores plain words and non-interesting extensions", () => {
60
+ const files = extractFileCandidates("look at foo/bar.png and src/x.ts and justaword");
61
+ assert.deepEqual(files, ["src/x.ts"]);
62
+ });
63
+ test("summarizeMessages lists tool names sorted + deduped", () => {
64
+ const summary = summarizeMessages([toolUse("search", "{}"), toolUse("bash", "{}"), toolResult("search", "ok")]);
65
+ assert.ok(summary.includes("Tools mentioned: bash, search."));
66
+ });
67
+ test("autoCompactCheck reports utilization and threshold gate", () => {
68
+ const under = autoCompactCheck(10000, 50000);
69
+ assert.equal(under.shouldCompact, false);
70
+ assert.equal(under.utilizationPct, 20);
71
+ const over = autoCompactCheck(60000, 50000);
72
+ assert.equal(over.shouldCompact, true);
73
+ });
74
+ test("token estimator counts text + tool payloads", () => {
75
+ const total = estimateSessionTokens([user("abcd"), toolResult("bash", "abcd")]);
76
+ // "abcd"/4+1 = 2 for user; tool: name "bash"/4+1=2 plus output "abcd"/4+1=2 => 4
77
+ assert.equal(total, 2 + 4);
78
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * config/dedup.ts — SINGLE SOURCE OF TRUTH for dedup tier flags + thresholds
3
+ * (Sprint 14, Phase 7).
4
+ *
5
+ * Every tier flag and threshold that was previously an inline default in
6
+ * vectorStore.ts / the RAPTOR modules is defined HERE and only here (QA #8: no
7
+ * duplicated threshold across modules). Values are read from MEGACOMPACT_* env
8
+ * at load, with the file defaults below as the fallback (which reproduce the
9
+ * Sprint 13 behavior — all tiers active, nothing MARK_ONLY).
10
+ *
11
+ * MARK_ONLY semantics (QA ops): a tier in MARK_ONLY still RUNS and RECORDS its
12
+ * decision (so we keep the data + can replay), but does NOT collapse/dedup — a
13
+ * safe partial-rollout / auto-degrade state.
14
+ *
15
+ * PREVENT-PI-004: pure config, no network. Booleans/numbers only.
16
+ */
17
+ function envBool(name, def) {
18
+ const v = process.env[name];
19
+ if (v === undefined)
20
+ return def;
21
+ return v === "true" || v === "1";
22
+ }
23
+ function envNum(name, def) {
24
+ const v = process.env[name];
25
+ if (v === undefined)
26
+ return def;
27
+ const n = Number(v);
28
+ return Number.isFinite(n) ? n : def;
29
+ }
30
+ /** Read the current dedup config from env (file defaults reproduce Sprint 13). */
31
+ export function loadDedupConfig() {
32
+ return {
33
+ L0_ENABLED: envBool("MEGACOMPACT_L0_ENABLED", true),
34
+ L1_ENABLED: envBool("MEGACOMPACT_L1_ENABLED", true),
35
+ L2_ENABLED: envBool("MEGACOMPACT_L2_ENABLED", true),
36
+ RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", false), // shadow by default
37
+ MARK_ONLY_L0: envBool("MEGACOMPACT_MARK_ONLY_L0", false),
38
+ MARK_ONLY_L1: envBool("MEGACOMPACT_MARK_ONLY_L1", false),
39
+ MARK_ONLY_L2: envBool("MEGACOMPACT_MARK_ONLY_L2", false),
40
+ MINILM_EMBEDDER: envBool("MEGACOMPACT_MINILM", false),
41
+ L2_COSINE: envNum("MEGACOMPACT_L2_THRESHOLD", 0.85),
42
+ L1_JACCARD: envNum("MEGACOMPACT_L1_JACCARD", 0.8),
43
+ DEDUP_SIM: envNum("MEGACOMPACT_DEDUP_SIM", 0.9),
44
+ MMR_LAMBDA: envNum("MEGACOMPACT_MMR_LAMBDA", 0.5),
45
+ SEMDEDUP_COSINE: envNum("MEGACOMPACT_SEMDEDUP_COSINE", 0.95),
46
+ SIMILARITY_BUDGET_MS: envNum("MEGACOMPACT_SIMILARITY_BUDGET_MS", 50),
47
+ L1_VERIFY_BUDGET_MS: envNum("MEGACOMPACT_L1_VERIFY_BUDGET_MS", 20),
48
+ L1_CANDIDATE_CAP: envNum("MEGACOMPACT_L1_CANDIDATE_CAP", 100),
49
+ RAPTOR_BUDGET_MS: envNum("MEGACOMPACT_RAPTOR_BUDGET_MS", 5000),
50
+ RAPTOR_CLUSTERS_PER_LEVEL: envNum("MEGACOMPACT_RAPTOR_CLUSTERS", 5),
51
+ RAPTOR_CONSISTENCY: envNum("MEGACOMPACT_RAPTOR_CONSISTENCY", 0.6),
52
+ FP_RATE_L0: envNum("MEGACOMPACT_FP_RATE_L0", 0.01),
53
+ FP_RATE_L1L2: envNum("MEGACOMPACT_FP_RATE_L1L2", 0.05),
54
+ ALERT_WINDOW_MS: envNum("MEGACOMPACT_ALERT_WINDOW_MS", 600_000),
55
+ P95_BUDGET_MS: envNum("MEGACOMPACT_P95_BUDGET_MS", 100),
56
+ };
57
+ }
58
+ /**
59
+ * The default config snapshot (read once at import). Callers that need to honor
60
+ * runtime env changes (tests) should call loadDedupConfig() directly; the live
61
+ * add()/search() path reads this snapshot but accepts an override for testing.
62
+ */
63
+ export const DedupConfig = loadDedupConfig();
64
+ /** Is a given tier enabled (and not merely MARK_ONLY)? */
65
+ export function tierEnabled(cfg, tier) {
66
+ switch (tier) {
67
+ case "L0": return cfg.L0_ENABLED;
68
+ case "L1": return cfg.L1_ENABLED;
69
+ case "L2": return cfg.L2_ENABLED;
70
+ case "RAPTOR": return cfg.RAPTOR_ENABLED;
71
+ }
72
+ }
73
+ /** Is a given tier in MARK_ONLY (record, don't collapse)? */
74
+ export function tierMarkOnly(cfg, tier) {
75
+ switch (tier) {
76
+ case "L0": return cfg.MARK_ONLY_L0;
77
+ case "L1": return cfg.MARK_ONLY_L1;
78
+ case "L2": return cfg.MARK_ONLY_L2;
79
+ case "RAPTOR": return false; // RAPTOR has its own shadow mode
80
+ }
81
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * config.ts — shared default paths/constants for the mega-compact engine.
3
+ *
4
+ * Kept tiny and dependency-free so both the extension entry and unit tests can
5
+ * import it without pulling in pi runtime types.
6
+ */
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ /** Default on-disk location for checkpoints + session state. */
10
+ export const STATE_DIR_DEFAULT = join(homedir(), ".pi", "agent", "extensions", "pi-mega-compact");
11
+ /** Pi custom message / entry type used as the dedup sentinel. */
12
+ export const MARKER_TYPE = "mega-compact-marker";
@@ -0,0 +1,41 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { normalize, stripAnsi } from "./normalize.js";
4
+ import { computeContentDigest, CONTENT_HASH_VERSION } from "./digest.js";
5
+ test("normalize collapses whitespace/newline variants to one form (Sprint 9)", () => {
6
+ // Sprint 9 normalizes whitespace/newlines/ANSI (not case — that is Sprint 10).
7
+ const variants = [
8
+ "foo bar",
9
+ "foo bar",
10
+ "foo\tbar",
11
+ "foo\nbar",
12
+ " foo bar ",
13
+ "foo\r\nbar",
14
+ ];
15
+ const digests = variants.map((v) => computeContentDigest(v).contentHash);
16
+ const unique = new Set(digests);
17
+ assert.equal(unique.size, 1, "all whitespace/newline variants must collapse to one digest");
18
+ });
19
+ test("normalize is idempotent", () => {
20
+ const input = " Hello World \n";
21
+ assert.equal(normalize(normalize(input)), normalize(input));
22
+ });
23
+ test("stripAnsi removes terminal color codes", () => {
24
+ const colored = "err\x1b[31m fatal\x1b[0m boom";
25
+ assert.equal(stripAnsi(colored), "err fatal boom");
26
+ });
27
+ test("computeContentDigest emits full 64-hex dual hashes + version", () => {
28
+ const d = computeContentDigest("the same region text");
29
+ assert.equal(d.contentHash.length, 64);
30
+ assert.equal(d.contentHash2.length, 64);
31
+ assert.equal(d.contentHashVersion, CONTENT_HASH_VERSION);
32
+ assert.equal(d.normalizedText, "the same region text");
33
+ // Secondary is an independent view (reversed) so it differs from primary.
34
+ assert.notEqual(d.contentHash, d.contentHash2);
35
+ });
36
+ test("dual-hash: distinct content yields a distinct pair (both must agree to dedup)", () => {
37
+ const a = computeContentDigest("region about authentication");
38
+ const b = computeContentDigest("region about authorization");
39
+ assert.notEqual(a.contentHash, b.contentHash);
40
+ assert.notEqual(a.contentHash2, b.contentHash2);
41
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * digest.ts — content-addressable digest (Sprint 9).
3
+ *
4
+ * Dual-hash design (QA #2 spirit, local): a primary + a secondary hash guard
5
+ * against a single-hash collision silently merging distinct content. The L0 dedup
6
+ * key is `(content_hash, content_hash2)` — both must agree to declare a duplicate.
7
+ *
8
+ * `content_hash` is the full 64-hex SHA-256 of the normalized text. `content_hash2`
9
+ * is a second independent view (SHA-256 of the reversed normalized text) so a
10
+ * collision on one alone does not dedup. `content_hash_version` lets Sprint 11/12
11
+ * plug in stronger digests later without breaking old rows.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { normalize } from "./normalize.js";
15
+ export const CONTENT_HASH_VERSION = 1;
16
+ /** Compute the canonical content digest for a (possibly raw) region text. */
17
+ export function computeContentDigest(text) {
18
+ const normalizedText = normalize(text);
19
+ const contentHash = createHash("sha256").update(normalizedText).digest("hex");
20
+ // Secondary: hash the reversed normalized string so it's an independent view.
21
+ const contentHash2 = createHash("sha256")
22
+ .update(normalizedText.split("").reverse().join(""))
23
+ .digest("hex");
24
+ return {
25
+ contentHash,
26
+ contentHash2,
27
+ contentHashVersion: CONTENT_HASH_VERSION,
28
+ normalizedText,
29
+ };
30
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * l1-lsh.ts — Locality-Sensitive Hashing banding over MinHash signatures
3
+ * (Sprint 11).
4
+ *
5
+ * Splits the 256-slot signature into `BANDS` bands of `ROWS_PER_BAND` rows. Each
6
+ * band is hashed into a bucket key; rows sharing a band are likely near-duplicates.
7
+ * The bucket key INCLUDES the session_id so candidates are scoped per session
8
+ * (deterministic, no cross-session leakage — QA determinism fix).
9
+ *
10
+ * All hashing is deterministic given the signature + seed (PREVENT-PI-004, pure).
11
+ */
12
+ import { SIGNATURE_VERSION, NUM_HASHES } from "./l1-minhash.js";
13
+ export const BANDS = 64;
14
+ export const ROWS_PER_BAND = 4; // 64 * 4 = 256 slots (matches minhashSignature length)
15
+ /** Stable 32-bit FNV-1a (buffer form) for band hashing. */
16
+ function fnv1aBuf(buf) {
17
+ let h = 0x811c9dc5;
18
+ for (let i = 0; i < buf.length; i++) {
19
+ h ^= buf[i];
20
+ h = Math.imul(h, 0x01000193);
21
+ }
22
+ return h >>> 0;
23
+ }
24
+ /**
25
+ * Compute the list of LSH bucket keys for a signature within a session.
26
+ * Deterministic: same (session_id, signature) → same keys, every run.
27
+ */
28
+ export function lshBands(signature, sessionId, version = SIGNATURE_VERSION) {
29
+ if (signature.length < BANDS * ROWS_PER_BAND) {
30
+ throw new Error(`lshBands: signature length ${signature.length} < required ${BANDS * ROWS_PER_BAND}`);
31
+ }
32
+ const keys = [];
33
+ const seedPrefix = Buffer.from(`${sessionId}|${version}|`, "utf-8");
34
+ for (let band = 0; band < BANDS; band++) {
35
+ const start = band * ROWS_PER_BAND;
36
+ const slice = signature.slice(start, start + ROWS_PER_BAND);
37
+ const body = Buffer.allocUnsafe(ROWS_PER_BAND * 4);
38
+ for (let r = 0; r < ROWS_PER_BAND; r++) {
39
+ body.writeUInt32LE(slice[r] >>> 0, r * 4);
40
+ }
41
+ const combined = Buffer.concat([seedPrefix, body]);
42
+ keys.push(`b${band}:${fnv1aBuf(combined).toString(16)}`);
43
+ }
44
+ return keys;
45
+ }
46
+ /** Convenience: derive bands directly from text (used by callers without a cached sig). */
47
+ export function bandsForText(signature, sessionId, version = SIGNATURE_VERSION) {
48
+ if (signature.length !== NUM_HASHES) {
49
+ throw new Error(`bandsForText: expected signature length ${NUM_HASHES}, got ${signature.length}`);
50
+ }
51
+ return lshBands(signature, sessionId, version);
52
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * l1-minhash.ts — MinHash signatures for L1 near-duplicate detection (Sprint 11).
3
+ *
4
+ * Uses UNIVERSAL HASHING (QA #3), not the broken permutation scheme from the
5
+ * generic dedup plan. Each of the 256 hash functions is `h_i(x) = (a_i·x + b_i)
6
+ * mod p` with a fixed prime `p` and per-index coefficients derived from a pinned
7
+ * seed (0xDEADBEEF). This makes signatures DETERMINISTIC across process restarts
8
+ * (a hard requirement — non-determinism silently breaks dedup).
9
+ *
10
+ * `signatureVersion` lets Sprint 12+ swap the scheme without invalidating stored
11
+ * signatures: old buckets keep their version, new ones get the new one.
12
+ *
13
+ * Pure compute, no deps, no network (PREVENT-PI-004).
14
+ */
15
+ import { normalize } from "./normalize.js";
16
+ export const SIGNATURE_VERSION = 1;
17
+ export const NUM_HASHES = 256;
18
+ export const SHINGLE_SIZE = 5; // char 5-grams
19
+ const MAX_SHINGLES = 50_000; // QA #7/#15 complexity cap
20
+ const SEED = 0xdeadbeef;
21
+ const P = 2147483647; // 2^31 - 1, Mersenne prime
22
+ /** Per-index universal-hashing coefficients, derived deterministically from SEED. */
23
+ function coeffA(i) {
24
+ return (SEED + i * 2 + 1) % P;
25
+ }
26
+ function coeffB(i) {
27
+ return (SEED * 3 + i * 7 + 13) % P;
28
+ }
29
+ /** Stable 32-bit FNV-1a of a shingle (the `x` fed to the universal hashes). */
30
+ function shingleHash(gram) {
31
+ let h = 0x811c9dc5;
32
+ for (let k = 0; k < gram.length; k++) {
33
+ h ^= gram.charCodeAt(k);
34
+ h = Math.imul(h, 0x01000193);
35
+ }
36
+ return h >>> 0;
37
+ }
38
+ /** Char n-gram shingle set (deduped) of normalized text, capped at MAX_SHINGLES. */
39
+ export function shingles(text, size = SHINGLE_SIZE) {
40
+ const norm = normalize(text);
41
+ if (norm.length === 0)
42
+ return [];
43
+ const set = new Set();
44
+ if (norm.length < size) {
45
+ set.add(shingleHash(norm));
46
+ }
47
+ else {
48
+ for (let i = 0; i + size <= norm.length; i++) {
49
+ set.add(shingleHash(norm.slice(i, i + size)));
50
+ if (set.size >= MAX_SHINGLES)
51
+ break;
52
+ }
53
+ }
54
+ return [...set];
55
+ }
56
+ /**
57
+ * Compute the 256-element MinHash signature of a text. Each slot is the minimum,
58
+ * over all shingles, of the i-th universal hash. Empty text → all-P sentinel.
59
+ */
60
+ export function minhashSignature(text) {
61
+ const grams = shingles(text);
62
+ const sig = new Array(NUM_HASHES).fill(P);
63
+ if (grams.length === 0)
64
+ return sig;
65
+ for (let i = 0; i < NUM_HASHES; i++) {
66
+ const a = coeffA(i);
67
+ const b = coeffB(i);
68
+ let min = P;
69
+ for (const x of grams) {
70
+ // (a*x + b) mod p — use Number math; a,x < 2^31 so a*x < 2^62, within
71
+ // double-precision integer range (2^53) only if reduced; reduce a*x first.
72
+ const ax = (a * (x % P)) % P;
73
+ const h = (ax + b) % P;
74
+ if (h < min)
75
+ min = h;
76
+ }
77
+ sig[i] = min;
78
+ }
79
+ return sig;
80
+ }
81
+ /** Estimated Jaccard similarity of two signatures (fraction of equal slots). */
82
+ export function signatureSimilarity(a, b) {
83
+ const n = Math.min(a.length, b.length);
84
+ if (n === 0)
85
+ return 0;
86
+ let equal = 0;
87
+ for (let i = 0; i < n; i++)
88
+ if (a[i] === b[i])
89
+ equal++;
90
+ return equal / n;
91
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * l1-verify.ts — trigram-similarity verification gate for L1 near-duplicates
3
+ * (Sprint 11).
4
+ *
5
+ * After LSH cheaply retrieves candidate chunk_ids, we apply a REAL similarity
6
+ * check before declaring a duplicate. This is the `pg_trgm`-equivalent final
7
+ * gate: we compute the trigram (character 3-gram) Jaccard / overlap similarity
8
+ * between the new normalized text and each candidate's normalized text.
9
+ *
10
+ * We compute it in TS (not via FTS5 MATCH, which is boolean) so we get a stable
11
+ * [0,1] score to threshold against (0.85 per spec). The FTS5 `trigram` table is
12
+ * still maintained for future query-path use, but verification is pure TS so its
13
+ * result is deterministic and unit-testable (PREVENT-PI-004).
14
+ */
15
+ import { normalize } from "./normalize.js";
16
+ const TRIGRAM_SIZE = 3;
17
+ export const L1_VERIFY_THRESHOLD = 0.85;
18
+ /** Extract the set of character trigrams from normalized text. */
19
+ function trigrams(text) {
20
+ const norm = normalize(text);
21
+ const set = new Set();
22
+ if (norm.length === 0)
23
+ return set;
24
+ if (norm.length < TRIGRAM_SIZE) {
25
+ set.add(norm);
26
+ return set;
27
+ }
28
+ for (let i = 0; i + TRIGRAM_SIZE <= norm.length; i++) {
29
+ set.add(norm.slice(i, i + TRIGRAM_SIZE));
30
+ }
31
+ return set;
32
+ }
33
+ /**
34
+ * Trigram similarity in [0,1]. We use overlap coefficient (|A∩B| / min(|A|,|B|))
35
+ * which, like pg_trgm's `similarity`, is robust when one text is a substring of
36
+ * the other — better than Jaccard for the near-dup "one-word edit" case.
37
+ */
38
+ export function trigramSimilarity(a, b) {
39
+ const ta = trigrams(a);
40
+ const tb = trigrams(b);
41
+ if (ta.size === 0 || tb.size === 0)
42
+ return 0;
43
+ let inter = 0;
44
+ const smaller = ta.size <= tb.size ? ta : tb;
45
+ const larger = smaller === ta ? tb : ta;
46
+ for (const g of smaller)
47
+ if (larger.has(g))
48
+ inter++;
49
+ return inter / smaller.size;
50
+ }
51
+ /** True when `a` and `b` are near-duplicates under the L1 threshold. */
52
+ export function isNearDuplicate(a, b, threshold = L1_VERIFY_THRESHOLD) {
53
+ return trigramSimilarity(a, b) >= threshold;
54
+ }
@@ -0,0 +1,50 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { minhashSignature, shingles, signatureSimilarity, NUM_HASHES } from "./l1-minhash.js";
4
+ import { lshBands, BANDS, ROWS_PER_BAND } from "./l1-lsh.js";
5
+ import { trigramSimilarity, isNearDuplicate, L1_VERIFY_THRESHOLD } from "./l1-verify.js";
6
+ test("minhashSignature is deterministic across calls (same input → same sig)", () => {
7
+ const a = minhashSignature("the authentication module handles login securely");
8
+ const b = minhashSignature("the authentication module handles login securely");
9
+ assert.deepEqual(a, b);
10
+ assert.equal(a.length, NUM_HASHES);
11
+ });
12
+ test("minhashSignature of near-identical text is more similar than of unrelated text", () => {
13
+ const s1 = minhashSignature("user logged in and viewed the dashboard");
14
+ const s2 = minhashSignature("user logged in and viewed the dashboard page"); // one word added
15
+ const s3 = minhashSignature("the compiler optimized the hot loop aggressively");
16
+ assert.ok(signatureSimilarity(s1, s2) > signatureSimilarity(s1, s3));
17
+ });
18
+ test("lshBands produces BANDS keys and is stable per session", () => {
19
+ const sig = minhashSignature("some text to band");
20
+ const k1 = lshBands(sig, "sess_a", 1);
21
+ const k2 = lshBands(sig, "sess_a", 1);
22
+ assert.equal(k1.length, BANDS);
23
+ assert.equal(BANDS * ROWS_PER_BAND, NUM_HASHES);
24
+ assert.deepEqual(k1, k2);
25
+ // Different session → different bucket keys (scoped, deterministic).
26
+ const k3 = lshBands(sig, "sess_b", 1);
27
+ assert.notDeepEqual(k1, k3);
28
+ });
29
+ test("lshBands keys are stable across restarts (no entropy source)", () => {
30
+ // Recomputed in a fresh call path — determinism is structural, not time-based.
31
+ const sig = minhashSignature("deterministic bucket key check");
32
+ const first = lshBands(sig, "sess_x", 1);
33
+ const again = lshBands(sig, "sess_x", 1);
34
+ assert.deepEqual(first, again);
35
+ });
36
+ test("trigramSimilarity is 1 for identical, high for one-word-edit, low for unrelated", () => {
37
+ const a = "the quick brown fox jumps";
38
+ assert.equal(trigramSimilarity(a, a), 1);
39
+ assert.ok(trigramSimilarity(a, "the quick brown fox jumps over") >= L1_VERIFY_THRESHOLD);
40
+ assert.ok(trigramSimilarity(a, "a completely different sentence about databases") < 0.5);
41
+ });
42
+ test("isNearDuplicate thresholds at 0.85", () => {
43
+ assert.equal(isNearDuplicate("user fixed the parser bug", "user fixed the parser bug today"), true);
44
+ assert.equal(isNearDuplicate("alpha beta gamma", "totally different words here"), false);
45
+ });
46
+ test("shingles are capped at 50K (complexity guard)", () => {
47
+ const huge = "x".repeat(200_000);
48
+ const sh = shingles(huge);
49
+ assert.ok(sh.length <= 50_000);
50
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * mmr.ts — Maximal Marginal Relevance reranking for retrieval diversity
3
+ * (Sprint 12, QA #10).
4
+ *
5
+ * After a relevance-ranked candidate list, MMR reorders so we don't inject a
6
+ * cluster of near-identical checkpoints. Each step picks the candidate that
7
+ * maximizes `λ·relevance − (1−λ)·maxSimToAlreadySelected`, balancing relevance
8
+ * against redundancy. λ=0.5 is the default (equal weight).
9
+ *
10
+ * Pure function over cosine similarities — no deps, no network (PREVENT-PI-004).
11
+ */
12
+ import { cosineSimilarity } from "../embedder.js";
13
+ export const MMR_LAMBDA = 0.5;
14
+ /**
15
+ * Rerank `items` by MMR. Returns the items in MMR order, capped at `k`.
16
+ * `lambda` balances relevance vs diversity (1 = pure relevance, 0 = max diversity).
17
+ */
18
+ export function mmrRerank(items, k, lambda = MMR_LAMBDA) {
19
+ if (items.length === 0)
20
+ return [];
21
+ const remaining = [...items];
22
+ const selected = [];
23
+ const cap = Math.min(k, items.length);
24
+ while (selected.length < cap && remaining.length > 0) {
25
+ let bestIdx = 0;
26
+ let bestScore = -Infinity;
27
+ for (let i = 0; i < remaining.length; i++) {
28
+ const cand = remaining[i];
29
+ // Max similarity to already-selected (redundancy penalty).
30
+ let maxSimToSelected = 0;
31
+ for (const sel of selected) {
32
+ const sim = cosineSimilarity(cand.vector, sel.vector);
33
+ if (sim > maxSimToSelected)
34
+ maxSimToSelected = sim;
35
+ }
36
+ const mmr = lambda * cand.relevance - (1 - lambda) * maxSimToSelected;
37
+ if (mmr > bestScore) {
38
+ bestScore = mmr;
39
+ bestIdx = i;
40
+ }
41
+ }
42
+ selected.push(remaining.splice(bestIdx, 1)[0]);
43
+ }
44
+ return selected.map((s) => s.item);
45
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * normalize.ts — text normalization for content-addressable dedup (Sprint 9).
3
+ *
4
+ * The L0 dedup key is `sha256(normalize(text))`, so normalization decides which
5
+ * surface variants collapse to the same checkpoint. Pure, synchronous, no deps.
6
+ *
7
+ * Steps (order matters):
8
+ * 1. strip ANSI escape sequences (terminal color codes leak into tool output)
9
+ * 2. Unicode NFC (canonical composition — "e" + combining accent == "é")
10
+ * 3. case-fold (NFKC Cf + toLowerCase) so "Foo"/"foo"/"FOO" collapse (Sprint 10 L0 upgrade)
11
+ * 4. normalize newlines (CRLF/CR → LF)
12
+ * 5. collapse runs of whitespace to a single space, trim ends
13
+ * 6. cap at 32K chars (bounds hashing cost on pathological inputs — QA #7/#15)
14
+ */
15
+ const MAX_CHARS = 32_768;
16
+ // ANSI/VT100 escape sequence: ESC (0x1B) [ ...params... [ -/]* final-byte.
17
+ // Built from the code point so the source file contains no literal escape byte.
18
+ const ESC = String.fromCharCode(0x1b);
19
+ const ANSI_RE = new RegExp(ESC + "\\[[0-?]*[ -/]*[@-~]", "g");
20
+ /** Strip ANSI/VT100 escape sequences. */
21
+ export function stripAnsi(text) {
22
+ return text.replace(ANSI_RE, "");
23
+ }
24
+ /**
25
+ * Normalize text to its canonical dedup form. Deterministic and idempotent:
26
+ * `normalize(normalize(x)) === normalize(x)`.
27
+ */
28
+ export function normalize(text) {
29
+ if (!text)
30
+ return "";
31
+ let out = stripAnsi(text);
32
+ out = out.normalize("NFC");
33
+ out = out.toLocaleLowerCase(); // case-fold so "Foo"/"FOO" collapse to one key
34
+ out = out.replace(/\r\n?/g, "\n"); // CRLF / CR → LF
35
+ out = out.replace(/\s+/g, " ").trim();
36
+ if (out.length > MAX_CHARS)
37
+ out = out.slice(0, MAX_CHARS);
38
+ return out;
39
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * guardrails.ts — hallucination defense for RAPTOR summary nodes (Sprint 13, QA #16).
3
+ *
4
+ * Four layers gate a candidate summary before it may be marked high-quality:
5
+ * 1. Claim grounding — every claim in the summary maps to source text
6
+ * (no entity/claim appears that isn't supported by a source chunk).
7
+ * 2. Entity coverage — fraction of summary entities that are present in source.
8
+ * 3. Consistency — cosine(reEmbed(summary), cluster centroid) ≥ threshold.
9
+ * This is the HARD gate: a low score means the summary drifted from the
10
+ * source cluster, so we fall back to extractive (never serve a low-quality
11
+ * LLM summary).
12
+ * 4. Quality markers — 'high' | 'low' | 'extractive_fallback' assigned from the
13
+ * above.
14
+ *
15
+ * Pure functions, no network, no model. The consistency check uses the caller's
16
+ * embedder (the same local Embedder used everywhere else).
17
+ */
18
+ import { cosineSimilarity } from "../../embedder.js";
19
+ const ENTITY_RE = /\b([A-Z][a-zA-Z0-9_]{2,}|[a-z_]+_[a-z_]+|\d{2,})\b/g;
20
+ /** Extract candidate "entities"/tokens from a summary for grounding checks. */
21
+ export function extractEntities(text) {
22
+ const out = new Set();
23
+ for (const m of text.matchAll(ENTITY_RE))
24
+ out.add(m[1].toLowerCase());
25
+ return [...out];
26
+ }
27
+ /** Lowercase word set from a body of source text (for grounding lookups). */
28
+ export function sourceTokenSet(sources) {
29
+ const set = new Set();
30
+ for (const s of sources)
31
+ for (const w of s.toLowerCase().split(/\W+/))
32
+ if (w)
33
+ set.add(w);
34
+ return set;
35
+ }
36
+ /**
37
+ * Verify a summary against its sources + centroid.
38
+ *
39
+ * Faithfulness (QA #16): consistency is the hard gate. If the summary embedding
40
+ * is insufficiently similar to the cluster centroid, the summary is NOT faithful
41
+ * to the source — mark it 'extractive_fallback' so callers fall back to the
42
+ * deterministic extractive summary instead of serving a drifted LLM summary.
43
+ *
44
+ * grounding: every summary entity must appear in the source token set. A single
45
+ * un-grounded entity fails grounding (caught hallucination).
46
+ */
47
+ export function applyHallucinationGuardrails(input) {
48
+ const threshold = input.consistencyThreshold ?? 0.6;
49
+ const sourceTokens = input.sourceTokens;
50
+ // Layer 1 + 2: entity grounding & coverage.
51
+ const entities = extractEntities(input.summary);
52
+ let groundedCount = 0;
53
+ for (const e of entities) {
54
+ if (sourceTokens.has(e))
55
+ groundedCount++;
56
+ }
57
+ const entityCoverage = entities.length === 0 ? 1 : groundedCount / entities.length;
58
+ const grounded = entities.length === 0 || groundedCount === entities.length;
59
+ // Layer 3: consistency re-embed.
60
+ const summEmbed = input.embedder.embed(input.summary);
61
+ const consistency = cosineSimilarity(summEmbed, input.centroid);
62
+ // Layer 4: quality marker decision.
63
+ if (!grounded || consistency < threshold) {
64
+ return {
65
+ marker: "extractive_fallback",
66
+ entityCoverage,
67
+ consistency,
68
+ grounded,
69
+ reason: !grounded
70
+ ? "ungrounded entity in summary"
71
+ : `consistency ${consistency.toFixed(2)} < ${threshold} (drift from source)`,
72
+ };
73
+ }
74
+ const marker = entityCoverage >= 0.7 ? "high" : "low";
75
+ return { marker, entityCoverage, consistency, grounded, reason: "ok" };
76
+ }
77
+ /**
78
+ * Convenience: build a fixture summary that is deliberately un-grounded (used by
79
+ * tests to prove the guardrail CATCHES a hallucination). Not used in production.
80
+ */
81
+ export function makeUngroundedSummary(realSource, fakeEntity) {
82
+ return `${realSource.slice(0, 60)} The quarterly revenue doubled to ${fakeEntity}.`;
83
+ }