pi-mega-compact 0.4.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.
- package/LICENSE +24 -0
- package/README.md +375 -0
- package/extensions/DASHBOARD.md +160 -0
- package/extensions/dashboard-server.test.ts +124 -0
- package/extensions/dashboard-server.ts +459 -0
- package/extensions/error-patterns.ts +175 -0
- package/extensions/mega-compact.test.ts +351 -0
- package/extensions/mega-compact.ts +846 -0
- package/extensions/openclaw-mega-compact.ts +370 -0
- package/package.json +61 -0
- package/src/adapt.ts +120 -0
- package/src/boundary.test.ts +61 -0
- package/src/boundary.ts +94 -0
- package/src/canary.ts +126 -0
- package/src/compact.test.ts +99 -0
- package/src/compact.ts +262 -0
- package/src/config/dedup.ts +120 -0
- package/src/config.ts +15 -0
- package/src/dedup/dedup.test.ts +46 -0
- package/src/dedup/digest.ts +40 -0
- package/src/dedup/l1-lsh.ts +67 -0
- package/src/dedup/l1-minhash.ts +90 -0
- package/src/dedup/l1-verify.ts +55 -0
- package/src/dedup/l1.test.ts +57 -0
- package/src/dedup/mmr.ts +54 -0
- package/src/dedup/normalize.ts +41 -0
- package/src/dedup/raptor/guardrails.ts +112 -0
- package/src/dedup/raptor/index.ts +118 -0
- package/src/dedup/raptor/kmeans.ts +156 -0
- package/src/dedup/raptor/raptor.test.ts +238 -0
- package/src/dedup/raptor/retrieval.ts +102 -0
- package/src/dedup/raptor/summarizer.ts +91 -0
- package/src/dedup/raptor/tree.ts +254 -0
- package/src/dedup/sprint12.test.ts +242 -0
- package/src/dedup/topk.ts +61 -0
- package/src/dedup-engine.test.ts +609 -0
- package/src/e2e.test.ts +843 -0
- package/src/embedder.ts +111 -0
- package/src/engine.test.ts +123 -0
- package/src/engine.ts +192 -0
- package/src/extractive.test.ts +156 -0
- package/src/extractive.ts +265 -0
- package/src/httpEmbedder.ts +154 -0
- package/src/log.test.ts +47 -0
- package/src/log.ts +60 -0
- package/src/monitoring.ts +171 -0
- package/src/ratio.bench.test.ts +1316 -0
- package/src/recall.integration.test.ts +96 -0
- package/src/recall.test.ts +59 -0
- package/src/recall.ts +100 -0
- package/src/sprint14.test.ts +245 -0
- package/src/store/backfill.ts +263 -0
- package/src/store/bloom.ts +122 -0
- package/src/store/compression.test.ts +83 -0
- package/src/store/compression.ts +203 -0
- package/src/store/integrity.ts +65 -0
- package/src/store/migrate.test.ts +158 -0
- package/src/store/migrate.ts +108 -0
- package/src/store/sprint10.test.ts +182 -0
- package/src/store/sqlite.ts +519 -0
- package/src/store.test.ts +169 -0
- package/src/store.ts +192 -0
- package/src/supersede.test.ts +42 -0
- package/src/supersede.ts +67 -0
- package/src/tokens.ts +35 -0
- package/src/types.test.ts +10 -0
- package/src/types.ts +49 -0
- package/src/vectorStore.test.ts +480 -0
- package/src/vectorStore.ts +544 -0
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
|
|
18
|
+
function envBool(name: string, def: boolean): boolean {
|
|
19
|
+
const v = process.env[name];
|
|
20
|
+
if (v === undefined) return def;
|
|
21
|
+
return v === "true" || v === "1";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function envNum(name: string, def: number): number {
|
|
25
|
+
const v = process.env[name];
|
|
26
|
+
if (v === undefined) return def;
|
|
27
|
+
const n = Number(v);
|
|
28
|
+
return Number.isFinite(n) ? n : def;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DedupConfigShape {
|
|
32
|
+
// Tier enable flags.
|
|
33
|
+
L0_ENABLED: boolean;
|
|
34
|
+
L1_ENABLED: boolean;
|
|
35
|
+
L2_ENABLED: boolean;
|
|
36
|
+
RAPTOR_ENABLED: boolean;
|
|
37
|
+
// MARK_ONLY per tier: run + record, never collapse.
|
|
38
|
+
MARK_ONLY_L0: boolean;
|
|
39
|
+
MARK_ONLY_L1: boolean;
|
|
40
|
+
MARK_ONLY_L2: boolean;
|
|
41
|
+
// Embedder selection.
|
|
42
|
+
MINILM_EMBEDDER: boolean;
|
|
43
|
+
// Thresholds.
|
|
44
|
+
L2_COSINE: number; // semantic dedup firing point
|
|
45
|
+
L1_JACCARD: number; // MinHash/LSH near-dup verification
|
|
46
|
+
DEDUP_SIM: number; // legacy content-similarity fallback
|
|
47
|
+
MMR_LAMBDA: number; // retrieval diversity
|
|
48
|
+
SEMDEDUP_COSINE: number; // offline SemDeDup pair threshold
|
|
49
|
+
// Caps / budgets.
|
|
50
|
+
SIMILARITY_BUDGET_MS: number;
|
|
51
|
+
L1_VERIFY_BUDGET_MS: number;
|
|
52
|
+
L1_CANDIDATE_CAP: number;
|
|
53
|
+
RAPTOR_BUDGET_MS: number;
|
|
54
|
+
RAPTOR_CLUSTERS_PER_LEVEL: number;
|
|
55
|
+
RAPTOR_CONSISTENCY: number;
|
|
56
|
+
// Monitoring / alerting.
|
|
57
|
+
FP_RATE_L0: number; // FP alert threshold for exact tier
|
|
58
|
+
FP_RATE_L1L2: number; // FP alert threshold for fuzzy tiers
|
|
59
|
+
ALERT_WINDOW_MS: number;
|
|
60
|
+
P95_BUDGET_MS: number; // canary p95 budget per tier
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read the current dedup config from env (file defaults reproduce Sprint 13). */
|
|
64
|
+
export function loadDedupConfig(): DedupConfigShape {
|
|
65
|
+
return {
|
|
66
|
+
L0_ENABLED: envBool("MEGACOMPACT_L0_ENABLED", true),
|
|
67
|
+
L1_ENABLED: envBool("MEGACOMPACT_L1_ENABLED", true),
|
|
68
|
+
L2_ENABLED: envBool("MEGACOMPACT_L2_ENABLED", true),
|
|
69
|
+
RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", false), // shadow by default
|
|
70
|
+
MARK_ONLY_L0: envBool("MEGACOMPACT_MARK_ONLY_L0", false),
|
|
71
|
+
MARK_ONLY_L1: envBool("MEGACOMPACT_MARK_ONLY_L1", false),
|
|
72
|
+
MARK_ONLY_L2: envBool("MEGACOMPACT_MARK_ONLY_L2", false),
|
|
73
|
+
MINILM_EMBEDDER: envBool("MEGACOMPACT_MINILM", false),
|
|
74
|
+
L2_COSINE: envNum("MEGACOMPACT_L2_THRESHOLD", 0.85),
|
|
75
|
+
L1_JACCARD: envNum("MEGACOMPACT_L1_JACCARD", 0.8),
|
|
76
|
+
DEDUP_SIM: envNum("MEGACOMPACT_DEDUP_SIM", 0.9),
|
|
77
|
+
MMR_LAMBDA: envNum("MEGACOMPACT_MMR_LAMBDA", 0.5),
|
|
78
|
+
SEMDEDUP_COSINE: envNum("MEGACOMPACT_SEMDEDUP_COSINE", 0.95),
|
|
79
|
+
SIMILARITY_BUDGET_MS: envNum("MEGACOMPACT_SIMILARITY_BUDGET_MS", 50),
|
|
80
|
+
L1_VERIFY_BUDGET_MS: envNum("MEGACOMPACT_L1_VERIFY_BUDGET_MS", 20),
|
|
81
|
+
L1_CANDIDATE_CAP: envNum("MEGACOMPACT_L1_CANDIDATE_CAP", 100),
|
|
82
|
+
RAPTOR_BUDGET_MS: envNum("MEGACOMPACT_RAPTOR_BUDGET_MS", 5000),
|
|
83
|
+
RAPTOR_CLUSTERS_PER_LEVEL: envNum("MEGACOMPACT_RAPTOR_CLUSTERS", 5),
|
|
84
|
+
RAPTOR_CONSISTENCY: envNum("MEGACOMPACT_RAPTOR_CONSISTENCY", 0.6),
|
|
85
|
+
FP_RATE_L0: envNum("MEGACOMPACT_FP_RATE_L0", 0.01),
|
|
86
|
+
FP_RATE_L1L2: envNum("MEGACOMPACT_FP_RATE_L1L2", 0.05),
|
|
87
|
+
ALERT_WINDOW_MS: envNum("MEGACOMPACT_ALERT_WINDOW_MS", 600_000),
|
|
88
|
+
P95_BUDGET_MS: envNum("MEGACOMPACT_P95_BUDGET_MS", 100),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The default config snapshot (read once at import). Callers that need to honor
|
|
94
|
+
* runtime env changes (tests) should call loadDedupConfig() directly; the live
|
|
95
|
+
* add()/search() path reads this snapshot but accepts an override for testing.
|
|
96
|
+
*/
|
|
97
|
+
export const DedupConfig: DedupConfigShape = loadDedupConfig();
|
|
98
|
+
|
|
99
|
+
/** Tier identifiers used in monitoring + alerting. */
|
|
100
|
+
export type DedupTier = "L0" | "L1" | "L2" | "RAPTOR";
|
|
101
|
+
|
|
102
|
+
/** Is a given tier enabled (and not merely MARK_ONLY)? */
|
|
103
|
+
export function tierEnabled(cfg: DedupConfigShape, tier: DedupTier): boolean {
|
|
104
|
+
switch (tier) {
|
|
105
|
+
case "L0": return cfg.L0_ENABLED;
|
|
106
|
+
case "L1": return cfg.L1_ENABLED;
|
|
107
|
+
case "L2": return cfg.L2_ENABLED;
|
|
108
|
+
case "RAPTOR": return cfg.RAPTOR_ENABLED;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Is a given tier in MARK_ONLY (record, don't collapse)? */
|
|
113
|
+
export function tierMarkOnly(cfg: DedupConfigShape, tier: DedupTier): boolean {
|
|
114
|
+
switch (tier) {
|
|
115
|
+
case "L0": return cfg.MARK_ONLY_L0;
|
|
116
|
+
case "L1": return cfg.MARK_ONLY_L1;
|
|
117
|
+
case "L2": return cfg.MARK_ONLY_L2;
|
|
118
|
+
case "RAPTOR": return false; // RAPTOR has its own shadow mode
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
|
|
11
|
+
/** Default on-disk location for checkpoints + session state. */
|
|
12
|
+
export const STATE_DIR_DEFAULT = join(homedir(), ".pi", "agent", "extensions", "pi-mega-compact");
|
|
13
|
+
|
|
14
|
+
/** Pi custom message / entry type used as the dedup sentinel. */
|
|
15
|
+
export const MARKER_TYPE = "mega-compact-marker";
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
|
|
6
|
+
test("normalize collapses whitespace/newline variants to one form (Sprint 9)", () => {
|
|
7
|
+
// Sprint 9 normalizes whitespace/newlines/ANSI (not case — that is Sprint 10).
|
|
8
|
+
const variants = [
|
|
9
|
+
"foo bar",
|
|
10
|
+
"foo bar",
|
|
11
|
+
"foo\tbar",
|
|
12
|
+
"foo\nbar",
|
|
13
|
+
" foo bar ",
|
|
14
|
+
"foo\r\nbar",
|
|
15
|
+
];
|
|
16
|
+
const digests = variants.map((v) => computeContentDigest(v).contentHash);
|
|
17
|
+
const unique = new Set(digests);
|
|
18
|
+
assert.equal(unique.size, 1, "all whitespace/newline variants must collapse to one digest");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("normalize is idempotent", () => {
|
|
22
|
+
const input = " Hello World \n";
|
|
23
|
+
assert.equal(normalize(normalize(input)), normalize(input));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("stripAnsi removes terminal color codes", () => {
|
|
27
|
+
const colored = "err\x1b[31m fatal\x1b[0m boom";
|
|
28
|
+
assert.equal(stripAnsi(colored), "err fatal boom");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("computeContentDigest emits full 64-hex dual hashes + version", () => {
|
|
32
|
+
const d = computeContentDigest("the same region text");
|
|
33
|
+
assert.equal(d.contentHash.length, 64);
|
|
34
|
+
assert.equal(d.contentHash2.length, 64);
|
|
35
|
+
assert.equal(d.contentHashVersion, CONTENT_HASH_VERSION);
|
|
36
|
+
assert.equal(d.normalizedText, "the same region text");
|
|
37
|
+
// Secondary is an independent view (reversed) so it differs from primary.
|
|
38
|
+
assert.notEqual(d.contentHash, d.contentHash2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dual-hash: distinct content yields a distinct pair (both must agree to dedup)", () => {
|
|
42
|
+
const a = computeContentDigest("region about authentication");
|
|
43
|
+
const b = computeContentDigest("region about authorization");
|
|
44
|
+
assert.notEqual(a.contentHash, b.contentHash);
|
|
45
|
+
assert.notEqual(a.contentHash2, b.contentHash2);
|
|
46
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { normalize } from "./normalize.js";
|
|
16
|
+
|
|
17
|
+
export const CONTENT_HASH_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
export interface ContentDigest {
|
|
20
|
+
contentHash: string;
|
|
21
|
+
contentHash2: string;
|
|
22
|
+
contentHashVersion: number;
|
|
23
|
+
normalizedText: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Compute the canonical content digest for a (possibly raw) region text. */
|
|
27
|
+
export function computeContentDigest(text: string): ContentDigest {
|
|
28
|
+
const normalizedText = normalize(text);
|
|
29
|
+
const contentHash = createHash("sha256").update(normalizedText).digest("hex");
|
|
30
|
+
// Secondary: hash the reversed normalized string so it's an independent view.
|
|
31
|
+
const contentHash2 = createHash("sha256")
|
|
32
|
+
.update(normalizedText.split("").reverse().join(""))
|
|
33
|
+
.digest("hex");
|
|
34
|
+
return {
|
|
35
|
+
contentHash,
|
|
36
|
+
contentHash2,
|
|
37
|
+
contentHashVersion: CONTENT_HASH_VERSION,
|
|
38
|
+
normalizedText,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
|
|
13
|
+
import { SIGNATURE_VERSION, NUM_HASHES } from "./l1-minhash.js";
|
|
14
|
+
|
|
15
|
+
export const BANDS = 64;
|
|
16
|
+
export const ROWS_PER_BAND = 4; // 64 * 4 = 256 slots (matches minhashSignature length)
|
|
17
|
+
|
|
18
|
+
/** Stable 32-bit FNV-1a (buffer form) for band hashing. */
|
|
19
|
+
function fnv1aBuf(buf: Buffer): number {
|
|
20
|
+
let h = 0x811c9dc5;
|
|
21
|
+
for (let i = 0; i < buf.length; i++) {
|
|
22
|
+
h ^= buf[i];
|
|
23
|
+
h = Math.imul(h, 0x01000193);
|
|
24
|
+
}
|
|
25
|
+
return h >>> 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute the list of LSH bucket keys for a signature within a session.
|
|
30
|
+
* Deterministic: same (session_id, signature) → same keys, every run.
|
|
31
|
+
*/
|
|
32
|
+
export function lshBands(
|
|
33
|
+
signature: number[],
|
|
34
|
+
sessionId: string,
|
|
35
|
+
version: number = SIGNATURE_VERSION,
|
|
36
|
+
): string[] {
|
|
37
|
+
if (signature.length < BANDS * ROWS_PER_BAND) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`lshBands: signature length ${signature.length} < required ${BANDS * ROWS_PER_BAND}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const keys: string[] = [];
|
|
43
|
+
const seedPrefix = Buffer.from(`${sessionId}|${version}|`, "utf-8");
|
|
44
|
+
for (let band = 0; band < BANDS; band++) {
|
|
45
|
+
const start = band * ROWS_PER_BAND;
|
|
46
|
+
const slice = signature.slice(start, start + ROWS_PER_BAND);
|
|
47
|
+
const body = Buffer.allocUnsafe(ROWS_PER_BAND * 4);
|
|
48
|
+
for (let r = 0; r < ROWS_PER_BAND; r++) {
|
|
49
|
+
body.writeUInt32LE(slice[r] >>> 0, r * 4);
|
|
50
|
+
}
|
|
51
|
+
const combined = Buffer.concat([seedPrefix, body]);
|
|
52
|
+
keys.push(`b${band}:${fnv1aBuf(combined).toString(16)}`);
|
|
53
|
+
}
|
|
54
|
+
return keys;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Convenience: derive bands directly from text (used by callers without a cached sig). */
|
|
58
|
+
export function bandsForText(
|
|
59
|
+
signature: number[],
|
|
60
|
+
sessionId: string,
|
|
61
|
+
version: number = SIGNATURE_VERSION,
|
|
62
|
+
): string[] {
|
|
63
|
+
if (signature.length !== NUM_HASHES) {
|
|
64
|
+
throw new Error(`bandsForText: expected signature length ${NUM_HASHES}, got ${signature.length}`);
|
|
65
|
+
}
|
|
66
|
+
return lshBands(signature, sessionId, version);
|
|
67
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
|
|
16
|
+
import { normalize } from "./normalize.js";
|
|
17
|
+
|
|
18
|
+
export const SIGNATURE_VERSION = 1;
|
|
19
|
+
export const NUM_HASHES = 256;
|
|
20
|
+
export const SHINGLE_SIZE = 5; // char 5-grams
|
|
21
|
+
const MAX_SHINGLES = 50_000; // QA #7/#15 complexity cap
|
|
22
|
+
const SEED = 0xdeadbeef;
|
|
23
|
+
const P = 2147483647; // 2^31 - 1, Mersenne prime
|
|
24
|
+
|
|
25
|
+
/** Per-index universal-hashing coefficients, derived deterministically from SEED. */
|
|
26
|
+
function coeffA(i: number): number {
|
|
27
|
+
return (SEED + i * 2 + 1) % P;
|
|
28
|
+
}
|
|
29
|
+
function coeffB(i: number): number {
|
|
30
|
+
return (SEED * 3 + i * 7 + 13) % P;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Stable 32-bit FNV-1a of a shingle (the `x` fed to the universal hashes). */
|
|
34
|
+
function shingleHash(gram: string): number {
|
|
35
|
+
let h = 0x811c9dc5;
|
|
36
|
+
for (let k = 0; k < gram.length; k++) {
|
|
37
|
+
h ^= gram.charCodeAt(k);
|
|
38
|
+
h = Math.imul(h, 0x01000193);
|
|
39
|
+
}
|
|
40
|
+
return h >>> 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Char n-gram shingle set (deduped) of normalized text, capped at MAX_SHINGLES. */
|
|
44
|
+
export function shingles(text: string, size = SHINGLE_SIZE): number[] {
|
|
45
|
+
const norm = normalize(text);
|
|
46
|
+
if (norm.length === 0) return [];
|
|
47
|
+
const set = new Set<number>();
|
|
48
|
+
if (norm.length < size) {
|
|
49
|
+
set.add(shingleHash(norm));
|
|
50
|
+
} else {
|
|
51
|
+
for (let i = 0; i + size <= norm.length; i++) {
|
|
52
|
+
set.add(shingleHash(norm.slice(i, i + size)));
|
|
53
|
+
if (set.size >= MAX_SHINGLES) break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...set];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compute the 256-element MinHash signature of a text. Each slot is the minimum,
|
|
61
|
+
* over all shingles, of the i-th universal hash. Empty text → all-P sentinel.
|
|
62
|
+
*/
|
|
63
|
+
export function minhashSignature(text: string): number[] {
|
|
64
|
+
const grams = shingles(text);
|
|
65
|
+
const sig = new Array<number>(NUM_HASHES).fill(P);
|
|
66
|
+
if (grams.length === 0) return sig;
|
|
67
|
+
for (let i = 0; i < NUM_HASHES; i++) {
|
|
68
|
+
const a = coeffA(i);
|
|
69
|
+
const b = coeffB(i);
|
|
70
|
+
let min = P;
|
|
71
|
+
for (const x of grams) {
|
|
72
|
+
// (a*x + b) mod p — use Number math; a,x < 2^31 so a*x < 2^62, within
|
|
73
|
+
// double-precision integer range (2^53) only if reduced; reduce a*x first.
|
|
74
|
+
const ax = (a * (x % P)) % P;
|
|
75
|
+
const h = (ax + b) % P;
|
|
76
|
+
if (h < min) min = h;
|
|
77
|
+
}
|
|
78
|
+
sig[i] = min;
|
|
79
|
+
}
|
|
80
|
+
return sig;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Estimated Jaccard similarity of two signatures (fraction of equal slots). */
|
|
84
|
+
export function signatureSimilarity(a: number[], b: number[]): number {
|
|
85
|
+
const n = Math.min(a.length, b.length);
|
|
86
|
+
if (n === 0) return 0;
|
|
87
|
+
let equal = 0;
|
|
88
|
+
for (let i = 0; i < n; i++) if (a[i] === b[i]) equal++;
|
|
89
|
+
return equal / n;
|
|
90
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
|
|
16
|
+
import { normalize } from "./normalize.js";
|
|
17
|
+
|
|
18
|
+
const TRIGRAM_SIZE = 3;
|
|
19
|
+
export const L1_VERIFY_THRESHOLD = 0.85;
|
|
20
|
+
|
|
21
|
+
/** Extract the set of character trigrams from normalized text. */
|
|
22
|
+
function trigrams(text: string): Set<string> {
|
|
23
|
+
const norm = normalize(text);
|
|
24
|
+
const set = new Set<string>();
|
|
25
|
+
if (norm.length === 0) return set;
|
|
26
|
+
if (norm.length < TRIGRAM_SIZE) {
|
|
27
|
+
set.add(norm);
|
|
28
|
+
return set;
|
|
29
|
+
}
|
|
30
|
+
for (let i = 0; i + TRIGRAM_SIZE <= norm.length; i++) {
|
|
31
|
+
set.add(norm.slice(i, i + TRIGRAM_SIZE));
|
|
32
|
+
}
|
|
33
|
+
return set;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Trigram similarity in [0,1]. We use overlap coefficient (|A∩B| / min(|A|,|B|))
|
|
38
|
+
* which, like pg_trgm's `similarity`, is robust when one text is a substring of
|
|
39
|
+
* the other — better than Jaccard for the near-dup "one-word edit" case.
|
|
40
|
+
*/
|
|
41
|
+
export function trigramSimilarity(a: string, b: string): number {
|
|
42
|
+
const ta = trigrams(a);
|
|
43
|
+
const tb = trigrams(b);
|
|
44
|
+
if (ta.size === 0 || tb.size === 0) return 0;
|
|
45
|
+
let inter = 0;
|
|
46
|
+
const smaller = ta.size <= tb.size ? ta : tb;
|
|
47
|
+
const larger = smaller === ta ? tb : ta;
|
|
48
|
+
for (const g of smaller) if (larger.has(g)) inter++;
|
|
49
|
+
return inter / smaller.size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when `a` and `b` are near-duplicates under the L1 threshold. */
|
|
53
|
+
export function isNearDuplicate(a: string, b: string, threshold = L1_VERIFY_THRESHOLD): boolean {
|
|
54
|
+
return trigramSimilarity(a, b) >= threshold;
|
|
55
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
|
|
7
|
+
test("minhashSignature is deterministic across calls (same input → same sig)", () => {
|
|
8
|
+
const a = minhashSignature("the authentication module handles login securely");
|
|
9
|
+
const b = minhashSignature("the authentication module handles login securely");
|
|
10
|
+
assert.deepEqual(a, b);
|
|
11
|
+
assert.equal(a.length, NUM_HASHES);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("minhashSignature of near-identical text is more similar than of unrelated text", () => {
|
|
15
|
+
const s1 = minhashSignature("user logged in and viewed the dashboard");
|
|
16
|
+
const s2 = minhashSignature("user logged in and viewed the dashboard page"); // one word added
|
|
17
|
+
const s3 = minhashSignature("the compiler optimized the hot loop aggressively");
|
|
18
|
+
assert.ok(signatureSimilarity(s1, s2) > signatureSimilarity(s1, s3));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("lshBands produces BANDS keys and is stable per session", () => {
|
|
22
|
+
const sig = minhashSignature("some text to band");
|
|
23
|
+
const k1 = lshBands(sig, "sess_a", 1);
|
|
24
|
+
const k2 = lshBands(sig, "sess_a", 1);
|
|
25
|
+
assert.equal(k1.length, BANDS);
|
|
26
|
+
assert.equal(BANDS * ROWS_PER_BAND, NUM_HASHES);
|
|
27
|
+
assert.deepEqual(k1, k2);
|
|
28
|
+
// Different session → different bucket keys (scoped, deterministic).
|
|
29
|
+
const k3 = lshBands(sig, "sess_b", 1);
|
|
30
|
+
assert.notDeepEqual(k1, k3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("lshBands keys are stable across restarts (no entropy source)", () => {
|
|
34
|
+
// Recomputed in a fresh call path — determinism is structural, not time-based.
|
|
35
|
+
const sig = minhashSignature("deterministic bucket key check");
|
|
36
|
+
const first = lshBands(sig, "sess_x", 1);
|
|
37
|
+
const again = lshBands(sig, "sess_x", 1);
|
|
38
|
+
assert.deepEqual(first, again);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("trigramSimilarity is 1 for identical, high for one-word-edit, low for unrelated", () => {
|
|
42
|
+
const a = "the quick brown fox jumps";
|
|
43
|
+
assert.equal(trigramSimilarity(a, a), 1);
|
|
44
|
+
assert.ok(trigramSimilarity(a, "the quick brown fox jumps over") >= L1_VERIFY_THRESHOLD);
|
|
45
|
+
assert.ok(trigramSimilarity(a, "a completely different sentence about databases") < 0.5);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("isNearDuplicate thresholds at 0.85", () => {
|
|
49
|
+
assert.equal(isNearDuplicate("user fixed the parser bug", "user fixed the parser bug today"), true);
|
|
50
|
+
assert.equal(isNearDuplicate("alpha beta gamma", "totally different words here"), false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("shingles are capped at 50K (complexity guard)", () => {
|
|
54
|
+
const huge = "x".repeat(200_000);
|
|
55
|
+
const sh = shingles(huge);
|
|
56
|
+
assert.ok(sh.length <= 50_000);
|
|
57
|
+
});
|
package/src/dedup/mmr.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
|
|
13
|
+
import type { Vector } from "../embedder.js";
|
|
14
|
+
import { cosineSimilarity } from "../embedder.js";
|
|
15
|
+
|
|
16
|
+
export const MMR_LAMBDA = 0.5;
|
|
17
|
+
|
|
18
|
+
export interface MmrItem<T> {
|
|
19
|
+
item: T;
|
|
20
|
+
vector: Vector; // embedding used for redundancy scoring
|
|
21
|
+
relevance: number; // base relevance score (e.g. query cosine)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rerank `items` by MMR. Returns the items in MMR order, capped at `k`.
|
|
26
|
+
* `lambda` balances relevance vs diversity (1 = pure relevance, 0 = max diversity).
|
|
27
|
+
*/
|
|
28
|
+
export function mmrRerank<T>(items: MmrItem<T>[], k: number, lambda = MMR_LAMBDA): T[] {
|
|
29
|
+
if (items.length === 0) return [];
|
|
30
|
+
const remaining = [...items];
|
|
31
|
+
const selected: MmrItem<T>[] = [];
|
|
32
|
+
const cap = Math.min(k, items.length);
|
|
33
|
+
|
|
34
|
+
while (selected.length < cap && remaining.length > 0) {
|
|
35
|
+
let bestIdx = 0;
|
|
36
|
+
let bestScore = -Infinity;
|
|
37
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
38
|
+
const cand = remaining[i];
|
|
39
|
+
// Max similarity to already-selected (redundancy penalty).
|
|
40
|
+
let maxSimToSelected = 0;
|
|
41
|
+
for (const sel of selected) {
|
|
42
|
+
const sim = cosineSimilarity(cand.vector, sel.vector);
|
|
43
|
+
if (sim > maxSimToSelected) maxSimToSelected = sim;
|
|
44
|
+
}
|
|
45
|
+
const mmr = lambda * cand.relevance - (1 - lambda) * maxSimToSelected;
|
|
46
|
+
if (mmr > bestScore) {
|
|
47
|
+
bestScore = mmr;
|
|
48
|
+
bestIdx = i;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
selected.push(remaining.splice(bestIdx, 1)[0]);
|
|
52
|
+
}
|
|
53
|
+
return selected.map((s) => s.item);
|
|
54
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
|
|
16
|
+
const MAX_CHARS = 32_768;
|
|
17
|
+
|
|
18
|
+
// ANSI/VT100 escape sequence: ESC (0x1B) [ ...params... [ -/]* final-byte.
|
|
19
|
+
// Built from the code point so the source file contains no literal escape byte.
|
|
20
|
+
const ESC = String.fromCharCode(0x1b);
|
|
21
|
+
const ANSI_RE = new RegExp(ESC + "\\[[0-?]*[ -/]*[@-~]", "g");
|
|
22
|
+
|
|
23
|
+
/** Strip ANSI/VT100 escape sequences. */
|
|
24
|
+
export function stripAnsi(text: string): string {
|
|
25
|
+
return text.replace(ANSI_RE, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize text to its canonical dedup form. Deterministic and idempotent:
|
|
30
|
+
* `normalize(normalize(x)) === normalize(x)`.
|
|
31
|
+
*/
|
|
32
|
+
export function normalize(text: string): string {
|
|
33
|
+
if (!text) return "";
|
|
34
|
+
let out = stripAnsi(text);
|
|
35
|
+
out = out.normalize("NFC");
|
|
36
|
+
out = out.toLocaleLowerCase(); // case-fold so "Foo"/"FOO" collapse to one key
|
|
37
|
+
out = out.replace(/\r\n?/g, "\n"); // CRLF / CR → LF
|
|
38
|
+
out = out.replace(/\s+/g, " ").trim();
|
|
39
|
+
if (out.length > MAX_CHARS) out = out.slice(0, MAX_CHARS);
|
|
40
|
+
return out;
|
|
41
|
+
}
|