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,177 @@
1
+ /**
2
+ * tree.ts — RAPTOR hierarchical summary-tree builder (Sprint 13, Phase 6).
3
+ *
4
+ * Builds a multi-level tree of summary nodes over leaf chunks: leaves are the
5
+ * original regions; each higher level summarizes clusters of the level below
6
+ * until a single root remains. QA ops: a wall-clock budget guard — on exhaustion
7
+ * we build an extractive fallback root. <10 leaves → a single summary node.
8
+ *
9
+ * Node model (kept simple + flat): every RaptorNode stores the LIST OF LEAF IDS
10
+ * it ultimately covers in `children` (not a mix of node/leaf ids). So the node
11
+ * map holds ONLY internal summary nodes — never per-leaf wrappers — which is
12
+ * what makes RAPTOR consolidate (nodes.size << leaves) and makes retrieval's
13
+ * leaf walk trivial.
14
+ *
15
+ * PREVENT-PI-004: no network here. summarizeCluster() may call a localhost
16
+ * Ollama (annotated in summarizer.ts); extractive is the default.
17
+ */
18
+ import { kmeanspp, meanVector } from "./kmeans.js";
19
+ import { summarizeCluster } from "./summarizer.js";
20
+ import { applyHallucinationGuardrails, sourceTokenSet } from "./guardrails.js";
21
+ const DEFAULT_BUDGET_MS = 5000;
22
+ const DEFAULT_CLUSTERS = 5;
23
+ function defaultNextId(level, index) {
24
+ return `r${level}_${index}`;
25
+ }
26
+ function summarizeInto(item, centroid, embedder, consistencyThreshold) {
27
+ let summary = summarizeCluster(item.messages);
28
+ const guard = applyHallucinationGuardrails({
29
+ summary: summary.summary,
30
+ sources: item.sources,
31
+ centroid,
32
+ embedder,
33
+ sourceTokens: sourceTokenSet(item.sources),
34
+ consistencyThreshold,
35
+ });
36
+ if (guard.marker === "extractive_fallback") {
37
+ summary = summarizeCluster(item.messages); // deterministic extractive text
38
+ }
39
+ return {
40
+ summary: summary.summary,
41
+ tokenEstimate: summary.tokenEstimate,
42
+ qualityMarker: guard.marker === "extractive_fallback" ? "low" : guard.marker,
43
+ };
44
+ }
45
+ /**
46
+ * Build a RAPTOR tree from leaf chunks. Synchronous; guarded by an elapsed-time
47
+ * budget. Returns a tree whose `nodes` map holds ONLY internal summary nodes.
48
+ */
49
+ export function buildRaptorTree(leaves, opts) {
50
+ const embedder = opts.embedder;
51
+ const budgetMs = opts.budgetMs ?? DEFAULT_BUDGET_MS;
52
+ const clustersPerLevel = opts.clustersPerLevel ?? DEFAULT_CLUSTERS;
53
+ const nextId = opts.nextId ?? defaultNextId;
54
+ const now = opts.now ?? (() => Date.now());
55
+ const start = now();
56
+ const within = () => now() - start <= budgetMs;
57
+ const nodes = new Map();
58
+ // <10 leaves → single summary root (no hierarchy needed).
59
+ if (leaves.length < 10) {
60
+ const item = {
61
+ id: "root",
62
+ embedding: meanVector(leaves.map((l) => l.embedding)),
63
+ leafIds: leaves.map((l) => l.id),
64
+ messages: leaves.flatMap((l) => l.messages),
65
+ sources: leaves.map((l) => l.sourceText),
66
+ };
67
+ const centroid = item.embedding;
68
+ const { summary, tokenEstimate, qualityMarker } = summarizeInto(item, centroid, embedder, opts.consistencyThreshold);
69
+ const rootId = nextId(0, 0);
70
+ nodes.set(rootId, {
71
+ id: rootId,
72
+ level: 0,
73
+ parentId: null,
74
+ children: item.leafIds,
75
+ summary,
76
+ embedding: centroid,
77
+ qualityMarker,
78
+ tokenEstimate,
79
+ });
80
+ return { nodes, rootId, levels: 1, timedOut: false };
81
+ }
82
+ let currentLevel = leaves.map((l) => ({
83
+ id: l.id,
84
+ embedding: l.embedding,
85
+ leafIds: [l.id],
86
+ messages: l.messages,
87
+ sources: [l.sourceText],
88
+ }));
89
+ let level = 0;
90
+ while (currentLevel.length > 1) {
91
+ if (!within())
92
+ return extractiveFallbackRoot(leaves, nodes, nextId);
93
+ // Once we're down to a handful of items, collapse them all into one root.
94
+ // (k === currentLevel.length would make every item its own singleton
95
+ // cluster and never shrink — an infinite loop until the budget blows.)
96
+ if (currentLevel.length <= clustersPerLevel) {
97
+ const merged = {
98
+ id: "merge",
99
+ embedding: meanVector(currentLevel.map((c) => c.embedding)),
100
+ leafIds: currentLevel.flatMap((c) => c.leafIds),
101
+ messages: currentLevel.flatMap((c) => c.messages),
102
+ sources: currentLevel.flatMap((c) => c.sources),
103
+ };
104
+ const centroid = merged.embedding;
105
+ const { summary, tokenEstimate, qualityMarker } = summarizeInto(merged, centroid, embedder, opts.consistencyThreshold);
106
+ const rootId = nextId(level + 1, 0);
107
+ nodes.set(rootId, {
108
+ id: rootId,
109
+ level: level + 1,
110
+ parentId: null,
111
+ children: merged.leafIds,
112
+ summary,
113
+ embedding: centroid,
114
+ qualityMarker,
115
+ tokenEstimate,
116
+ });
117
+ return { nodes, rootId, levels: level + 2, timedOut: false };
118
+ }
119
+ const k = Math.max(1, Math.min(clustersPerLevel, currentLevel.length));
120
+ const clustered = kmeanspp(currentLevel.map((c) => c.embedding), k, { seed: 0x1234 + level });
121
+ const groups = Array.from({ length: clustered.k }, () => []);
122
+ clustered.assignments.forEach((c, i) => groups[c].push(currentLevel[i]));
123
+ const nextLevel = [];
124
+ for (let g = 0; g < groups.length; g++) {
125
+ const group = groups[g];
126
+ if (group.length === 0)
127
+ continue;
128
+ const merged = {
129
+ id: nextId(level + 1, g),
130
+ embedding: clustered.centroids[g],
131
+ leafIds: group.flatMap((c) => c.leafIds),
132
+ messages: group.flatMap((c) => c.messages),
133
+ sources: group.flatMap((c) => c.sources),
134
+ };
135
+ const { summary, tokenEstimate, qualityMarker } = summarizeInto(merged, merged.embedding, embedder, opts.consistencyThreshold);
136
+ nodes.set(merged.id, {
137
+ id: merged.id,
138
+ level: level + 1,
139
+ parentId: null,
140
+ children: merged.leafIds,
141
+ summary,
142
+ embedding: merged.embedding,
143
+ qualityMarker,
144
+ tokenEstimate,
145
+ });
146
+ nextLevel.push(merged);
147
+ }
148
+ currentLevel = nextLevel;
149
+ level++;
150
+ }
151
+ const root = currentLevel[0];
152
+ return {
153
+ nodes,
154
+ rootId: root ? root.id : null,
155
+ levels: level + 1,
156
+ timedOut: false,
157
+ };
158
+ }
159
+ /**
160
+ * Budget-exceeded fallback: build a single deterministic extractive root over
161
+ * all leaves and mark it low quality. Keeps a valid (if shallow) tree.
162
+ */
163
+ function extractiveFallbackRoot(leaves, nodes, nextId) {
164
+ const summary = summarizeCluster(leaves.flatMap((l) => l.messages));
165
+ const rootId = nextId(99, 0);
166
+ nodes.set(rootId, {
167
+ id: rootId,
168
+ level: 99,
169
+ parentId: null,
170
+ children: leaves.map((l) => l.id),
171
+ summary: summary.summary,
172
+ embedding: meanVector(leaves.map((l) => l.embedding)),
173
+ qualityMarker: "low",
174
+ tokenEstimate: summary.tokenEstimate,
175
+ });
176
+ return { nodes, rootId, levels: 2, timedOut: true };
177
+ }
@@ -0,0 +1,219 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { VectorStore, L2_ENABLED } from "../vectorStore.js";
7
+ import { mmrRerank } from "./mmr.js";
8
+ import { topK } from "./topk.js";
9
+ import { cosineSimilarity, defaultEmbedder } from "../embedder.js";
10
+ import { upsertCheckpoint } from "../store/sqlite.js";
11
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-s12-"));
12
+ let counter = 0;
13
+ function store(opts = {}) {
14
+ const dir = join(baseTmp, `run-${counter++}`);
15
+ return new VectorStore({ stateDir: dir, ...opts });
16
+ }
17
+ // --- MMR diversity ---------------------------------------------------------
18
+ test("mmrRerank diversifies: a cluster yields distinct-relevance results", () => {
19
+ const e = defaultEmbedder();
20
+ // Three near-identical vectors + one distinct.
21
+ const v = e.embed("the compiler optimized the parser hot loop");
22
+ const v2 = e.embed("the compiler optimized the parser hot loops"); // near-dup of v
23
+ const v3 = e.embed("the compiler optimized the parser hot loop now"); // near-dup of v
24
+ const vDistinct = e.embed("the database added a covering index for queries");
25
+ const items = [
26
+ { item: "a", vector: v, relevance: 0.9 },
27
+ { item: "b", vector: v2, relevance: 0.88 },
28
+ { item: "c", vector: v3, relevance: 0.87 },
29
+ { item: "d", vector: vDistinct, relevance: 0.5 },
30
+ ];
31
+ const ranked = mmrRerank(items, 2, 0.5);
32
+ assert.equal(ranked.length, 2);
33
+ // The near-dup cluster (a) and the distinct one (d) should both survive.
34
+ assert.ok(ranked.includes("a"));
35
+ assert.ok(ranked.includes("d"));
36
+ assert.ok(!ranked.includes("b") || !ranked.includes("c"));
37
+ });
38
+ test("mmrRerank with lambda=1 is pure relevance ranking", () => {
39
+ const items = [
40
+ { item: "low", vector: [1, 0, 0], relevance: 0.1 },
41
+ { item: "high", vector: [0, 1, 0], relevance: 0.9 },
42
+ ];
43
+ const ranked = mmrRerank(items, 2, 1);
44
+ assert.deepEqual(ranked, ["high", "low"]);
45
+ });
46
+ // --- Heap top-k ------------------------------------------------------------
47
+ test("topK matches brute-force full sort on a fixture", () => {
48
+ const items = Array.from({ length: 1000 }, (_, i) => ({ item: i, score: Math.sin(i) * 100 + (i % 7) }));
49
+ for (const k of [1, 3, 10, 50]) {
50
+ const heap = topK(items, k).map((s) => s.item).sort((a, b) => b - a);
51
+ const brute = [...items].sort((a, b) => b.score - a.score).slice(0, k).map((s) => s.item).sort((a, b) => b - a);
52
+ assert.deepEqual(heap, brute, `topK(${k}) should match brute force`);
53
+ }
54
+ });
55
+ test("topK with k >= n returns all (descending by score)", () => {
56
+ const items = [{ item: "x", score: 1 }, { item: "y", score: 2 }];
57
+ assert.deepEqual(topK(items, 5).map((s) => s.item), ["y", "x"]);
58
+ });
59
+ // --- Empty-vector guard ----------------------------------------------------
60
+ test("cosineSimilarity guards empty vector → 0 (no NaN)", () => {
61
+ assert.equal(cosineSimilarity([], [1, 2, 3]), 0);
62
+ assert.equal(cosineSimilarity([0, 0, 0], [1, 2, 3]), 0);
63
+ assert.ok(!Number.isNaN(cosineSimilarity([], [])));
64
+ });
65
+ // --- L2_ENABLED flag -------------------------------------------------------
66
+ test("L2_ENABLED defaults true; search still returns hits", () => {
67
+ assert.equal(L2_ENABLED, true);
68
+ const s = store();
69
+ s.add({ sessionId: "sess_l2", summary: "investigated the parser", regionText: "investigated src/parser.ts and added a tokenizer", timestamp: 1 });
70
+ const hits = s.search("sess_l2", "src/parser.ts tokenizer", 3);
71
+ assert.ok(hits.length >= 1);
72
+ });
73
+ test("L2_ENABLED=false skips semantic tier but L0/L1 still work", () => {
74
+ const s = store({ l2Enabled: false });
75
+ const r1 = s.add({ sessionId: "sess_l2off", summary: "x", regionText: "the auth module validates the session token", timestamp: 1 });
76
+ const r2 = s.add({ sessionId: "sess_l2off", summary: "x", regionText: "the auth module validates the session token", timestamp: 2 });
77
+ assert.equal(r2.deduped, true); // L0 catches exact
78
+ assert.equal(r1.deduped, false);
79
+ });
80
+ // --- SemDeDup --------------------------------------------------------------
81
+ // Seed a legacy session directly (bypassing add-time dedup tiers) so SemDeDup
82
+ // has a redundant pair to prune — its real job is offline cleanup of data that
83
+ // predates / escaped the online cascade.
84
+ function seed(dir, sessionId, rows) {
85
+ const e = defaultEmbedder();
86
+ for (const r of rows) {
87
+ const cp = {
88
+ checkpointId: r.id,
89
+ sessionId,
90
+ summary: r.text,
91
+ keyDecisions: [],
92
+ nextSteps: [],
93
+ filesModified: [],
94
+ tokenEstimate: r.tok,
95
+ regionHash: `r-${r.id}`,
96
+ embedding: e.embed(r.text),
97
+ timestamp: 1,
98
+ };
99
+ upsertCheckpoint(cp, dir);
100
+ }
101
+ }
102
+ test("semDedup marks redundant near-identical rows 'removed' and search excludes them", () => {
103
+ const dir = join(baseTmp, `run-${counter++}`);
104
+ const s = new VectorStore({ stateDir: dir });
105
+ seed(dir, "sess_sd", [
106
+ { id: "chkpt_001", text: "the cache stores parsed ast nodes for fast lookup", tok: 100 },
107
+ { id: "chkpt_002", text: "the cache stores parsed ast nodes for fast lookup and reuse", tok: 900 },
108
+ ]);
109
+ const removed = s.semDedup("sess_sd", 0.85);
110
+ assert.equal(removed, 1);
111
+ const st = s.list("sess_sd");
112
+ const dropped = st.find((c) => c.dedupStatus === "removed");
113
+ assert.ok(dropped);
114
+ assert.equal(dropped.checkpointId, "chkpt_001"); // lower tokenEstimate removed
115
+ // Search excludes the removed row (only one active remains).
116
+ const hits = s.search("sess_sd", "cache parsed ast nodes", 5);
117
+ assert.equal(hits.length, 1);
118
+ });
119
+ test("semDedup is idempotent (re-run removes nothing new)", () => {
120
+ const dir = join(baseTmp, `run-${counter++}`);
121
+ const s = new VectorStore({ stateDir: dir });
122
+ seed(dir, "sess_sd2", [
123
+ { id: "chkpt_001", text: "identical region text for the dedup job now", tok: 100 },
124
+ { id: "chkpt_002", text: "identical region text for the dedup job right now", tok: 200 },
125
+ ]);
126
+ const first = s.semDedup("sess_sd2", 0.85);
127
+ const second = s.semDedup("sess_sd2", 0.85);
128
+ assert.equal(first, 1);
129
+ assert.equal(second, 0);
130
+ });
131
+ // --- HttpEmbedder (BYO localhost backend) ----------------------------------
132
+ // Hermetic: a self-test server returns a deterministic embedding. The server
133
+ // runs in an INDEPENDENT child process (its own event loop) so that when
134
+ // HttpEmbedder.embed() blocks the *parent* via spawnSync, the server can still
135
+ // accept the connection — hosting it in-process would deadlock.
136
+ import { spawn } from "node:child_process";
137
+ const ECHO_SERVER = String.raw `
138
+ import { createServer } from "node:http";
139
+ const s = createServer((req, res) => {
140
+ let b = "";
141
+ req.on("data", (c) => (b += c));
142
+ req.on("end", () => {
143
+ const input = JSON.parse(b).input || [""];
144
+ const text = (input[0] || "").toLowerCase().replace(/\s+/g, " ");
145
+ const vec = new Array(8).fill(0);
146
+ for (const w of text.split(" ")) {
147
+ let h = 0x811c9dc5;
148
+ for (let i = 0; i < w.length; i++) { h ^= w.charCodeAt(i); h = Math.imul(h, 0x01000193); }
149
+ vec[h % 8] += 1;
150
+ }
151
+ res.setHeader("content-type", "application/json");
152
+ res.end(JSON.stringify({ data: [{ embedding: vec }] }));
153
+ });
154
+ });
155
+ s.listen(0, "127.0.0.1", () => process.stdout.write(String(s.address().port)));
156
+ `;
157
+ // A simpler shape-only server: always returns { data: [[0.1,0.2,0.3]] }.
158
+ const DATA_ARR_SERVER = String.raw `
159
+ import { createServer } from "node:http";
160
+ const s = createServer((_req, res) => {
161
+ res.setHeader("content-type", "application/json");
162
+ res.end(JSON.stringify({ data: [[0.1, 0.2, 0.3]] }));
163
+ });
164
+ s.listen(0, "127.0.0.1", () => process.stdout.write(String(s.address().port)));
165
+ `;
166
+ /** Spawn an independent echo server; resolves with its url once the port is up. */
167
+ function startEchoServer(shape) {
168
+ const script = shape === "data-arr" ? DATA_ARR_SERVER : ECHO_SERVER;
169
+ const proc = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "ignore"] });
170
+ return new Promise((resolve, reject) => {
171
+ let buf = "";
172
+ proc.stdout.on("data", (d) => {
173
+ buf += d.toString();
174
+ const m = buf.match(/(\d+)/);
175
+ if (m)
176
+ resolve({ url: `http://127.0.0.1:${m[1]}`, proc });
177
+ });
178
+ proc.on("error", reject);
179
+ setTimeout(() => reject(new Error("echo server did not start")), 5000);
180
+ });
181
+ }
182
+ test("HttpEmbedder parses OpenAI-style response and resolves dim", async () => {
183
+ const { url, proc } = await startEchoServer();
184
+ try {
185
+ const { HttpEmbedder } = await import("../httpEmbedder.js");
186
+ const emb = new HttpEmbedder({ url });
187
+ assert.equal(emb.dim, 0); // unknown until first embed
188
+ const v = emb.embed("the parser optimized the hot loop");
189
+ assert.equal(v.length, 8); // echo server returns 8-dim
190
+ assert.equal(emb.dim, 8); // resolved after first call
191
+ const v2 = emb.embed("the parser optimized the hot loop");
192
+ assert.deepEqual(v2, v); // deterministic
193
+ }
194
+ finally {
195
+ proc.kill();
196
+ }
197
+ });
198
+ test("HttpEmbedder tolerant parser: accepts { data: [[...]] } shape", async () => {
199
+ const { url, proc } = await startEchoServer("data-arr");
200
+ try {
201
+ const { HttpEmbedder } = await import("../httpEmbedder.js");
202
+ const emb = new HttpEmbedder({ url });
203
+ // embed() L2-normalizes; verify the { data: [[...]] } shape parsed (dim 3)
204
+ // and the returned vector is the unit-normalized form of [0.1, 0.2, 0.3].
205
+ const v = emb.embed("x");
206
+ assert.equal(v.length, 3);
207
+ const norm = Math.sqrt(0.1 ** 2 + 0.2 ** 2 + 0.3 ** 2);
208
+ assert.ok(Math.abs(v[0] - 0.1 / norm) < 1e-9);
209
+ assert.ok(Math.abs(v[1] - 0.2 / norm) < 1e-9);
210
+ assert.ok(Math.abs(v[2] - 0.3 / norm) < 1e-9);
211
+ }
212
+ finally {
213
+ proc.kill();
214
+ }
215
+ });
216
+ // --- cleanup ---------------------------------------------------------------
217
+ test("Sprint 12 cleanup", () => {
218
+ rmSync(baseTmp, { recursive: true, force: true });
219
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * topk.ts — min-heap based top-K selection (Sprint 12, QA #4).
3
+ *
4
+ * Replaces the O(N log N) full `.sort()` in search() with an O(N log k) heap,
5
+ * which matters once a session holds thousands of checkpoints. Generic over any
6
+ * scored item with a numeric `score`.
7
+ *
8
+ * Pure, no deps, no network (PREVENT-PI-004).
9
+ */
10
+ /**
11
+ * Return the `k` highest-scoring items (stable insertion order on ties).
12
+ * O(N log k) — a bounded min-heap of size k.
13
+ */
14
+ export function topK(items, k) {
15
+ if (k <= 0)
16
+ return [];
17
+ if (items.length <= k)
18
+ return [...items].sort((a, b) => b.score - a.score);
19
+ // Min-heap of the current top-k, stored as a flat array of Scored<T>.
20
+ const heap = [];
21
+ const push = (e) => {
22
+ heap.push(e);
23
+ let i = heap.length - 1;
24
+ while (i > 0) {
25
+ const parent = (i - 1) >> 1;
26
+ if (heap[parent].score <= heap[i].score)
27
+ break;
28
+ [heap[parent], heap[i]] = [heap[i], heap[parent]];
29
+ i = parent;
30
+ }
31
+ };
32
+ const siftDown = (start) => {
33
+ let i = start;
34
+ for (;;) {
35
+ const l = 2 * i + 1;
36
+ const r = 2 * i + 2;
37
+ let smallest = i;
38
+ if (l < heap.length && heap[l].score < heap[smallest].score)
39
+ smallest = l;
40
+ if (r < heap.length && heap[r].score < heap[smallest].score)
41
+ smallest = r;
42
+ if (smallest === i)
43
+ break;
44
+ [heap[smallest], heap[i]] = [heap[i], heap[smallest]];
45
+ i = smallest;
46
+ }
47
+ };
48
+ for (const it of items) {
49
+ if (heap.length < k) {
50
+ push(it);
51
+ }
52
+ else if (it.score > heap[0].score) {
53
+ // Replace the current minimum with this better-scoring item, then sift
54
+ // it down to restore the min-heap invariant.
55
+ heap[0] = it;
56
+ siftDown(0);
57
+ }
58
+ }
59
+ return heap.sort((a, b) => b.score - a.score);
60
+ }